dev: hello world

This commit is contained in:
vamsi 2022-11-19 19:51:26 +05:30
commit 6037fed3f4
145 changed files with 16848 additions and 0 deletions

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
node_modules
.next
### NextJS ###
# Dependencies
/node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
.history
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Vercel
.vercel

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "es5"
}

201
LICENSE.txt Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 Plane Software Labs Private Limited
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

42
README.md Normal file
View File

@ -0,0 +1,42 @@
<br /><br />
<p align="center">
<a href="https://plane.so">
<img src="https://ik.imagekit.io/w2okwbtu2/plane-logo_0m83xue7R.png?ik-sdk-version=javascript-1.4.3&updatedAt=1668862717084" alt="Plane Logo" width="350">
</a>
</p>
<br />
Plane helps you track your issues, epics, and product roadmaps. Take off and experience the world of project management like never before.
<br /><br />
<p align="center">
<a href="https://discord.com/invite/8SR2N9PAcJ">
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a>
</p>
# Getting Started
Visit https://app.plane.so to get started with Plane.
# Documentation
Coming soon.
# Community
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/8SR2N9PAcJ).
Our Code of Conduct applies to all Plane community channels.
# Contributing
Please see our contributing.md.
# Security
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.

View File

@ -0,0 +1,282 @@
import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { DocumentPlusIcon, FolderPlusIcon, FolderIcon } from "@heroicons/react/24/outline";
// commons
import { classNames } from "constants/common";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/CreateProjectModal";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
// hooks
import useTheme from "lib/hooks/useTheme";
// types
import { IIssue } from "types";
type ItemType = {
name: string;
url?: string;
onClick?: () => void;
};
const CommandPalette: React.FC = () => {
const router = useRouter();
const [query, setQuery] = useState("");
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const { issues, activeProject } = useUser();
const { toggleCollapsed } = useTheme();
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const quickActions = [
{
name: "Add new issue...",
icon: DocumentPlusIcon,
shortcut: "I",
onClick: () => {
setIsIssueModalOpen(true);
},
},
{
name: "Add new project...",
icon: FolderPlusIcon,
shortcut: "P",
onClick: () => {
setIsProjectModalOpen(true);
},
},
];
const handleCommandPaletteClose = () => {
setIsPaletteOpen(false);
setQuery("");
};
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "/") {
e.preventDefault();
setIsPaletteOpen(true);
} else if (e.ctrlKey && e.key === "i") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if (e.ctrlKey && e.key === "p") {
e.preventDefault();
setIsProjectModalOpen(true);
} else if (e.ctrlKey && e.key === "b") {
e.preventDefault();
toggleCollapsed();
} else if (e.ctrlKey && e.key === "h") {
e.preventDefault();
setIsShortcutsModalOpen(true);
} else if (e.ctrlKey && e.key === "q") {
e.preventDefault();
setIsCreateCycleModalOpen(true);
}
},
[toggleCollapsed]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
{activeProject && (
<CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen}
setIsOpen={setIsCreateCycleModalOpen}
projectId={activeProject.id}
/>
)}
<CreateUpdateIssuesModal
isOpen={isIssueModalOpen}
setIsOpen={setIsIssueModalOpen}
projectId={activeProject?.id}
/>
<Transition.Root
show={isPaletteOpen}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<Dialog as="div" className="relative z-10" onClose={handleCommandPaletteClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 overflow-hidden rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<Combobox
onChange={(item: ItemType) => {
const { url, onClick } = item;
if (url) router.push(url);
else if (onClick) onClick();
handleCommandPaletteClose();
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={{
name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex cursor-pointer select-none items-center rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<FolderIcon
className={classNames(
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
active ? "text-opacity-100" : ""
)}
aria-hidden="true"
/>
<span className="ml-3 flex-auto truncate">{issue.name}</span>
{active && (
<span className="ml-3 flex-none text-gray-500">
Jump to...
</span>
)}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
</>
)}
{query === "" && (
<li className="p-2">
<h2 className="sr-only">Quick actions</h2>
<ul className="text-sm text-gray-700">
{quickActions.map((action) => (
<Combobox.Option
key={action.shortcut}
value={{
name: action.name,
onClick: action.onClick,
}}
className={({ active }) =>
classNames(
"flex cursor-default select-none items-center rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<action.icon
className={classNames(
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
active ? "text-opacity-100" : ""
)}
aria-hidden="true"
/>
<span className="ml-3 flex-auto truncate">{action.name}</span>
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
<kbd className="font-sans"></kbd>
<kbd className="font-sans">{action.shortcut}</kbd>
</span>
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<FolderIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default CommandPalette;

View File

@ -0,0 +1,108 @@
import React from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import { XMarkIcon } from "@heroicons/react/20/solid";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={setIsOpen}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white p-8">
<div className="sm:flex sm:items-start">
<div className="text-center sm:text-left w-full">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 flex justify-between"
>
<span>Keyboard Shortcuts</span>
<span>
<button type="button" onClick={() => setIsOpen(false)}>
<XMarkIcon
className="h-6 w-6 text-gray-400 hover:text-gray-500"
aria-hidden="true"
/>
</button>
</span>
</Dialog.Title>
<div className="mt-2 pt-5 flex flex-col gap-y-3 w-full">
{[
{
title: "Navigation",
shortcuts: [
{ key: "/", description: "To open navigator" },
{ key: "↑", description: "Move up" },
{ key: "↓", description: "Move down" },
{ key: "←", description: "Move left" },
{ key: "→", description: "Move right" },
{ key: "Enter", description: "Select" },
{ key: "Esc", description: "Close" },
],
},
{
title: "Common",
shortcuts: [
{ key: "Ctrl + p", description: "To open create project modal" },
{ key: "Ctrl + i", description: "To open create issue modal" },
{ key: "Ctrl + q", description: "To open create cycle modal" },
{ key: "Ctrl + h", description: "To open shortcuts guide" },
],
},
].map(({ title, shortcuts }) => (
<div className="w-full flex flex-col" key={title}>
<p className="font-medium mb-4">{title}</p>
<div className="flex flex-col gap-y-3">
{shortcuts.map(({ key, description }) => (
<div className="flex justify-between" key={key}>
<p className="text-sm text-gray-500">{description}</p>
<div className="flex gap-x-1">
<kbd className="bg-gray-200 text-sm px-1 rounded">{key}</kbd>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ShortcutsModal;

View File

@ -0,0 +1,25 @@
import React, { useState, useEffect } from "react";
// react beautiful dnd
import { Droppable } from "react-beautiful-dnd";
import type { DroppableProps } from "react-beautiful-dnd";
const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
return <Droppable {...props}>{children}</Droppable>;
};
export default StrictModeDroppable;

View File

@ -0,0 +1,131 @@
import React, { useState } from "react";
// react hook form
import { useForm } from "react-hook-form";
// ui
import { Button, Input } from "ui";
import authenticationService from "lib/services/authentication.service";
// icons
import { CheckCircleIcon } from "@heroicons/react/20/solid";
// types
type SignIn = {
email: string;
key?: string;
token?: string;
};
const EmailCodeForm = ({ onSuccess }: any) => {
const [codeSent, setCodeSent] = useState(false);
const {
register,
handleSubmit,
setError,
setValue,
formState: { errors, isSubmitting, dirtyFields, isValid, isDirty },
} = useForm<SignIn>({
defaultValues: {
email: "",
key: "",
token: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const onSubmit = ({ email }: SignIn) => {
console.log(email);
authenticationService
.emailCode({ email })
.then((res) => {
setValue("key", res.key);
setCodeSent(true);
})
.catch((err) => {
console.log(err);
});
};
const handleSignin = (formData: SignIn) => {
authenticationService
.magicSignIn(formData)
.then(async (response) => {
await onSuccess(response);
})
.catch((error) => {
console.log(error);
setError("token" as keyof SignIn, {
type: "manual",
message: error.error,
});
});
};
return (
<>
<form
className="mt-5 space-y-5"
onSubmit={codeSent ? handleSubmit(handleSignin) : handleSubmit(onSubmit)}
>
{codeSent && (
<div className="rounded-md bg-green-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-5 w-5 text-green-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-green-800">
Please check your mail for code.
</p>
</div>
</div>
</div>
)}
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
/>
</div>
{codeSent && (
<div>
<Input
id="token"
type="token"
name="token"
register={register}
validations={{
required: "Code is required",
}}
error={errors.token}
placeholder="Enter code"
/>
</div>
)}
<div>
<Button
disabled={isSubmitting || (!isValid && isDirty)}
className="w-full text-center"
type="submit"
>
{isSubmitting ? "Signing in..." : codeSent ? "Sign In" : "Continue with Email ID"}
</Button>
</div>
</form>
</>
);
};
export default EmailCodeForm;

View File

@ -0,0 +1,111 @@
import React from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// react hook form
import { useForm } from "react-hook-form";
// ui
import { Button, Input } from "ui";
import authenticationService from "lib/services/authentication.service";
// types
type SignIn = {
email: string;
password?: string;
medium?: string;
};
const EmailPasswordForm = ({ onSuccess }: any) => {
const {
register,
handleSubmit,
setError,
setValue,
getValues,
formState: { errors, isSubmitting, dirtyFields, isValid, isDirty },
} = useForm<SignIn>({
defaultValues: {
email: "",
password: "",
medium: "email",
},
mode: "onChange",
reValidateMode: "onChange",
});
const onSubmit = (formData: SignIn) => {
authenticationService
.emailLogin(formData)
.then(async (response) => {
await onSuccess(response);
})
.catch((error) => {
console.log(error);
if (!error?.response?.data) return;
Object.keys(error.response.data).forEach((key) => {
const err = error.response.data[key];
console.log("err", err);
setError(key as keyof SignIn, {
type: "manual",
message: Array.isArray(err) ? err.join(", ") : err,
});
});
});
};
return (
<>
<form className="mt-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
/>
</div>
<div className="mt-5">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password"
/>
</div>
<div className="flex items-center justify-between mt-2">
<div className="text-sm ml-auto">
<Link href={"/forgot-password"}>
<a className="font-medium text-indigo-600 hover:text-indigo-500">
Forgot your password?
</a>
</Link>
</div>
</div>
<div className="mt-5">
<Button
disabled={isSubmitting || (!isValid && isDirty)}
className="w-full text-center"
type="submit"
>
{isSubmitting ? "Signing in..." : "Sign In"}
</Button>
</div>
</form>
</>
);
};
export default EmailPasswordForm;

View File

@ -0,0 +1,144 @@
import React, { useEffect, useRef, useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import projectService from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
// types
import type { IProject } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IProject;
};
const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace, mutateProjects } = useUser();
const { setToastAlert } = useToast();
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !activeWorkspace) return;
await projectService
.deleteProject(activeWorkspace.slug, data.id)
.then(() => {
handleClose();
mutateProjects((prevData) => (prevData ?? []).filter((item) => item.id !== data.id), false);
setToastAlert({
title: "Success",
type: "success",
message: "Project deleted successfully",
});
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={handleClose}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete Project
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete project - {`"`}
<span className="italic">{data?.name}</span>
{`"`} ? All of the data related to the project will be permanently
removed. This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={handleClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ConfirmProjectDeletion;

View File

@ -0,0 +1,233 @@
import React, { useState, useEffect, useCallback } from "react";
// swr
import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// services
import projectServices from "lib/services/project.service";
// fetch keys
import { PROJECTS_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// ui
import { Button, Input, TextArea, Select } from "ui";
// common
import { debounce } from "constants/common";
// types
import { IProject } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const defaultValues: Partial<IProject> = {
name: "",
description: "",
};
const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace } = useUser();
const { setToastAlert } = useToast();
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
watch,
setValue,
} = useForm<IProject>({
defaultValues,
});
const onSubmit = async (formData: IProject) => {
if (!activeWorkspace) return;
await projectServices
.createProject(activeWorkspace.slug, formData)
.then((res) => {
console.log(res);
mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug),
(prevData) => [res, ...(prevData ?? [])],
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Project created successfully",
});
handleClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
const errorMessages = err[key];
setError(key as keyof IProject, {
message: Array.isArray(errorMessages) ? errorMessages.join(", ") : errorMessages,
});
});
});
};
const projectName = watch("name") ?? "";
const projectIdentifier = watch("identifier") ?? "";
const checkIdentifier = (slug: string, value: string) => {
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
console.log(response);
if (response.exists) setError("identifier", { message: "Identifier already exists" });
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
useEffect(() => {
if (projectName && isChangeIdentifierRequired) {
setValue("identifier", projectName.replace(/ /g, "-").toUpperCase().substring(0, 3));
}
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Create Project
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Create a new project to start working on it.
</p>
</div>
<div className="space-y-3">
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
error={errors.name}
register={register}
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>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div>
<Input
id="identifier"
label="Identifier"
name="identifier"
type="text"
placeholder="Enter Project Identifier"
error={errors.identifier}
register={register}
onChange={(e: any) => {
setIsChangeIdentifierRequired(false);
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{
required: "Identifier is required",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 9,
message: "Identifier must at most be of 9 characters",
},
}}
/>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating Project..." : "Create Project"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default CreateProjectModal;

View File

@ -0,0 +1,285 @@
import React from "react";
// swr
import useSWR, { mutate } from "swr";
// react hook form
import { useForm, Controller } from "react-hook-form";
// headless
import { Dialog, Transition, Listbox } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// services
import projectService from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// constants
import { PROJECT_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// ui
import { Button, Select, TextArea } from "ui";
// icons
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
// types
import { ProjectMember, WorkspaceMember } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
members: any[];
};
const defaultValues: Partial<ProjectMember> = {
email: "",
message: "",
};
const ROLE = {
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
};
const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, members }) => {
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const { data: people } = useSWR<WorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
setValue,
control,
} = useForm<ProjectMember>({
defaultValues,
});
const onSubmit = async (formData: ProjectMember) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
await projectService
.inviteProject(activeWorkspace.slug, activeProject.id, formData)
.then((response) => {
console.log(response);
setIsOpen(false);
mutate(
PROJECT_INVITATIONS,
(prevData: any[]) => {
return [{ ...formData, ...response }, ...(prevData ?? [])];
},
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Member added successfully",
});
})
.catch((error) => {
console.log(error);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Invite Members
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Invite members to work on your project.
</p>
</div>
<div className="space-y-3">
<div>
<Controller
control={control}
name="user_id"
rules={{ required: "Please select a member" }}
render={({ field: { value, onChange } }) => (
<Listbox
value={value}
onChange={(data: any) => {
onChange(data.id);
setValue("member_id", data.id);
setValue("email", data.email);
}}
>
{({ open }) => (
<>
<Listbox.Label className="text-gray-500 mb-2">
Email
</Listbox.Label>
<div className="relative">
<Listbox.Button
className={`bg-white relative w-full border 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 ${
errors.user_id ? "border-red-500 bg-red-50" : ""
}`}
>
<span className="block truncate">
{value && value !== ""
? people?.find((p) => p.member.id === value)?.member.email
: "Select email"}
</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) =>
!members.some(
(m: any) => m.email === person.member.email
) && (
<Listbox.Option
key={person.member.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9 text-left`
}
value={{
id: person.member.id,
email: person.member.email,
}}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.email}
</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>
<p className="text-sm text-red-400">
{errors.user_id && errors.user_id.message}
</p>
</>
)}
</Listbox>
)}
></Controller>
</div>
<div>
<div>
<Select
id="role"
label="Role"
name="role"
error={errors.role}
register={register}
validations={{
required: "Role is required",
}}
options={Object.entries(ROLE).map(([key, value]) => ({
value: key,
label: value,
}))}
/>
</div>
</div>
<div>
<TextArea
id="message"
name="message"
label="Message"
placeholder="Enter message"
error={errors.message}
register={register}
/>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending Invitation..." : "Send Invitation"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default SendProjectInvitationModal;

View File

@ -0,0 +1,144 @@
import React, { useEffect, useRef, useState } from "react";
// swr
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "lib/services/cycles.services";
// fetch api
import { CYCLE_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
// types
import type { ICycle } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: ICycle;
};
const ConfirmCycleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !activeWorkspace) return;
await cycleService
.deleteCycle(activeWorkspace.slug, data.project, data.id)
.then(() => {
mutate<ICycle[]>(
CYCLE_LIST(data.project),
(prevData) => prevData?.filter((cycle) => cycle.id !== data?.id),
false
);
handleClose();
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={handleClose}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete Cycle
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete cycle - {`"`}
<span className="italic">{data?.name}</span>
{`"`} ? All of the data related to the cycle will be permanently removed.
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={handleClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ConfirmCycleDeletion;

View File

@ -0,0 +1,238 @@
import React, { useEffect } from "react";
// swr
import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "lib/services/cycles.services";
// fetch keys
import { CYCLE_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// common
import { renderDateFormat } from "constants/common";
// ui
import { Button, Input, TextArea, Select } from "ui";
// types
import type { ICycle } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
projectId: string;
data?: ICycle;
};
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
};
const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace } = useUser();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
} = useForm<ICycle>({
defaultValues,
});
const onSubmit = async (formData: ICycle) => {
if (!activeWorkspace) return;
const payload = {
...formData,
start_date: formData.start_date ? renderDateFormat(formData.start_date) : null,
end_date: formData.end_date ? renderDateFormat(formData.end_date) : null,
};
if (!data) {
await cycleService
.createCycle(activeWorkspace.slug, projectId, payload)
.then((res) => {
mutate<ICycle[]>(CYCLE_LIST(projectId), (prevData) => [res, ...(prevData ?? [])], false);
handleClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof typeof defaultValues, {
message: err[key].join(", "),
});
});
});
} else {
await cycleService
.updateCycle(activeWorkspace.slug, projectId, data.id, payload)
.then((res) => {
mutate<ICycle[]>(
CYCLE_LIST(projectId),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
handleClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof typeof defaultValues, {
message: err[key].join(", "),
});
});
});
}
};
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{data ? "Update" : "Create"} Cycle
</Dialog.Title>
<div className="space-y-3">
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div>
<Select
id="status"
name="status"
label="Status"
error={errors.status}
register={register}
validations={{
required: "Status is required",
}}
options={[
{ label: "Draft", value: "draft" },
{ label: "Started", value: "started" },
{ label: "Completed", value: "completed" },
]}
/>
</div>
<div className="flex gap-x-2">
<div className="w-full">
<Input
id="start_date"
label="Start Date"
name="start_date"
type="date"
placeholder="Enter start date"
error={errors.start_date}
register={register}
/>
</div>
<div className="w-full">
<Input
id="end_date"
label="End Date"
name="end_date"
type="date"
placeholder="Enter end date"
error={errors.end_date}
register={register}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{data
? isSubmitting
? "Updating Cycle..."
: "Update Cycle"
: isSubmitting
? "Creating Cycle..."
: "Create Cycle"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default CreateUpdateCycleModal;

View File

@ -0,0 +1,258 @@
import React from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// headless ui
import { Disclosure, Transition, Menu, Listbox } from "@headlessui/react";
// fetch keys
import { PROJECT_ISSUES_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
// services
import issuesServices from "lib/services/issues.services";
import cycleServices from "lib/services/cycles.services";
// commons
import { classNames, renderShortNumericDateFormat } from "constants/common";
// ui
import { Spinner } from "ui";
// icons
import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
// types
import type { ICycle, SprintViewProps as Props, SprintIssueResponse, IssueResponse } from "types";
const SprintView: React.FC<Props> = ({
sprint,
selectSprint,
workspaceSlug,
projectId,
openIssueModal,
addIssueToSprint,
}) => {
const router = useRouter();
const { data: sprintIssues } = useSWR<SprintIssueResponse[]>(CYCLE_ISSUES(sprint.id), () =>
cycleServices.getCycleIssues(workspaceSlug, projectId, sprint.id)
);
const { data: projectIssues } = useSWR<IssueResponse>(
projectId && workspaceSlug ? PROJECT_ISSUES_LIST(workspaceSlug, projectId) : null,
workspaceSlug ? () => issuesServices.getIssues(workspaceSlug, projectId) : null
);
return (
<div className="w-full flex flex-col gap-y-4 pb-5 relative">
<Disclosure defaultOpen>
{({ open }) => (
<div className="bg-gray-50 py-5 px-5 rounded">
<div className="w-full h-full space-y-6 overflow-auto pb-10">
<div className="w-full flex items-center">
<Disclosure.Button className="w-full">
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
width={22}
className={`text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
/>
</span>
<h2 className="text-xl">{sprint.name}</h2>
<p className="font-light text-gray-500">
{sprint.status === "started"
? sprint.start_date
? `${renderShortNumericDateFormat(sprint.start_date)} - `
: ""
: sprint.status}
{sprint.end_date ? renderShortNumericDateFormat(sprint.end_date) : ""}
</p>
</div>
</Disclosure.Button>
<div className="relative">
<Menu>
<Menu.Button>
<EllipsisHorizontalIcon width="16" height="16" />
</Menu.Button>
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24">
<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={() => selectSprint({ ...sprint, actionType: "edit" })}
>
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={() => selectSprint({ ...sprint, actionType: "delete" })}
>
Delete
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="space-y-3">
{sprintIssues ? (
sprintIssues.length > 0 ? (
sprintIssues.map((issue) => (
<div
key={issue.id}
className="p-4 bg-white border border-gray-200 rounded flex items-center justify-between"
>
<button
type="button"
onClick={() =>
router.push(
`/projects/${projectId}/issues/${issue.issue_details.id}`
)
}
>
<p>{issue.issue_details.name}</p>
</button>
<div className="flex items-center gap-x-4">
<span
className="text-black rounded px-2 py-0.5 text-sm border"
style={{
backgroundColor: `${issue.issue_details.state_detail.color}20`,
borderColor: issue.issue_details.state_detail.color,
}}
>
{issue.issue_details.state_detail.name}
</span>
<div className="relative">
<Menu>
<Menu.Button>
<EllipsisHorizontalIcon width="16" height="16" />
</Menu.Button>
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24">
<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={() =>
openIssueModal(sprint.id, issue.issue_details, "edit")
}
>
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={() =>
openIssueModal(sprint.id, issue.issue_details, "delete")
}
>
Delete
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
</div>
))
) : (
<p className="text-sm text-gray-500">This sprint has no issues.</p>
)
) : (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="flex flex-col gap-y-2">
<button
className="text-indigo-600 flex items-center gap-x-2"
onClick={() => openIssueModal(sprint.id)}
>
<div className="bg-theme text-white rounded-full p-0.5">
<PlusIcon width="18" height="18" />
</div>
<p>Add Issue</p>
</button>
<div className="ml-1">
<Menu as="div" className="inline-block text-left">
<div>
<Menu.Button className="inline-flex w-full items-center justify-center rounded-md text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<div className="text-indigo-600 flex items-center gap-x-2">
<p>Add Existing Issue</p>
</div>
<ChevronDownIcon
className="-mr-1 ml-2 h-5 w-5 text-indigo-600"
aria-hidden="true"
/>
</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="absolute left-5 z-20 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{projectIssues?.results.map((issue) => (
<Menu.Item
key={issue.id}
as="div"
onClick={() => {
addIssueToSprint(sprint.id, issue.id);
}}
>
{({ active }) => (
<p
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block px-4 py-2 text-sm"
)}
>
{issue.name}
</p>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</div>
</div>
)}
</Disclosure>
</div>
);
};
export default SprintView;

View File

@ -0,0 +1,355 @@
import React, { useState } from "react";
// Next imports
import Link from "next/link";
// React beautiful dnd
import { Draggable } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// common
import { addSpaceIfCamelCase, renderShortNumericDateFormat } from "constants/common";
// types
import { IIssue, Properties, NestedKeyOf } from "types";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
CalendarDaysIcon,
EllipsisHorizontalIcon,
PencilIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
import Image from "next/image";
type Props = {
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
groupedByIssues: any;
index: number;
setIsIssueOpen: React.Dispatch<React.SetStateAction<boolean>>;
properties: Properties;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
bgColor?: string;
stateId?: string;
};
const SingleBoard: React.FC<Props> = ({
selectedGroup,
groupTitle,
groupedByIssues,
index,
setIsIssueOpen,
properties,
setPreloadedData,
bgColor = "#0f2b16",
stateId,
}) => {
// Collapse/Expand
const [show, setState] = useState<any>(true);
// Edit state name
const [showInput, setInput] = useState<any>(false);
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
return (
<Draggable draggableId={groupTitle} index={index}>
{(provided, snapshot) => (
<div
className={`rounded flex-shrink-0 h-full ${
snapshot.isDragging ? "border-indigo-600 shadow-lg" : ""
} ${!show ? "" : "w-80 bg-gray-50 border"}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
snapshot.isDragging ? "bg-indigo-50 border-indigo-100 border-b" : ""
} ${!show ? "flex-col bg-gray-50 rounded-md border" : ""}`}
>
{showInput ? null : (
<div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
<button
type="button"
{...provided.dragHandleProps}
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
!show ? "" : "rotate-90"
} ${selectedGroup !== "state_detail.name" ? "hidden" : ""}`}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
</button>
<div
className={`flex items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
onClick={() => {
// setInput(true);
}}
>
<span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
style={{
backgroundColor: bgColor,
}}
/>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
</div>
)}
<div className={`flex items-center ${!show ? "flex-col pb-2" : ""}`}>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => {
setState(!show);
setInput(false);
}}
>
{show ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon 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"
onClick={() => {
setIsIssueOpen(true);
if (selectedGroup !== null)
setPreloadedData({
state: stateId,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}}
>
<PlusIcon 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"
onClick={() =>
setPreloadedData({
// ...state,
actionType: "edit",
})
}
>
<PencilIcon 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"
onClick={() =>
setSelectedState({
...state,
actionType: "delete",
})
}
>
<TrashIcon className="h-4 w-4 text-red-500" />
</button> */}
</div>
</div>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!show ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
{groupedByIssues[groupTitle].map((childIssue: any, index: number) => (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<div
className={`border rounded bg-white shadow-sm cursor-pointer ${
snapshot.isDragging ? "border-indigo-600 shadow-lg bg-indigo-50" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
className="px-2 py-3 space-y-1.5 select-none"
{...provided.dragHandleProps}
>
{Object.keys(properties).map(
(key) =>
properties[key as keyof Properties] &&
!Array.isArray(childIssue[key as keyof IIssue]) && (
<div
key={key}
className={`${
key === "name"
? "text-sm font-medium mb-2"
: key === "description"
? "text-xs text-black"
: key === "priority"
? `text-xs bg-gray-200 px-2 py-1 mt-2 flex items-center gap-x-1 rounded w-min whitespace-nowrap capitalize font-medium ${
childIssue.priority === "high"
? "bg-red-100 text-red-600"
: childIssue.priority === "medium"
? "bg-orange-100 text-orange-500"
: childIssue.priority === "low"
? "bg-green-100 text-green-500"
: "hidden"
}`
: key === "target_date"
? "text-xs bg-indigo-50 px-2 py-1 mt-2 flex items-center gap-x-1 rounded w-min whitespace-nowrap"
: "text-sm text-gray-500"
} gap-1
`}
>
{key === "target_date" ? (
<>
<CalendarDaysIcon className="h-4 w-4" />{" "}
{childIssue.target_date
? renderShortNumericDateFormat(childIssue.target_date)
: "N/A"}
</>
) : (
""
)}
{key === "name" && (
<Link
href={`/projects/${childIssue.project}/issues/${childIssue.id}`}
>
<a className="hover:text-theme duration-300">
{childIssue.name}
</a>
</Link>
)}
{key === "state" && (
<>{addSpaceIfCamelCase(childIssue["state_detail"].name)}</>
)}
{key === "priority" && <>{childIssue.priority}</>}
{key === "description" && <>{childIssue.description}</>}
{key === "assignee" ? (
<div className="flex items-center gap-1 text-xs">
{childIssue?.assignee_details?.length > 0 ? (
childIssue?.assignee_details?.map(
(assignee: any, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{assignee.avatar && assignee.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={assignee.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={assignee.name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-500 text-white border-2 border-white grid place-items-center rounded-full`}
>
{assignee.first_name.charAt(0)}
</div>
)}
</div>
)
)
) : (
<span>None</span>
)}
</div>
) : null}
</div>
)
)}
</div>
{/* <div
className={`p-2 bg-indigo-50 flex items-center justify-between ${
snapshot.isDragging ? "bg-indigo-200" : ""
}`}
>
<button
type="button"
className="flex flex-col"
{...provided.dragHandleProps}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
</button>
<div className="flex gap-1 items-center">
<button type="button">
<HeartIcon className="h-4 w-4 text-yellow-500" />
</button>
<button type="button">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
</button>
</div>
</div> */}
</div>
)}
</Draggable>
))}
{provided.placeholder}
<button
type="button"
className="flex items-center text-xs font-medium hover:bg-gray-200 p-2 rounded duration-300 outline-none"
onClick={() => {
setIsIssueOpen(true);
if (selectedGroup !== null) {
setPreloadedData({
state: stateId,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
<PlusIcon className="h-3 w-3 mr-1" />
Create
</button>
</div>
)}
</StrictModeDroppable>
</div>
</div>
)}
</Draggable>
);
};
export default SingleBoard;

View File

@ -0,0 +1,218 @@
import React, { useCallback, useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// react beautiful dnd
import type { DropResult } from "react-beautiful-dnd";
import { DragDropContext } from "react-beautiful-dnd";
// services
import stateServices from "lib/services/state.services";
import issuesServices from "lib/services/issues.services";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
import { STATE_LIST } from "constants/fetch-keys";
// components
import SingleBoard from "components/project/issues/BoardView/SingleBoard";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
// ui
import { Spinner, Button } from "ui";
// types
import type { IState, IIssue, Properties, NestedKeyOf } from "types";
type Props = {
properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null;
groupedByIssues: {
[key: string]: IIssue[];
};
};
const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues }) => {
const [isOpen, setIsOpen] = useState(false);
const [isIssueOpen, setIsIssueOpen] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
const { activeWorkspace } = useUser();
const router = useRouter();
const { projectId } = router.query;
const { data: states, mutate: mutateState } = useSWR<IState[]>(
projectId && activeWorkspace ? STATE_LIST(projectId as string) : null,
activeWorkspace
? () => stateServices.getStates(activeWorkspace.slug, projectId as string)
: null
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
const { source, destination, type } = result;
if (type === "state") {
const newStates = Array.from(states ?? []);
const [reorderedState] = newStates.splice(source.index, 1);
newStates.splice(destination.index, 0, reorderedState);
const prevSequenceNumber = newStates[destination.index - 1]?.sequence;
const nextSequenceNumber = newStates[destination.index + 1]?.sequence;
const sequenceNumber =
prevSequenceNumber && nextSequenceNumber
? (prevSequenceNumber + nextSequenceNumber) / 2
: nextSequenceNumber
? nextSequenceNumber - 15000 / 2
: prevSequenceNumber
? prevSequenceNumber + 15000 / 2
: 15000;
newStates[destination.index].sequence = sequenceNumber;
mutateState(newStates, false);
if (!activeWorkspace) return;
stateServices
.patchState(activeWorkspace.slug, projectId as string, newStates[destination.index].id, {
sequence: sequenceNumber,
})
.then((response) => {
console.log(response);
})
.catch((err) => {
console.error(err);
});
} else {
if (source.droppableId !== destination.droppableId) {
const sourceGroup = source.droppableId; // source group id
const destinationGroup = destination.droppableId; // destination group id
if (!sourceGroup || !destinationGroup) return;
// removed/dragged item
const removedItem = groupedByIssues[source.droppableId][source.index];
if (selectedGroup === "priority") {
// update the removed item for mutation
removedItem.priority = destinationGroup;
// patch request
issuesServices.patchIssue(activeWorkspace!.slug, projectId as string, removedItem.id, {
priority: destinationGroup,
});
} else if (selectedGroup === "state_detail.name") {
const destinationState = states?.find((s) => s.name === destinationGroup);
const destinationStateId = destinationState?.id;
// update the removed item for mutation
if (!destinationStateId || !destinationState) return;
removedItem.state = destinationStateId;
removedItem.state_detail = destinationState;
// patch request
issuesServices.patchIssue(activeWorkspace!.slug, projectId as string, removedItem.id, {
state: destinationStateId,
});
}
// remove item from the source group
groupedByIssues[source.droppableId].splice(source.index, 1);
// add item to the destination group
groupedByIssues[destination.droppableId].splice(destination.index, 0, removedItem);
}
}
},
[activeWorkspace, mutateState, groupedByIssues, projectId, selectedGroup, states]
);
useEffect(() => {
if (isOpen) return;
const timer = setTimeout(() => {
setPreloadedData(undefined);
clearTimeout(timer);
}, 500);
}, [isOpen]);
return (
<>
{/* <CreateUpdateStateModal
isOpen={
isOpen &&
preloadedData?.actionType !== "delete" &&
preloadedData?.actionType !== "createIssue"
}
setIsOpen={setIsOpen}
data={preloadedData as Partial<IIssue>}
projectId={projectId as string}
/> */}
{/* <ConfirmStateDeletion
isOpen={isOpen && preloadedData?.actionType === "delete"}
setIsOpen={setIsOpen}
data={preloadedData as Partial<IIssue>}
/> */}
<CreateUpdateIssuesModal
isOpen={isIssueOpen && preloadedData?.actionType === "createIssue"}
setIsOpen={setIsIssueOpen}
prePopulateData={{
...preloadedData,
}}
projectId={projectId as string}
/>
{groupedByIssues ? (
groupedByIssues ? (
<div className="h-full w-full">
<DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden">
<StrictModeDroppable droppableId="state" type="state" direction="horizontal">
{(provided) => (
<div
className="h-full w-full"
{...provided.droppableProps}
ref={provided.innerRef}
>
<div className="flex gap-x-4 h-full overflow-x-auto overflow-y-hidden pb-3">
{Object.keys(groupedByIssues).map((singleGroup, index) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
index={index}
setIsIssueOpen={setIsIssueOpen}
properties={properties}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id
: undefined
}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: undefined
}
/>
))}
</div>
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
</div>
</DragDropContext>
</div>
) : null
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</>
);
};
export default BoardView;

View File

@ -0,0 +1,147 @@
import React, { useEffect, useRef, useState } from "react";
// swr
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import stateServices from "lib/services/state.services";
// fetch api
import { STATE_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
// types
import type { IState } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IState;
};
const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !activeWorkspace) return;
await stateServices
.deleteState(activeWorkspace.slug, data.project, data.id)
.then(() => {
mutate<IState[]>(
STATE_LIST(data.project),
(prevData) => prevData?.filter((state) => state.id !== data?.id),
false,
);
handleClose();
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={handleClose}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900"
>
Delete State
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete state - {`"`}
<span className="italic">{data?.name}</span>
{`"`} ? All of the data related to the state will be
permanently removed. This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={handleClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ConfirmStateDeletion;

View File

@ -0,0 +1,271 @@
import React, { useEffect } from "react";
// swr
import { mutate } from "swr";
// react hook form
import { Controller, useForm } from "react-hook-form";
// react color
import { TwitterPicker } from "react-color";
// headless
import { Dialog, Popover, Transition } from "@headlessui/react";
// services
import stateService from "lib/services/state.services";
// fetch keys
import { STATE_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Button, Input, TextArea } from "ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import type { IState } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
projectId: string;
data?: IState;
};
const defaultValues: Partial<IState> = {
name: "",
description: "",
color: "#000000",
};
const CreateUpdateStateModal: React.FC<Props> = ({
isOpen,
setIsOpen,
data,
projectId,
}) => {
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace } = useUser();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
watch,
control,
reset,
setError,
} = useForm<IState>({
defaultValues,
});
const onSubmit = async (formData: IState) => {
if (!activeWorkspace) return;
const payload: IState = {
...formData,
};
if (!data) {
await stateService
.createState(activeWorkspace.slug, projectId, payload)
.then((res) => {
mutate<IState[]>(
STATE_LIST(projectId),
(prevData) => [...(prevData ?? []), res],
false
);
handleClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof IState, {
message: err[key].join(", "),
});
});
});
} else {
await stateService
.updateState(activeWorkspace.slug, projectId, data.id, payload)
.then((res) => {
mutate<IState[]>(
STATE_LIST(projectId),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
handleClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof IState, {
message: err[key].join(", "),
});
});
});
}
};
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<div className="mt-3 sm:mt-5">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900"
>
{data ? "Update" : "Create"} State
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group bg-white rounded-md inline-flex items-center text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
open ? "text-gray-900" : "text-gray-500"
}`}
>
<span>Color</span>
{watch("color") && watch("color") !== "" && (
<span
className="w-4 h-4 ml-2 rounded"
style={{
backgroundColor:
watch("color") ?? "green",
}}
></span>
)}
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-gray-500 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</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="fixed z-50 transform left-5 mt-3 px-2 w-screen max-w-xs sm:px-0">
<Controller
name="color"
control={control}
render={({
field: { value, onChange },
}) => (
<TwitterPicker
color={value}
onChange={(value) =>
onChange(value.hex)
}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{data
? isSubmitting
? "Updating State..."
: "Update State"
: isSubmitting
? "Creating State..."
: "Create State"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default CreateUpdateStateModal;

View File

@ -0,0 +1,150 @@
import React, { useEffect, useRef, useState } from "react";
// swr
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// fetching keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// services
import issueServices from "lib/services/issues.services";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
// types
import type { IIssue, IssueResponse } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IIssue;
};
const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const { setToastAlert } = useToast();
const cancelButtonRef = useRef(null);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !activeWorkspace) return;
const projectId = data.project;
await issueServices
.deleteIssue(activeWorkspace.slug, projectId, data.id)
.then(() => {
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, projectId),
(prevData) => {
return {
...(prevData as IssueResponse),
results: prevData?.results.filter((i) => i.id !== data.id) ?? [],
count: (prevData?.count as number) - 1,
};
},
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Issue deleted successfully",
});
handleClose();
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" initialFocus={cancelButtonRef} onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete Issue
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete issue - {`"`}
<span className="italic">{data?.name}</span>
{`"`} ? All of the data related to the issue will be permanently removed.
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={onClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ConfirmIssueDeletion;

View File

@ -0,0 +1,126 @@
import React, { useContext } from "react";
// swr
import useSWR from "swr";
// react hook form
import { Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// fetch keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
// icons
import { CheckIcon } from "@heroicons/react/20/solid";
// types
import type { Control } from "react-hook-form";
import type { IIssue, WorkspaceMember } from "types";
import { UserIcon } from "@heroicons/react/24/outline";
type Props = {
control: Control<IIssue, any>;
};
const SelectAssignee: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR<WorkspaceMember[]>(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)
: null
);
return (
<Controller
control={control}
name="assignees_list"
render={({ field: { value, onChange } }) => (
<Listbox
value={value}
onChange={(data: any) => {
const valueCopy = [...(value ?? [])];
if (valueCopy.some((i) => i === data)) onChange(valueCopy.filter((i) => i !== data));
else onChange([...valueCopy, data]);
}}
>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
<UserIcon className="h-3 w-3" />
<span className="block truncate">
{value && value.length > 0
? value
.map(
(id) =>
people
?.find((i) => i.member.id === id)
?.member.email.substring(0, 4) + "..."
)
.join(", ")
: "Assignees"}
</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 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{people?.map((person) => (
<Listbox.Option
key={person.member.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none relative p-2 rounded-md`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected || (value ?? []).some((i) => i === person.member.id)
? "font-semibold"
: "font-normal"
} block truncate`}
>
{person.member.email}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active || (value ?? []).some((i) => i === person.member.id)
? "text-white"
: "text-indigo-600"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
></Controller>
);
};
export default SelectAssignee;

View File

@ -0,0 +1,105 @@
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// components
import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal";
// icons
import { CheckIcon, ChevronDownIcon, PlusIcon } from "@heroicons/react/20/solid";
// types
import type { IIssue } from "types";
import type { Control } from "react-hook-form";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
type Props = {
control: Control<IIssue, any>;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const SelectSprint: React.FC<Props> = ({ control, setIsOpen }) => {
const { sprints } = useUser();
return (
<>
<Controller
control={control}
name="sprints"
render={({ field: { value, onChange } }) => (
<Listbox as="div" value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
<ArrowPathIcon className="h-3 w-3" />
<span className="block truncate">
{sprints?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"}
</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 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{sprints?.map((sprint) => (
<Listbox.Option
key={sprint.id}
value={sprint.id}
className={({ active }) =>
`relative cursor-pointer select-none p-2 rounded-md ${
active ? "bg-theme text-white" : "text-gray-900"
}`
}
>
{({ active, selected }) => (
<>
<span className={`block ${selected && "font-semibold"}`}>
{sprint.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>
)}
</>
)}
</Listbox.Option>
))}
</div>
<button
type="button"
className="relative select-none py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
onClick={() => setIsOpen(true)}
>
<span>
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
<span>
<span className="block truncate">Create cycle</span>
</span>
</button>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</>
);
};
export default SelectSprint;

View File

@ -0,0 +1,198 @@
import React, { useEffect, useState } from "react";
// swr
import useSWR from "swr";
// react hook form
import { useForm, Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
// icons
import { CheckIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
// ui
import { Button, Input } from "ui";
// types
import type { Control } from "react-hook-form";
import type { IIssue, IIssueLabels } from "types";
import { TagIcon } from "@heroicons/react/24/outline";
type Props = {
control: Control<IIssue, any>;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
};
const SelectLabels: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const [isOpen, setIsOpen] = useState(false);
const { data: issueLabels, mutate: issueLabelsMutate } = useSWR<IIssueLabels[]>(
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
activeProject && activeWorkspace
? () => issuesServices.getIssueLabels(activeWorkspace.slug, activeProject.id)
: null
);
const onSubmit = async (data: IIssueLabels) => {
if (!activeProject || !activeWorkspace || isSubmitting) return;
await issuesServices
.createIssueLabel(activeWorkspace.slug, activeProject.id, data)
.then((response) => {
issueLabelsMutate((prevData) => [...(prevData ?? []), response], false);
setIsOpen(false);
reset(defaultValues);
})
.catch((error) => {
console.log(error);
});
};
const {
register,
handleSubmit,
formState: { isSubmitting },
setFocus,
reset,
} = useForm<IIssueLabels>({ defaultValues });
useEffect(() => {
isOpen && setFocus("name");
}, [isOpen, setFocus]);
return (
<Controller
control={control}
name="labels_list"
render={({ field: { value, onChange } }) => (
<Listbox
value={value}
onChange={(data: any) => {
const valueCopy = [...(value ?? [])];
if (valueCopy.some((i) => i === data)) onChange(valueCopy.filter((i) => i !== data));
else onChange([...valueCopy, data]);
}}
>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
<TagIcon className="h-3 w-3" />
<span className="block truncate">
{value && value.length > 0
? value.map((id) => issueLabels?.find((i) => i.id === id)?.name).join(", ")
: "Labels"}
</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 mt-1 bg-white shadow-lg max-h-28 rounded-md text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{issueLabels?.map((label) => (
<Listbox.Option
key={label.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none w-full p-2 rounded-md`
}
value={label.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected || (value ?? []).some((i) => i === label.id)
? "font-semibold"
: "font-normal"
} block`}
>
{label.name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active || (value ?? []).some((i) => i === label.id)
? "text-white"
: "text-indigo-600"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</div>
<div className="cursor-default select-none relative p-2 min-w-[12rem]">
{isOpen ? (
<div className="flex items-center gap-x-1">
<Input
id="name"
name="name"
type="text"
placeholder="Title"
className="w-full"
autoComplete="off"
register={register}
validations={{
required: true,
}}
/>
<button
type="button"
className="bg-green-600 text-white h-8 w-12 rounded-md grid place-items-center"
disabled={isSubmitting}
onClick={handleSubmit(onSubmit)}
>
<PlusIcon className="h-4 w-4" />
</button>
<button
type="button"
className="bg-red-600 text-white h-8 w-12 rounded-md grid place-items-center"
onClick={() => setIsOpen(false)}
>
<XMarkIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
) : (
<button
type="button"
className="flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
onClick={() => setIsOpen(true)}
>
<span>
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
<span>
<span className="block truncate">Create label</span>
</span>
</button>
)}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
></Controller>
);
};
export default SelectLabels;

View File

@ -0,0 +1,91 @@
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// icons
import { CheckIcon } from "@heroicons/react/20/solid";
// types
import type { IIssue } from "types";
import type { Control } from "react-hook-form";
import { UserIcon } from "@heroicons/react/24/outline";
type Props = {
control: Control<IIssue, any>;
};
const SelectParent: React.FC<Props> = ({ control }) => {
const { issues: projectIssues } = useUser();
const getSelectedIssueKey = (issueId: string | undefined) => {
const identifier = projectIssues?.results?.find((i) => i.id.toString() === issueId?.toString())
?.project_detail.identifier;
const sequenceId = projectIssues?.results?.find(
(i) => i.id.toString() === issueId?.toString()
)?.sequence_id;
if (issueId) return `${identifier}-${sequenceId}`;
else return "Parent issue";
};
return (
<Controller
control={control}
name="parent"
render={({ field: { value, onChange } }) => (
<Listbox as="div" value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
<UserIcon className="h-3 w-3 flex-shrink-0" />
<span className="block truncate">{getSelectedIssueKey(value?.toString())}</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 mt-1 bg-white shadow-lg max-h-28 max-w-[15rem] rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{projectIssues?.results?.map((issue) => (
<Listbox.Option
key={issue.id}
value={issue.id}
className={({ active }) =>
`relative cursor-pointer select-none p-2 rounded-md ${
active ? "bg-theme text-white" : "text-gray-900"
}`
}
>
{({ active, selected }) => (
<>
<span className={`block truncate ${selected && "font-medium"}`}>
<span className="font-medium">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</span>
</>
)}
</Listbox.Option>
))}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
);
};
export default SelectParent;

View File

@ -0,0 +1,89 @@
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { CheckIcon } from "@heroicons/react/20/solid";
// types
import type { IIssue } from "types";
import type { Control } from "react-hook-form";
import { ChartBarIcon } from "@heroicons/react/24/outline";
type Props = {
control: Control<IIssue, any>;
};
const PRIORITIES = ["high", "medium", "low"];
const SelectPriority: React.FC<Props> = ({ control }) => {
return (
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
<ChartBarIcon className="h-3 w-3" />
<span className="block capitalize">{value ?? "Priority"}</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 mt-1 w-full bg-white shadow-lg max-h-28 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-xs">
<div className="p-1">
{PRIORITIES.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none relative p-2 rounded-md`
}
value={priority}
>
{({ selected, active }) => (
<>
<span
className={`block capitalize ${
selected ? "font-semibold" : "font-normal"
}`}
>
{priority}
</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>
))}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
></Controller>
);
};
export default SelectPriority;

View File

@ -0,0 +1,102 @@
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// icons
import { CheckIcon } from "@heroicons/react/20/solid";
import { ClipboardDocumentListIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
// ui
import { Spinner } from "ui";
// types
import type { Control } from "react-hook-form";
import type { IIssue } from "types";
type Props = {
control: Control<IIssue, any>;
};
const SelectProject: React.FC<Props> = ({ control }) => {
const { projects, setActiveProject } = useUser();
return (
<>
<Controller
control={control}
name="project"
render={({ field: { value, onChange } }) => (
<Listbox
value={value}
onChange={(value) => {
onChange(value);
setActiveProject(projects?.find((i) => i.id === value));
}}
>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="flex items-center gap-1 bg-white relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<ClipboardDocumentListIcon className="h-3 w-3" />
<span className="block truncate">
{projects?.find((i) => i.id === value)?.identifier ?? "Project"}
</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 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{projects ? (
projects.length > 0 ? (
projects.map((project) => (
<Listbox.Option
key={project.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none p-2 rounded-md`
}
value={project.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-medium" : "font-normal"
} block truncate`}
>
{project.name}
</span>
</>
)}
</Listbox.Option>
))
) : (
<p className="text-gray-400">No projects found!</p>
)
) : (
<div className="flex justify-center">
<Spinner />
</div>
)}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
></Controller>
</>
);
};
export default SelectProject;

View File

@ -0,0 +1,124 @@
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// components
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
// icons
import { CheckIcon, PlusIcon } from "@heroicons/react/20/solid";
// ui
import { Spinner } from "ui";
// types
import type { Control } from "react-hook-form";
import type { IIssue } from "types";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
type Props = {
control: Control<IIssue, any>;
data?: IIssue;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const SelectState: React.FC<Props> = ({ control, data, setIsOpen }) => {
const { states } = useUser();
return (
<>
<Controller
control={control}
name="state"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
<Squares2X2Icon className="h-3 w-3" />
<span className="block truncate">
{states?.find((i) => i.id === value)?.name ?? "State"}
</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 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{states ? (
states.filter((i) => i.id !== data?.id).length > 0 ? (
states
.filter((i) => i.id !== data?.id)
.map((state) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none relative p-2 rounded-md`
}
value={state.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{state.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>
))
) : (
<p className="text-gray-400">No states found!</p>
)
) : (
<div className="flex justify-center">
<Spinner />
</div>
)}
</div>
<button
type="button"
className="select-none relative py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
onClick={() => setIsOpen(true)}
>
<span>
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
<span>
<span className="block truncate">Create state</span>
</span>
</button>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
></Controller>
</>
);
};
export default SelectState;

View File

@ -0,0 +1,370 @@
import React, { useEffect, useState } from "react";
// swr
import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// fetching keys
import { PROJECT_ISSUES_DETAILS, PROJECT_ISSUES_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
// headless
import { Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// ui
import { Button, Input, TextArea } from "ui";
// commons
import { renderDateFormat } from "constants/common";
// components
import SelectState from "./SelectState";
import SelectCycles from "./SelectCycles";
import SelectLabels from "./SelectLabels";
import SelectProject from "./SelectProject";
import SelectPriority from "./SelectPriority";
import SelectAssignee from "./SelectAssignee";
import SelectParent from "./SelectParentIssues";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
// types
import type { IIssue, IssueResponse, SprintIssueResponse } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
projectId?: string;
data?: IIssue;
prePopulateData?: Partial<IIssue>;
isUpdatingSingleIssue?: boolean;
};
const defaultValues: Partial<IIssue> = {
name: "",
description: "",
};
const CreateUpdateIssuesModal: React.FC<Props> = ({
isOpen,
setIsOpen,
data,
projectId,
prePopulateData,
isUpdatingSingleIssue = false,
}) => {
const [isCycleModalOpen, setIsCycleModalOpen] = useState(false);
const [isStateModalOpen, setIsStateModalOpen] = useState(false);
const handleClose = () => {
setIsOpen(false);
if (data) {
resetForm();
}
};
const resetForm = () => {
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
control,
watch,
} = useForm<IIssue>({
defaultValues,
});
const addIssueToSprint = async (issueId: string, sprintId: string, issueDetail: IIssue) => {
if (!activeWorkspace || !activeProject) return;
await issuesServices
.addIssueToSprint(activeWorkspace.slug, activeProject.id, sprintId, {
issue: issueId,
})
.then((res) => {
console.log("add to sprint", res);
mutate<SprintIssueResponse[]>(
CYCLE_ISSUES(sprintId),
(prevData) => {
const targetResponse = prevData?.find((t) => t.cycle === sprintId);
if (targetResponse) {
targetResponse.issue_details = issueDetail;
return prevData;
} else {
return [
...(prevData ?? []),
{
cycle: sprintId,
issue_details: issueDetail,
} as SprintIssueResponse,
];
}
},
false
);
if (isUpdatingSingleIssue) {
mutate<IIssue>(
PROJECT_ISSUES_DETAILS,
(prevData) => ({ ...(prevData as IIssue), sprints: sprintId }),
false
);
} else
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((issue) => {
if (issue.id === res.id) return { ...issue, sprints: sprintId };
return issue;
}),
};
},
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Issue added to sprint successfully",
});
})
.catch((err) => {
console.log(err);
});
};
const onSubmit = async (formData: IIssue) => {
if (!activeWorkspace || !activeProject) return;
const payload: Partial<IIssue> = {
...formData,
target_date: formData.target_date ? renderDateFormat(formData.target_date ?? "") : null,
};
if (!data) {
await issuesServices
.createIssues(activeWorkspace.slug, activeProject.id, payload)
.then(async (res) => {
console.log(res);
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
results: [res, ...(prevData?.results ?? [])],
count: (prevData?.count ?? 0) + 1,
};
},
false
);
if (formData.sprints && formData.sprints !== null) {
await addIssueToSprint(res.id, formData.sprints, formData);
}
handleClose();
resetForm();
setToastAlert({
title: "Success",
type: "success",
message: `Issue ${data ? "updated" : "created"} successfully`,
});
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof IIssue, { message: err[key].join(", ") });
});
});
} else {
await issuesServices
.updateIssue(activeWorkspace.slug, activeProject.id, data.id, payload)
.then(async (res) => {
console.log(res);
if (isUpdatingSingleIssue) {
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
} else
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((issue) => {
if (issue.id === res.id) return { ...issue, ...res };
return issue;
}),
};
},
false
);
if (formData.sprints && formData.sprints !== null) {
await addIssueToSprint(res.id, formData.sprints, formData);
}
handleClose();
resetForm();
setToastAlert({
title: "Success",
type: "success",
message: "Issue updated successfully",
});
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof IIssue, { message: err[key].join(", ") });
});
});
}
};
useEffect(() => {
if (data) setIsOpen(true);
}, [data, setIsOpen]);
useEffect(() => {
reset({
...defaultValues,
...watch(),
...data,
project: activeProject?.id ?? projectId,
...prePopulateData,
});
}, [data, prePopulateData, reset, projectId, activeProject, isOpen, watch]);
return (
<>
{activeProject && (
<>
<CreateUpdateStateModal
isOpen={isStateModalOpen}
setIsOpen={setIsStateModalOpen}
projectId={activeProject?.id}
/>
<CreateUpdateCycleModal
isOpen={isCycleModalOpen}
setIsOpen={setIsCycleModalOpen}
projectId={activeProject?.id}
/>
</>
)}
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<div className="flex items-center gap-x-2">
<SelectProject control={control} />
<h3 className="text-lg font-medium leading-6 text-gray-900">
{data ? "Update" : "Create"} Issue
</h3>
</div>
<div className="space-y-3">
<div className="mt-2 space-y-3">
<div>
<TextArea
id="name"
label="Name"
name="name"
rows={1}
className="resize-none"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div>
<Input
id="target_date"
label="Due Date"
name="target_date"
type="date"
placeholder="Enter name"
autoComplete="off"
error={errors.target_date}
register={register}
/>
</div>
<div className="flex items-center gap-2">
<SelectState control={control} setIsOpen={setIsStateModalOpen} />
<SelectCycles control={control} setIsOpen={setIsCycleModalOpen} />
<SelectPriority control={control} />
<SelectLabels control={control} />
<SelectAssignee control={control} />
<SelectParent control={control} />
</div>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button
theme="secondary"
onClick={() => {
handleClose();
resetForm();
}}
>
Discard
</Button>
<Button type="submit" disabled={isSubmitting}>
{data
? isSubmitting
? "Updating Issue..."
: "Update Issue"
: isSubmitting
? "Creating Issue..."
: "Create Issue"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default CreateUpdateIssuesModal;

View File

@ -0,0 +1,411 @@
// next
import Link from "next/link";
// react
import React from "react";
// swr
import useSWR, { mutate } from "swr";
// ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse, IState, NestedKeyOf, Properties, WorkspaceMember } from "types";
// hooks
import useUser from "lib/hooks/useUser";
// fetch keys
import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// services
import issuesServices from "lib/services/issues.services";
import workspaceService from "lib/services/workspace.service";
// constants
import {
addSpaceIfCamelCase,
classNames,
renderShortNumericDateFormat,
replaceUnderscoreIfSnakeCase,
} from "constants/common";
// types
type Props = {
properties: Properties;
groupedByIssues: any;
selectedGroup: NestedKeyOf<IIssue> | null;
setSelectedIssue: any;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
};
const PRIORITIES = ["high", "medium", "low"];
const ListView: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
setSelectedIssue,
handleDeleteIssue,
}) => {
const { activeWorkspace, activeProject, states } = useUser();
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!activeWorkspace || !activeProject) return;
issuesServices
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, formData)
.then((response) => {
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => ({
...(prevData as IssueResponse),
results:
prevData?.results.map((issue) => (issue.id === response.id ? response : issue)) ?? [],
}),
false
);
})
.catch((error) => {
console.log(error);
});
};
const { data: people } = useSWR<WorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="mt-4 flex flex-col">
<div className="overflow-x-auto">
<div className="inline-block min-w-full p-0.5 align-middle">
<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>
{Object.keys(properties).map(
(key) =>
properties[key as keyof Properties] && (
<th
key={key}
scope="col"
className="px-3 py-3.5 text-left uppercase text-sm font-semibold text-gray-900"
>
{replaceUnderscoreIfSnakeCase(key)}
</th>
)
)}
<th
scope="col"
className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900"
>
ACTIONS
</th>
</tr>
</thead>
<tbody className="bg-white">
{Object.keys(groupedByIssues).map((singleGroup) => (
<React.Fragment key={singleGroup}>
{selectedGroup !== null ? (
<tr className="border-t border-gray-200">
<th
colSpan={14}
scope="colgroup"
className="bg-gray-50 px-4 py-2 text-left font-medium text-gray-900 capitalize"
>
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
<span className="ml-2 text-gray-500 font-normal text-sm">
{groupedByIssues[singleGroup as keyof IIssue].length}
</span>
</th>
</tr>
) : null}
{groupedByIssues[singleGroup].length > 0
? groupedByIssues[singleGroup].map((issue: IIssue, index: number) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map(
(assignee) =>
people?.find((p) => p.member.id === assignee)?.member.email
);
return (
<tr
key={issue.id}
className={classNames(
index === 0 ? "border-gray-300" : "border-gray-200",
"border-t"
)}
>
{Object.keys(properties).map(
(key) =>
properties[key as keyof Properties] && (
<td
key={key}
className="px-3 py-4 text-sm font-medium text-gray-900 relative"
>
{(key as keyof Properties) === "name" ? (
<p className="w-[15rem]">
<Link
href={`/projects/${issue.project}/issues/${issue.id}`}
>
<a className="hover:text-theme duration-300">
{issue.name}
</a>
</Link>
</p>
) : (key as keyof Properties) === "key" ? (
<p className="text-xs whitespace-nowrap">
{activeProject?.identifier}-{issue.sequence_id}
</p>
) : (key as keyof Properties) === "description" ? (
<p className="truncate text-xs max-w-[15rem]">
{issue.description}
</p>
) : (key as keyof Properties) === "priority" ? (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data }, issue.id);
}}
className="flex-shrink-0"
>
{({ open }) => (
<>
<div className="">
<Listbox.Button className="inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 py-1 px-0.5 text-xs font-medium text-gray-500 hover:bg-gray-100 border">
<span
className={classNames(
issue.priority ? "" : "text-gray-900",
"hidden truncate capitalize sm:block w-16"
)}
>
{issue.priority ?? "None"}
</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 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer capitalize select-none px-3 py-2"
)
}
value={priority}
>
{priority}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
) : (key as keyof Properties) === "assignee" ? (
<>
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) {
newData.splice(newData.indexOf(data), 1);
} else {
newData.push(data);
}
partialUpdateIssue(
{ assignees_list: newData },
issue.id
);
}}
className="flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button className="rounded-full bg-gray-50 px-5 py-1 text-xs text-gray-500 hover:bg-gray-100 border">
{() => {
if (assignees.length > 0)
return (
<>
{assignees.map((assignee, index) => (
<div
key={index}
className={
"hidden truncate sm:block text-left"
}
>
{assignee}
</div>
))}
</>
);
else return <span>None</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 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none px-3 py-2"
)
}
value={person.member.id}
>
<div
className={`flex items-center ${
assignees.includes(
person.member.email
)
? "font-medium"
: "font-normal"
}`}
>
{person.member.email}
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
</>
) : (key as keyof Properties) === "state" ? (
<Listbox
as="div"
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data }, issue.id);
}}
className="flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button
className="inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 border"
style={{
border: `2px solid ${issue.state_detail.color}`,
backgroundColor: `${issue.state_detail.color}20`,
}}
>
<span
className={classNames(
issue.state ? "" : "text-gray-900",
"hidden capitalize sm:block w-16"
)}
>
{addSpaceIfCamelCase(issue.state_detail.name)}
</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 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
{states?.map((state) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none px-3 py-2"
)
}
value={state.id}
>
{addSpaceIfCamelCase(state.name)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
) : (key as keyof Properties) === "children" ? (
<p>No children.</p>
) : (key as keyof Properties) === "target_date" ? (
<p className="whitespace-nowrap">
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "-"}
</p>
) : (
<p className="capitalize text-sm">
{issue[key as keyof IIssue] ??
(issue[key as keyof IIssue] as any)?.name ??
"None"}
</p>
)}
</td>
)
)}
<td className="px-3">
<div className="flex justify-end items-center gap-2">
<button
type="button"
className="flex items-center bg-blue-100 text-blue-600 hover:bg-blue-200 duration-300 font-medium px-2 py-1 rounded-md text-sm outline-none"
onClick={() => {
setSelectedIssue({
...issue,
actionType: "edit",
});
}}
>
<PencilIcon className="h-3 w-3" />
</button>
<button
type="button"
className="flex items-center bg-red-100 text-red-600 hover:bg-red-200 duration-300 font-medium px-2 py-1 rounded-md text-sm outline-none"
onClick={() => {
handleDeleteIssue(issue.id);
}}
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
</td>
</tr>
);
})
: null}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default ListView;

View File

@ -0,0 +1,177 @@
import React, { useEffect, useState } from "react";
// next
import Image from "next/image";
// swr
import { mutate } from "swr";
// headless ui
import { Menu } from "@headlessui/react";
// react hook form
import { useForm } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
// fetch keys
import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys";
// common
import { timeAgo } from "constants/common";
// ui
import { TextArea } from "ui";
// icon
import { CheckIcon, EllipsisHorizontalIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types
import type { IIssueComment } from "types";
type Props = {
comment: IIssueComment;
onSubmit: (comment: IIssueComment) => void;
handleCommentDeletion: (comment: string) => void;
};
const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
const { user } = useUser();
const [isEditing, setIsEditing] = useState(false);
const {
register,
formState: { isSubmitting },
handleSubmit,
setFocus,
} = useForm<IIssueComment>({
defaultValues: comment,
});
const onEnter = (formData: IIssueComment) => {
if (isSubmitting) return;
mutate<IIssueComment[]>(
PROJECT_ISSUES_COMMENTS,
(prevData) => {
const newData = prevData ?? [];
const index = newData.findIndex((comment) => comment.id === formData.id);
newData[index] = formData;
return [...newData];
},
false
);
setIsEditing(false);
onSubmit(formData);
};
useEffect(() => {
isEditing && setFocus("comment");
}, [isEditing, setFocus]);
return (
<div key={comment.id}>
<div className="w-full h-full flex justify-between">
<div className="flex gap-x-2 w-full">
<div className="flex-shrink-0 -ml-1.5">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<Image
src={comment.actor_detail.avatar}
alt={comment.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`}
>
{comment.actor_detail.first_name.charAt(0)}
</div>
)}
</div>
<div className="w-full">
<p>
{comment.actor_detail.first_name} {comment.actor_detail.last_name}
</p>
<p className="text-xs text-gray-500">{timeAgo(comment.created_at)}</p>
<div className="w-full mt-2">
{isEditing ? (
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}>
<TextArea
id="comment"
name="comment"
register={register}
validations={{
required: true,
}}
autoComplete="off"
mode="transparent"
className="w-full"
/>
<div className="flex self-end gap-1">
<button
type="submit"
disabled={isSubmitting}
className="group bg-green-100 hover:bg-green-500 border border-green-500 duration-300 p-2 rounded shadow-md"
>
<CheckIcon className="h-3 w-3 text-green-500 group-hover:text-white duration-300" />
</button>
<button
type="button"
className="group bg-red-100 hover:bg-red-500 border border-red-500 duration-300 p-2 rounded shadow-md"
onClick={() => setIsEditing(false)}
>
<XMarkIcon className="h-3 w-3 text-red-500 group-hover:text-white duration-300" />
</button>
</div>
</form>
) : (
<>
{comment.comment.split("\n").map((item, index) => (
<p key={index} className="text-sm text-gray-600">
{item}
</p>
))}
</>
)}
</div>
</div>
</div>
{user?.id === comment.actor && (
<div className="relative">
<Menu>
<Menu.Button>
<EllipsisHorizontalIcon className="w-5 h-5 text-gray-500" />
</Menu.Button>
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24 -top-20">
<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={() => setIsEditing(true)}
>
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={() => {
mutate<IIssueComment[]>(
PROJECT_ISSUES_COMMENTS,
(prevData) => (prevData ?? []).filter((c) => c.id !== comment.id),
false
);
handleCommentDeletion(comment.id);
}}
>
Delete
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
)}
</div>
</div>
);
};
export default CommentCard;

View File

@ -0,0 +1,130 @@
import React from "react";
// swr
import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// services
import issuesServices from "lib/services/issues.services";
// fetch keys
import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys";
// components
import CommentCard from "components/project/issues/comment/IssueCommentCard";
// ui
import { TextArea, Button, Spinner } from "ui";
// types
import type { IIssueComment } from "types";
type Props = {
comments?: IIssueComment[];
workspaceSlug: string;
projectId: string;
issueId: string;
};
const defaultValues: Partial<IIssueComment> = {
comment: "",
};
const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, workspaceSlug }) => {
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
reset,
} = useForm<IIssueComment>({ defaultValues });
const onSubmit = async (formData: IIssueComment) => {
await issuesServices
.createIssueComment(workspaceSlug, projectId, issueId, formData)
.then((response) => {
console.log(response);
mutate<IIssueComment[]>(
PROJECT_ISSUES_COMMENTS,
(prevData) => [...(prevData ?? []), response],
false
);
reset(defaultValues);
})
.catch((error) => {
console.log(error);
});
};
const onCommentUpdate = async (comment: IIssueComment) => {
await issuesServices
.patchIssueComment(workspaceSlug, projectId, issueId, comment.id, comment)
.then((response) => {
console.log(response);
});
};
const onCommentDelete = async (commentId: string) => {
await issuesServices
.deleteIssueComment(workspaceSlug, projectId, issueId, commentId)
.then((response) => {
console.log(response);
});
};
return (
<div className="space-y-3 px-2">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="p-2 bg-indigo-50 rounded-md mb-6">
<div className="w-full">
<TextArea
id="comment"
name="comment"
register={register}
validations={{
required: true,
}}
mode="transparent"
error={errors.comment}
className="w-full pb-10 resize-none"
placeholder="Enter your comment"
onKeyDown={(e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
const value = e.currentTarget.value;
const start = e.currentTarget.selectionStart;
const end = e.currentTarget.selectionEnd;
setValue("comment", `${value.substring(0, start)}\r ${value.substring(end)}`);
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
isSubmitting || handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="w-full flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Adding comment..." : "Add comment"}
</Button>
</div>
</div>
</form>
{comments ? (
comments.length > 0 ? (
<div className="space-y-5">
{comments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
onSubmit={onCommentUpdate}
handleCommentDeletion={onCommentDelete}
/>
))}
</div>
) : (
<p className="text-sm">No comments yet. Be the first to comment.</p>
)
) : (
<div className="w-full flex justify-center">
<Spinner />
</div>
)}
</div>
);
};
export default IssueCommentSection;

View File

@ -0,0 +1,334 @@
import React from "react";
// swr
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// react hook form
import { useForm, Controller } from "react-hook-form";
// services
import stateServices from "lib/services/state.services";
import issuesServices from "lib/services/issues.services";
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
import {
PROJECT_ISSUES_LIST,
STATE_LIST,
WORKSPACE_MEMBERS,
PROJECT_ISSUE_LABELS,
} from "constants/fetch-keys";
// commons
import { classNames } from "constants/common";
// ui
import { Input, Button } from "ui";
// icons
import { Bars3BottomRightIcon, PlusIcon, UserIcon, TagIcon } from "@heroicons/react/24/outline";
// types
import type { Control } from "react-hook-form";
import type { IIssue, IIssueLabels, IssueResponse, IState, WorkspaceMember } from "types";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
};
const PRIORITIES = ["high", "medium", "low"];
const defaultValues: Partial<IIssueLabels> = {
name: "",
};
const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges }) => {
const { activeWorkspace, activeProject } = useUser();
const { data: states } = useSWR<IState[]>(
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
: null
);
const { data: people } = useSWR<WorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
const { data: projectIssues } = useSWR<IssueResponse>(
activeProject && activeWorkspace
? PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id)
: null,
activeProject && activeWorkspace
? () => issuesServices.getIssues(activeWorkspace.slug, activeProject.id)
: null
);
const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>(
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
activeProject && activeWorkspace
? () => issuesServices.getIssueLabels(activeWorkspace.slug, activeProject.id)
: null
);
const {
register,
handleSubmit,
formState: { isSubmitting },
reset,
} = useForm({
defaultValues,
});
const onSubmit = (formData: any) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
issuesServices
.createIssueLabel(activeWorkspace.slug, activeProject.id, formData)
.then((res) => {
console.log(res);
reset(defaultValues);
issueLabelMutate((prevData) => [...(prevData ?? []), res], false);
});
};
return (
<div className="w-full h-full">
<div className="space-y-3">
<div className="flex flex-col gap-y-4">
{[
{
label: "Priority",
name: "priority",
canSelectMultipleOptions: false,
icon: Bars3BottomRightIcon,
options: PRIORITIES.map((property) => ({
label: property,
value: property,
})),
},
{
label: "Status",
name: "state",
canSelectMultipleOptions: false,
icon: Bars3BottomRightIcon,
options: states?.map((state) => ({
label: state.name,
value: state.id,
})),
},
{
label: "Assignees",
name: "assignees_list",
canSelectMultipleOptions: true,
icon: UserIcon,
options: people?.map((person) => ({
label: person.member.first_name,
value: person.member.id,
})),
},
{
label: "Blocker",
name: "blockers_list",
canSelectMultipleOptions: true,
icon: UserIcon,
options: projectIssues?.results?.map((issue) => ({
label: issue.name,
value: issue.id,
})),
},
{
label: "Blocked",
name: "blocked_list",
canSelectMultipleOptions: true,
icon: UserIcon,
options: projectIssues?.results?.map((issue) => ({
label: issue.name,
value: issue.id,
})),
},
].map((item) => (
<div className="flex items-center gap-x-2" key={item.label}>
<div className="flex items-center gap-x-2">
<item.icon className="w-5 h-5 text-gray-500" />
<p>{item.label}</p>
</div>
<div>
<Controller
control={control}
name={item.name as keyof IIssue}
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={item.canSelectMultipleOptions}
onChange={(value) => submitChanges({ [item.name]: value })}
className="flex-shrink-0"
>
{({ open }) => (
<>
<Listbox.Label className="sr-only">{item.label}</Listbox.Label>
<div className="relative">
<Listbox.Button className="relative inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 py-2 px-2 text-sm font-medium text-gray-500 hover:bg-gray-100 sm:px-3 border border-dashed">
<PlusIcon
className="h-5 w-5 flex-shrink-0 text-gray-300 sm:-ml-1"
aria-hidden="true"
/>
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate capitalize sm:ml-2 sm:block w-16"
)}
>
{value
? Array.isArray(value)
? value
.map(
(i: any) =>
item.options?.find((option) => option.value === i)
?.label
)
.join(", ") || `Select ${item.label}`
: item.options?.find((option) => option.value === value)?.label
: `Select ${item.label}`}
</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 right-0 z-10 mt-1 max-h-56 w-52 overflow-auto rounded-lg bg-white py-3 text-base shadow ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{item.options?.map((option) => (
<Listbox.Option
key={option.value}
className={({ active, selected }) =>
classNames(
active || selected ? "bg-indigo-50" : "bg-white",
"relative cursor-default select-none py-2 px-3"
)
}
value={option.value}
>
<div className="flex items-center">
<span className="ml-3 block capitalize font-medium">
{option.label}
</span>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
))}
<div>
<form className="flex" onSubmit={handleSubmit(onSubmit)}>
<Input
id="name"
name="name"
placeholder="Add label"
register={register}
validations={{
required: false,
}}
autoComplete="off"
/>
<Button type="submit" disabled={isSubmitting}>
+
</Button>
</form>
</div>
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2">
<TagIcon className="w-5 h-5 text-gray-500" />
<p>Label</p>
</div>
<div>
<Controller
control={control}
name="labels_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple
onChange={(value) => submitChanges({ labels_list: value })}
className="flex-shrink-0"
>
{({ open }) => (
<>
<Listbox.Label className="sr-only">Label</Listbox.Label>
<div className="relative">
<Listbox.Button className="relative inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 py-2 px-2 text-sm font-medium text-gray-500 hover:bg-gray-100 sm:px-3 border border-dashed">
<PlusIcon
className="h-5 w-5 flex-shrink-0 text-gray-300 sm:-ml-1"
aria-hidden="true"
/>
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate capitalize sm:ml-2 sm:block w-16"
)}
>
{value && value.length > 0
? value
.map(
(i: string) =>
issueLabels?.find((option) => option.id === i)?.name
)
.join(", ")
: `Select label`}
</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 right-0 z-10 mt-1 max-h-56 w-52 overflow-auto rounded-lg bg-white py-3 text-base shadow ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{issueLabels?.map((label: any) => (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
classNames(
active || selected ? "bg-indigo-50" : "bg-white",
"relative cursor-default select-none py-2 px-3"
)
}
value={label.id}
>
<div className="flex items-center">
<span className="ml-3 block capitalize font-medium">
{label.name}
</span>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default IssueDetailSidebar;

View File

@ -0,0 +1,110 @@
// react
import React from "react";
// ui
import { Listbox, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// services
import issuesServices from "lib/services/issues.services";
import stateServices from "lib/services/state.services";
// swr
import useSWR, { mutate } from "swr";
// types
import { IIssue, IssueResponse, IState } from "types";
// constants
import { addSpaceIfCamelCase, classNames } from "constants/common";
import { STATE_LIST, USER_ISSUE } from "constants/fetch-keys";
type Props = {
issue: IIssue;
};
const ChangeStateDropdown = ({ issue }: Props) => {
const { activeWorkspace } = useUser();
const partialUpdateIssue = (formData: Partial<IIssue>, projectId: string, issueId: string) => {
if (!activeWorkspace) return;
issuesServices
.patchIssue(activeWorkspace.slug, projectId, issueId, formData)
.then((response) => {
// mutate<IssueResponse>(
// USER_ISSUE,
// (prevData) => ({
// ...(prevData as IssueResponse),
// }),
// false
// );
})
.catch((error) => {
console.log(error);
});
};
const { data: states } = useSWR<IState[]>(
activeWorkspace ? STATE_LIST(issue.project) : null,
activeWorkspace ? () => stateServices.getStates(activeWorkspace.slug, issue.project) : null
);
return (
<>
<Listbox
as="div"
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data }, issue.project, issue.id);
}}
className="flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button
className="inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 border"
style={{
border: `2px solid ${issue.state_detail.color}`,
backgroundColor: `${issue.state_detail.color}20`,
}}
>
<span
className={classNames(
issue.state ? "" : "text-gray-900",
"hidden capitalize sm:block w-16"
)}
>
{addSpaceIfCamelCase(issue.state_detail.name)}
</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 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
{states?.map((state) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none px-3 py-2"
)
}
value={state.id}
>
{addSpaceIfCamelCase(state.name)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
</>
);
};
export default ChangeStateDropdown;

View File

@ -0,0 +1,135 @@
// React
import React, { useState } from "react";
// next
import Link from "next/link";
import useSWR from "swr";
import _ from "lodash";
import useUser from "lib/hooks/useUser";
// Services
import projectService from "lib/services/project.service";
// icons
import {
CalendarDaysIcon,
CheckIcon,
EyeIcon,
MinusIcon,
PencilIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { renderShortNumericDateFormat } from "constants/common";
const ProjectMemberInvitations = ({
project,
slug,
invitationsRespond,
handleInvitation,
setDeleteProject,
}: any) => {
const { user } = useUser();
const { data: members } = useSWR("PROJECT_MEMBERS", () =>
projectService.projectMembers(slug, project.id)
);
const isMember =
_.filter(members, (item: any) => item.member.id === (user as any).id).length === 1;
const [selected, setSelected] = useState<any>(false);
return (
<>
<div
className={`w-full h-full flex flex-col px-4 py-3 rounded-lg bg-indigo-50 ${
selected ? "ring-2 ring-indigo-400" : ""
}`}
>
<div className="flex justify-between items-center">
<div className="font-medium text-lg flex gap-2">
{!isMember ? (
<input
id={project.id}
className="h-3 w-3 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 mt-2 hidden"
aria-describedby="workspaces"
name={project.id}
checked={invitationsRespond.includes(project.id)}
value={project.name}
onChange={(e) => {
setSelected(e.target.checked);
handleInvitation(
project,
invitationsRespond.includes(project.id) ? "withdraw" : "accepted"
);
}}
type="checkbox"
/>
) : null}
<Link href={`/projects/${project.id}/issues`}>
<a className="flex flex-col">
{project.name}
<span className="text-xs">({project.identifier})</span>
</a>
</Link>
</div>
{isMember ? (
<div className="flex">
<Link href={`/projects/${project.id}/settings`}>
<a className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 cursor-pointer">
<PencilIcon className="h-4 w-4" />
</a>
</Link>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => setDeleteProject(project)}
>
<TrashIcon className="h-4 w-4 text-red-500" />
</button>
</div>
) : null}
</div>
<div className="mt-2">
<p className="text-sm">{project.description}</p>
</div>
<div className="mt-3 h-full flex justify-between items-end">
<div className="flex gap-2">
{!isMember ? (
<label
htmlFor={project.id}
className="flex items-center gap-1 text-xs font-medium bg-blue-200 hover:bg-blue-300 p-2 rounded duration-300 cursor-pointer"
>
{selected ? (
<>
<MinusIcon className="h-3 w-3" />
Remove
</>
) : (
<>
<PlusIcon className="h-3 w-3" />
Select to Join
</>
)}
</label>
) : (
<span className="flex items-center gap-1 text-xs bg-green-200 p-2 rounded">
<CheckIcon className="h-3 w-3" />
Member
</span>
)}
<Link href={`/projects/${project.id}/issues`}>
<a className="flex items-center gap-1 text-xs font-medium bg-blue-200 hover:bg-blue-300 p-2 rounded duration-300">
<EyeIcon className="h-3 w-3" />
View
</a>
</Link>
</div>
<div className="flex items-center gap-1 text-xs mb-1">
<CalendarDaysIcon className="h-4 w-4" />
{renderShortNumericDateFormat(project.created_at)}
</div>
</div>
</div>
</>
);
};
export default ProjectMemberInvitations;

View File

@ -0,0 +1,40 @@
import { FC, CSSProperties, useEffect, useState } from "react";
import Script from "next/script";
export interface IGoogleLoginButton {
text?: string;
onSuccess?: (res: any) => void;
onFailure?: (res: any) => void;
styles?: CSSProperties;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
return (
<>
<Script
src="https://accounts.google.com/gsi/client"
async
defer
onLoad={() => {
window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: props.onSuccess as any,
});
window?.google?.accounts.id.renderButton(
document.getElementById("googleSignInButton") as HTMLElement,
{
type: "standard",
theme: "outline",
size: "large",
logo_alignment: "center",
width: document.getElementById("googleSignInButton")?.offsetWidth,
text: props.text || "Continue with Google",
} as GsiButtonConfiguration // customization attributes
);
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
}}
/>
<div className="w-full" id="googleSignInButton"></div>
</>
);
};

View File

@ -0,0 +1,67 @@
import React from "react";
// hooks
import useToast from "lib/hooks/useToast";
// icons
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
const ToastAlerts = () => {
const { alerts, removeAlert } = useToast();
if (!alerts) return null;
return (
<div className="space-y-5 fixed top-8 right-8 w-80 h-full overflow-hidden pointer-events-none z-50">
{alerts.map((alert) => (
<div className="relative text-white rounded-md overflow-hidden" key={alert.id}>
<div className="absolute top-1 right-1">
<button
type="button"
className="inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 pointer-events-auto"
onClick={() => removeAlert(alert.id)}
>
<span className="sr-only">Dismiss</span>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div
className={`px-2 py-4 ${
alert.type === "success"
? "bg-[#06d6a0]"
: alert.type === "error"
? "bg-[#ef476f]"
: alert.type === "warning"
? "bg-[#e98601]"
: "bg-[#1B9aaa]"
}`}
>
<div className="flex items-center gap-x-3">
<div className="flex-shrink-0">
{alert.type === "success" ? (
<CheckCircleIcon className="h-8 w-8" aria-hidden="true" />
) : alert.type === "error" ? (
<XCircleIcon className="h-8 w-8" />
) : alert.type === "warning" ? (
<ExclamationTriangleIcon className="h-8 w-8" aria-hidden="true" />
) : (
<InformationCircleIcon className="h-8 w-8" />
)}
</div>
<div>
<p className="font-semibold">{alert.title}</p>
{alert.message && <p className="text-xs mt-1">{alert.message}</p>}
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default ToastAlerts;

View File

@ -0,0 +1,143 @@
import React, { useRef, useState } from "react";
// next
import { useRouter } from "next/router";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
// types
import type { IWorkspace } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const router = useRouter();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace, mutateWorkspaces } = useUser();
const { setToastAlert } = useToast();
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!activeWorkspace) return;
await workspaceService
.deleteWorkspace(activeWorkspace.slug)
.then(() => {
handleClose();
mutateWorkspaces((prevData) => {
return (prevData ?? []).filter(
(workspace: IWorkspace) => workspace.slug !== activeWorkspace.slug
);
}, false);
router.push("/");
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={handleClose}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete Workspace
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete workspace - {`"`}
<span className="italic">{activeWorkspace?.name}</span>
{`"`} ? All of the data related to the workspace will be permanently
removed. This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={handleClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ConfirmWorkspaceDeletion;

View File

@ -0,0 +1,189 @@
import React from "react";
// swr
import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// fetch keys
import { WORKSPACE_INVITATIONS } from "constants/fetch-keys";
// services
import workspaceService from "lib/services/workspace.service";
// ui
import { Button, Input, TextArea, Select } from "ui";
// hooks
import useToast from "lib/hooks/useToast";
// types
import { WorkspaceMember } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
workspace_slug: string;
members: any[];
};
const ROLE = {
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
};
const defaultValues: Partial<WorkspaceMember> = {
email: "",
role: 5,
message: "",
};
const SendWorkspaceInvitationModal: React.FC<Props> = ({
isOpen,
setIsOpen,
workspace_slug,
members,
}) => {
const { setToastAlert } = useToast();
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<WorkspaceMember>({
defaultValues,
reValidateMode: "onChange",
mode: "all",
});
const onSubmit = async (formData: any) => {
await workspaceService
.inviteWorkspace(workspace_slug, formData)
.then((res) => {
console.log(res);
setIsOpen(false);
handleClose();
mutate(
WORKSPACE_INVITATIONS,
(prevData: any) => [{ ...res, ...formData }, ...(prevData ?? [])],
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Member invited successfully",
});
})
.catch((err) => console.log(err));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Members
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Invite members to work on your workspace.
</p>
</div>
<div className="space-y-3">
<div>
<Input
id="email"
label="Email"
name="email"
type="email"
placeholder="Enter email"
error={errors.email}
register={register}
validations={{
required: "Email is required",
validate: (value) => {
if (members.find((member) => member.email === value))
return "Email already exist";
},
}}
/>
</div>
<div>
<Select
id="role"
label="Role"
name="role"
error={errors.role}
register={register}
validations={{
required: "Role is required",
}}
options={Object.entries(ROLE).map(([key, value]) => ({
value: key,
label: value,
}))}
/>
</div>
<div>
<TextArea
id="message"
name="message"
label="Message"
placeholder="Enter message"
error={errors.message}
register={register}
/>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending Invitation..." : "Send Invitation"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default SendWorkspaceInvitationModal;

View File

@ -0,0 +1,32 @@
import axios from "axios";
// constants
import { BASE_STAGING, BASE_LOCAL, BASE_PROD } from "constants/api-routes";
const base_url =
process.env.NEXT_PUBLIC_APP_ENVIRONMENT === "production"
? BASE_PROD
: process.env.NEXT_PUBLIC_APP_ENVIRONMENT === "preview"
? BASE_STAGING
: BASE_LOCAL;
axios.defaults.baseURL = base_url;
export function setAxiosHeader(token?: string) {
if (token) axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
else axios.defaults.headers.common["Authorization"] = "";
}
(async function () {
setAxiosHeader();
})();
const UNAUTHORIZED = [401];
axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
return error;
}
);

117
constants/api-routes.ts Normal file
View File

@ -0,0 +1,117 @@
// Base URLS
export const BASE_PROD = "https://api.plane.so";
export const BASE_STAGING = "https://api.plane.so";
export const BASE_LOCAL = "http://localhost:8000";
// authentication urls
export const SIGN_IN_ENDPOINT = "/api/sign-in/";
export const SIGN_UP_ENDPOINT = "/api/sign-up/";
export const SIGN_OUT_ENDPOINT = "/api/sign-out/";
export const SOCIAL_AUTH_ENDPOINT = "/api/social-auth/";
export const MAGIC_LINK_GENERATE = "/api/magic-generate/";
export const MAGIC_LINK_SIGNIN = "/api/magic-sign-in/";
// user
export const USER_ENDPOINT = "/api/users/me/";
export const CHANGE_PASSWORD = "/api/users/me/change-password/";
export const USER_ONBOARD_ENDPOINT = "/api/users/me/onboard/";
export const USER_ISSUES_ENDPOINT = "/api/users/me/issues/";
export const USER_WORKSPACES = "/api/users/me/workspaces";
// s3 file url
export const S3_URL = `/api/file-assets/`;
// LIST USER INVITATIONS ---- RESPOND INVITATIONS IN BULK
export const USER_WORKSPACE_INVITATIONS = "/api/users/me/invitations/workspaces/";
export const USER_PROJECT_INVITATIONS = "/api/users/me/invitations/projects/";
export const USER_WORKSPACE_INVITATION = (invitationId: string) =>
`/api/users/me/invitations/${invitationId}/`;
export const JOIN_WORKSPACE = (workspaceSlug: string, invitationId: string) =>
`/api/users/me/invitations/workspaces/${workspaceSlug}/${invitationId}/join/`;
export const JOIN_PROJECT = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/projects/join/`;
export const USER_ISSUES = "/api/users/me/issues/";
// workspaces
export const WORKSPACES_ENDPOINT = "/api/workspaces/";
export const WORKSPACE_DETAIL = (workspaceSlug: string) => `/api/workspaces/${workspaceSlug}/`;
export const INVITE_WORKSPACE = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/invite/`;
export const WORKSPACE_MEMBERS = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/members/`;
export const WORKSPACE_MEMBER_DETAIL = (workspaceSlug: string, memberId: string) =>
`/api/workspaces/${workspaceSlug}/members/${memberId}/`;
export const WORKSPACE_INVITATIONS = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/invitations/`;
export const WORKSPACE_INVITATION_DETAIL = (workspaceSlug: string, invitationId: string) =>
`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/`;
// projects
export const PROJECTS_ENDPOINT = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/projects/`;
export const PROJECT_DETAIL = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/`;
export const INVITE_PROJECT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/add/`;
export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`;
export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`;
export const PROJECT_INVITATIONS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/invitations/`;
export const PROJECT_INVITATION_DETAIL = (
workspaceSlug: string,
projectId: string,
invitationId: string
) => `/api/workspaces/${workspaceSlug}/projects/${projectId}/invitations/${invitationId}/`;
export const CHECK_PROJECT_IDENTIFIER = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/project-identifiers`;
// issues
export const ISSUES_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`;
export const ISSUE_DETAIL = (workspaceSlug: string, projectId: string, issueId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`;
export const ISSUES_BY_STATE = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/?group_by=state`;
export const ISSUE_PROPERTIES_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/`;
export const ISSUE_COMMENTS = (workspaceSlug: string, projectId: string, issueId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/comments/`;
export const ISSUE_COMMENT_DETAIL = (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string
) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/comments/${commentId}/`;
export const ISSUE_ACTIVITIES = (workspaceSlug: string, projectId: string, issueId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/history/`;
export const ISSUE_LABELS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-labels/`;
export const FILTER_STATE_ISSUES = (workspaceSlug: string, projectId: string, state: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/?state=${state}`;
// states
export const STATES_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/`;
export const STATE_DETAIL = (workspaceSlug: string, projectId: string, stateId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`;
// CYCLES
export const CYCLES_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`;
export const CYCLE_DETAIL = (workspaceSlug: string, projectId: string, cycleId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`;

109
constants/common.ts Normal file
View File

@ -0,0 +1,109 @@
export const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
export const renderDateFormat = (date: string | Date) => {
var d = new Date(date),
month = "" + (d.getMonth() + 1),
day = "" + d.getDate(),
year = d.getFullYear();
if (month.length < 2) month = "0" + month;
if (day.length < 2) day = "0" + day;
return [year, month, day].join("-");
};
export const renderShortNumericDateFormat = (date: string | Date) => {
return new Date(date).toLocaleDateString("en-UK", {
day: "numeric",
month: "short",
});
};
export const groupBy = (array: any[], key: string) => {
const innerKey = key.split("."); // split the key by dot
return array.reduce((result, currentValue) => {
const key = innerKey.reduce((obj, i) => obj[i], currentValue); // get the value of the inner key
(result[key] = result[key] || []).push(currentValue);
return result;
}, {});
};
export const timeAgo = (time: any) => {
switch (typeof time) {
case "number":
break;
case "string":
time = +new Date(time);
break;
case "object":
if (time.constructor === Date) time = time.getTime();
break;
default:
time = +new Date();
}
var time_formats = [
[60, "seconds", 1], // 60
[120, "1 minute ago", "1 minute from now"], // 60*2
[3600, "minutes", 60], // 60*60, 60
[7200, "1 hour ago", "1 hour from now"], // 60*60*2
[86400, "hours", 3600], // 60*60*24, 60*60
[172800, "Yesterday", "Tomorrow"], // 60*60*24*2
[604800, "days", 86400], // 60*60*24*7, 60*60*24
[1209600, "Last week", "Next week"], // 60*60*24*7*4*2
[2419200, "weeks", 604800], // 60*60*24*7*4, 60*60*24*7
[4838400, "Last month", "Next month"], // 60*60*24*7*4*2
[29030400, "months", 2419200], // 60*60*24*7*4*12, 60*60*24*7*4
[58060800, "Last year", "Next year"], // 60*60*24*7*4*12*2
[2903040000, "years", 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12
[5806080000, "Last century", "Next century"], // 60*60*24*7*4*12*100*2
[58060800000, "centuries", 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100
];
var seconds = (+new Date() - time) / 1000,
token = "ago",
list_choice = 1;
if (seconds == 0) {
return "Just now";
}
if (seconds < 0) {
seconds = Math.abs(seconds);
token = "from now";
list_choice = 2;
}
var i = 0,
format;
while ((format = time_formats[i++]))
if (seconds < format[0]) {
if (typeof format[2] == "string") return format[list_choice];
else return Math.floor(seconds / format[2]) + " " + format[1] + " " + token;
}
return time;
};
export const debounce = (func: any, wait: number, immediate: boolean = false) => {
let timeout: any;
return function executedFunction(...args: any) {
const later = () => {
timeout = null;
if (!immediate) func(...args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func(...args);
};
};
export const addSpaceIfCamelCase = (str: string) => {
return str.replace(/([a-z])([A-Z])/g, "$1 $2");
};
export const replaceUnderscoreIfSnakeCase = (str: string) => {
return str.replace(/_/g, " ");
};

32
constants/fetch-keys.ts Normal file
View File

@ -0,0 +1,32 @@
export const CURRENT_USER = "CURRENT_USER";
export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS";
export const USER_WORKSPACES = "USER_WORKSPACES";
export const WORKSPACE_MEMBERS = "WORKSPACE_MEMBERS";
export const WORKSPACE_INVITATIONS = "WORKSPACE_INVITATIONS";
export const WORKSPACE_INVITATION = "WORKSPACE_INVITATION";
export const PROJECTS_LIST = (workspaceSlug: string) => `PROJECTS_LIST_${workspaceSlug}`;
export const PROJECT_DETAILS = "PROJECT_DETAILS";
export const PROJECT_MEMBERS = (projectId: string) => `PROJECT_MEMBERS_${projectId}`;
export const PROJECT_INVITATIONS = "PROJECT_INVITATIONS";
export const PROJECT_ISSUES_LIST = (workspaceSlug: string, projectId: string) =>
`PROJECT_ISSUES_LIST_${workspaceSlug}_${projectId}`;
export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId}`;
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>
`PROJECT_ISSUES_PROPERTIES_${projectId}`;
export const PROJECT_ISSUES_COMMENTS = "PROJECT_ISSUES_COMMENTS";
export const PROJECT_ISSUES_ACTIVITY = "PROJECT_ISSUES_ACTIVITY";
export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId}`;
export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`;
export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`;
export const CYCLE_ISSUES = (sprintId: string) => `CYCLE_ISSUES_${sprintId}`;
export const CYCLE_DETAIL = "CYCLE_DETAIL";
export const STATE_LIST = (projectId: string) => `STATE_LIST_${projectId}`;
export const STATE_DETAIL = "STATE_DETAIL";
export const USER_ISSUE = "USER_ISSUE";

View File

@ -0,0 +1,8 @@
export const SITE_NAME = "Plane";
export const SITE_TITLE = "Plane | Accelerate software development with peace.";
export const SITE_DESCRIPTION =
"Plane accelerated the software development by order of magnitude for agencies and product companies.";
export const SITE_KEYWORDS =
"software development, plan, ship, software, accelerate, code management, release management";
export const SITE_URL = "http://localhost:3000/";
export const TWITTER_USER_NAME = "caravel";

View File

@ -0,0 +1,4 @@
export const TOGGLE_SIDEBAR = "TOGGLE_SIDEBAR";
export const REHYDRATE_THEME = "REHYDRATE_THEME";
export const SET_ISSUE_VIEW = "SET_ISSUE_VIEW";
export const SET_GROUP_BY_PROPERTY = "SET_GROUP_BY_PROPERTY";

View File

@ -0,0 +1,2 @@
export const SET_TOAST_ALERT = "SET_TOAST_ALERT";
export const REMOVE_TOAST_ALERT = "REMOVE_TOAST_ALERT";

View File

@ -0,0 +1,15 @@
import { UserProvider } from "./user.context";
import { ToastContextProvider } from "./toast.context";
import { ThemeContextProvider } from "./theme.context";
const GlobalContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<UserProvider>
<ToastContextProvider>
<ThemeContextProvider>{children}</ThemeContextProvider>
</ToastContextProvider>
</UserProvider>
);
};
export default GlobalContextProvider;

144
contexts/theme.context.tsx Normal file
View File

@ -0,0 +1,144 @@
import React, { createContext, useCallback, useReducer, useEffect } from "react";
// constants
import {
TOGGLE_SIDEBAR,
REHYDRATE_THEME,
SET_ISSUE_VIEW,
SET_GROUP_BY_PROPERTY,
} from "constants/theme.context.constants";
// components
import ToastAlert from "components/toast-alert";
export const themeContext = createContext<ContextType>({} as ContextType);
// types
import type { IIssue, NestedKeyOf } from "types";
type Theme = {
collapsed: boolean;
issueView: "list" | "kanban" | null;
groupByProperty: NestedKeyOf<IIssue> | null;
};
type ReducerActionType = {
type:
| typeof TOGGLE_SIDEBAR
| typeof REHYDRATE_THEME
| typeof SET_ISSUE_VIEW
| typeof SET_GROUP_BY_PROPERTY;
payload?: Partial<Theme>;
};
type ContextType = {
collapsed: boolean;
issueView: "list" | "kanban" | null;
groupByProperty: NestedKeyOf<IIssue> | null;
toggleCollapsed: () => void;
setIssueView: (display: "list" | "kanban") => void;
setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void;
};
type StateType = Theme;
type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType;
export const initialState: StateType = {
collapsed: false,
issueView: null,
groupByProperty: null,
};
export const reducer: ReducerFunctionType = (state, action) => {
const { type, payload } = action;
switch (type) {
case TOGGLE_SIDEBAR:
const newState = {
...state,
collapsed: !state.collapsed,
};
localStorage.setItem("theme", JSON.stringify(newState));
return newState;
case REHYDRATE_THEME: {
let newState: any = localStorage.getItem("theme");
if (newState !== null) {
newState = JSON.parse(newState);
}
return { ...initialState, ...newState };
}
case SET_ISSUE_VIEW: {
const newState = {
...state,
issueView: payload?.issueView || "list",
};
localStorage.setItem("theme", JSON.stringify(newState));
return {
...state,
...newState,
};
}
case SET_GROUP_BY_PROPERTY: {
const newState = {
...state,
groupByProperty: payload?.groupByProperty || null,
};
localStorage.setItem("theme", JSON.stringify(newState));
return {
...state,
...newState,
};
}
default: {
return state;
}
}
};
export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const toggleCollapsed = useCallback(() => {
dispatch({
type: TOGGLE_SIDEBAR,
});
}, []);
const setIssueView = useCallback((display: "list" | "kanban") => {
dispatch({
type: SET_ISSUE_VIEW,
payload: {
issueView: display,
},
});
}, []);
const setGroupByProperty = useCallback((property: NestedKeyOf<IIssue> | null) => {
dispatch({
type: SET_GROUP_BY_PROPERTY,
payload: {
groupByProperty: property,
},
});
}, []);
useEffect(() => {
dispatch({
type: REHYDRATE_THEME,
});
}, []);
return (
<themeContext.Provider
value={{
collapsed: state.collapsed,
toggleCollapsed,
issueView: state.issueView,
setIssueView,
groupByProperty: state.groupByProperty,
setGroupByProperty,
}}
>
<ToastAlert />
{children}
</themeContext.Provider>
);
};

101
contexts/toast.context.tsx Normal file
View File

@ -0,0 +1,101 @@
import React, { createContext, useCallback, useReducer } from "react";
// uuid
import { v4 as uuid } from "uuid";
// constants
import { SET_TOAST_ALERT, REMOVE_TOAST_ALERT } from "constants/toast.context.constants";
// components
import ToastAlert from "components/toast-alert";
export const toastContext = createContext<ContextType>({} as ContextType);
// types
type ToastAlert = {
id: string;
title: string;
message?: string;
type: "success" | "error" | "warning" | "info";
};
type ReducerActionType = {
type: typeof SET_TOAST_ALERT | typeof REMOVE_TOAST_ALERT;
payload: ToastAlert;
};
type ContextType = {
alerts?: ToastAlert[];
removeAlert: (id: string) => void;
setToastAlert: (data: {
title: string;
type?: "success" | "error" | "warning" | "info" | undefined;
message?: string | undefined;
}) => void;
};
type StateType = {
toastAlerts?: ToastAlert[];
};
type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType;
export const initialState: StateType = {
toastAlerts: [],
};
export const reducer: ReducerFunctionType = (state, action) => {
const { type, payload } = action;
switch (type) {
case SET_TOAST_ALERT:
return {
...state,
toastAlerts: [...(state.toastAlerts ?? []), payload],
};
case REMOVE_TOAST_ALERT:
return {
...state,
toastAlerts: state.toastAlerts?.filter((toastAlert) => toastAlert.id !== payload.id),
};
default: {
return state;
}
}
};
export const ToastContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const removeAlert = useCallback((id: string) => {
dispatch({
type: REMOVE_TOAST_ALERT,
payload: { id, title: "", message: "", type: "success" },
});
}, []);
const setToastAlert = useCallback(
(data: {
title: string;
type?: "success" | "error" | "warning" | "info";
message?: string;
}) => {
const id = uuid();
const { title, type, message } = data;
dispatch({
type: SET_TOAST_ALERT,
payload: { id, title, message, type: type ?? "success" },
});
const timer = setTimeout(() => {
removeAlert(id);
clearTimeout(timer);
}, 5000);
},
[removeAlert]
);
return (
<toastContext.Provider value={{ setToastAlert, removeAlert, alerts: state.toastAlerts }}>
<ToastAlert />
{children}
</toastContext.Provider>
);
};

154
contexts/user.context.tsx Normal file
View File

@ -0,0 +1,154 @@
import React, { createContext, ReactElement, useEffect, useState, useCallback } from "react";
// next
import Router from "next/router";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// services
import userService from "lib/services/user.service";
import issuesServices from "lib/services/issues.services";
import stateServices from "lib/services/state.services";
import sprintsServices from "lib/services/cycles.services";
import projectServices from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// constants
import {
CURRENT_USER,
PROJECTS_LIST,
USER_WORKSPACES,
USER_WORKSPACE_INVITATIONS,
PROJECT_ISSUES_LIST,
STATE_LIST,
CYCLE_LIST,
} from "constants/fetch-keys";
// types
import type { KeyedMutator } from "swr";
import type { IUser, IWorkspace, IProject, IIssue, IssueResponse, ICycle, IState } from "types";
interface IUserContextProps {
user?: IUser;
isUserLoading: boolean;
mutateUser: KeyedMutator<IUser>;
activeWorkspace?: IWorkspace;
mutateWorkspaces: KeyedMutator<IWorkspace[]>;
workspaces?: IWorkspace[];
projects?: IProject[];
setActiveProject: React.Dispatch<React.SetStateAction<IProject | undefined>>;
mutateProjects: KeyedMutator<IProject[]>;
activeProject?: IProject;
issues?: IssueResponse;
mutateIssues: KeyedMutator<IssueResponse>;
sprints?: ICycle[];
mutateSprints: KeyedMutator<ICycle[]>;
states?: IState[];
mutateStates: KeyedMutator<IState[]>;
}
export const UserContext = createContext<IUserContextProps>({} as IUserContextProps);
export const UserProvider = ({ children }: { children: ReactElement }) => {
const router = useRouter();
const { projectId } = router.query;
const [activeWorkspace, setActiveWorkspace] = useState<IWorkspace | undefined>();
const [activeProject, setActiveProject] = useState<IProject | undefined>();
// API to fetch user information
const { data, error, mutate } = useSWR<IUser>(CURRENT_USER, () => userService.currentUser(), {
shouldRetryOnError: false,
});
const {
data: workspaces,
error: workspaceError,
mutate: mutateWorkspaces,
} = useSWR<IWorkspace[]>(
data ? USER_WORKSPACES : null,
data ? () => workspaceService.userWorkspaces() : null,
{
shouldRetryOnError: false,
}
);
const { data: projects, mutate: mutateProjects } = useSWR<IProject[]>(
activeWorkspace ? PROJECTS_LIST(activeWorkspace.slug) : null,
activeWorkspace ? () => projectServices.getProjects(activeWorkspace.slug) : null
);
const { data: issues, mutate: mutateIssues } = useSWR<IssueResponse>(
activeWorkspace && activeProject
? PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id)
: null,
activeWorkspace && activeProject
? () => issuesServices.getIssues(activeWorkspace.slug, activeProject.id)
: null
);
const { data: states, mutate: mutateStates } = useSWR<IState[]>(
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
: null
);
const { data: sprints, mutate: mutateSprints } = useSWR<ICycle[]>(
activeWorkspace && activeProject ? CYCLE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => sprintsServices.getCycles(activeWorkspace.slug, activeProject.id)
: null
);
useEffect(() => {
if (!projects) return;
const activeProject = projects.find((project) => project.id === projectId);
setActiveProject(activeProject ?? projects[0]);
}, [projectId, projects]);
useEffect(() => {
if (data?.last_workspace_id) {
const workspace = workspaces?.find((item) => item.id === data?.last_workspace_id);
if (workspace) {
setActiveWorkspace(workspace);
} else {
const workspace = workspaces?.[0];
setActiveWorkspace(workspace);
userService.updateUser({ last_workspace_id: workspace?.id });
}
} else if (data) {
const workspace = workspaces?.[0];
setActiveWorkspace(workspace);
userService.updateUser({ last_workspace_id: workspace?.id });
}
}, [data, workspaces]);
useEffect(() => {
if (!workspaces) return;
if (workspaces.length === 0) Router.push("/invitations");
}, [workspaces]);
return (
<UserContext.Provider
value={{
user: error ? undefined : data,
isUserLoading: !(!!data || !!error),
mutateUser: mutate,
activeWorkspace: workspaceError ? undefined : activeWorkspace,
mutateWorkspaces: mutateWorkspaces,
workspaces: workspaceError ? undefined : workspaces,
projects,
mutateProjects: mutateProjects,
activeProject,
issues,
mutateIssues,
sprints,
mutateSprints,
states,
mutateStates,
setActiveProject,
}}
>
{children}
</UserContext.Provider>
);
};

91
google.d.ts vendored Normal file
View File

@ -0,0 +1,91 @@
// google.d.ts
interface IdConfiguration {
client_id: string;
auto_select?: boolean;
callback: (handleCredentialResponse: CredentialResponse) => void;
login_uri?: string;
native_callback?: (...args: any[]) => void;
cancel_on_tap_outside?: boolean;
prompt_parent_id?: string;
nonce?: string;
context?: string;
state_cookie_domain?: string;
ux_mode?: "popup" | "redirect";
allowed_parent_origin?: string | string[];
intermediate_iframe_close_callback?: (...args: any[]) => void;
}
interface CredentialResponse {
credential?: string;
select_by?:
| "auto"
| "user"
| "user_1tap"
| "user_2tap"
| "btn"
| "btn_confirm"
| "brn_add_session"
| "btn_confirm_add_session";
clientId?: string;
}
interface GsiButtonConfiguration {
type: "standard" | "icon";
theme?: "outline" | "filled_blue" | "filled_black";
size?: "large" | "medium" | "small";
text?: "signin_with" | "signup_with" | "continue_with" | "signup_with";
shape?: "rectangular" | "pill" | "circle" | "square";
logo_alignment?: "left" | "center";
width?: string;
local?: string;
}
interface PromptMomentNotification {
isDisplayMoment: () => boolean;
isDisplayed: () => boolean;
isNotDisplayed: () => boolean;
getNotDisplayedReason: () =>
| "browser_not_supported"
| "invalid_client"
| "missing_client_id"
| "opt_out_or_no_session"
| "secure_http_required"
| "suppressed_by_user"
| "unregistered_origin"
| "unknown_reason";
isSkippedMoment: () => boolean;
getSkippedReason: () => "auto_cancel" | "user_cancel" | "tap_outside" | "issuing_failed";
isDismissedMoment: () => boolean;
getDismissedReason: () => "credential_returned" | "cancel_called" | "flow_restarted";
getMomentType: () => "display" | "skipped" | "dismissed";
}
interface RevocationResponse {
successful: boolean;
error: string;
}
interface Credential {
id: string;
password: string;
}
interface Google {
accounts: {
id: {
initialize: (input: IdConfiguration) => void;
prompt: (momentListener?: (res: PromptMomentNotification) => void) => void;
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
disableAutoSelect: () => void;
storeCredential: (credentials: Credential, callback: () => void) => void;
cancel: () => void;
onGoogleLibraryLoad: () => void;
revoke: (hint: string, callback: (done: RevocationResponse) => void) => void;
};
};
}
interface Window {
google?: Google;
}

19
layouts/AdminLayout.tsx Normal file
View File

@ -0,0 +1,19 @@
import React from "react";
// layouts
import Container from "layouts/Container";
// types
import type { Props } from "./types";
const AdminLayout: React.FC<Props> = ({ meta, children }) => {
return (
<Container meta={meta}>
<div className="w-full h-screen overflow-auto">
<>{children}</>
</div>
</Container>
);
};
export default AdminLayout;

71
layouts/Container.tsx Normal file
View File

@ -0,0 +1,71 @@
import Head from "next/head";
import { useRouter } from "next/router";
import {
SITE_NAME,
SITE_DESCRIPTION,
SITE_URL,
TWITTER_USER_NAME,
SITE_KEYWORDS,
SITE_TITLE,
} from "constants/seo/seo-variables";
// types
import type { Props } from "./types";
const Container = ({ meta, children }: Props) => {
const router = useRouter();
const image = meta?.image || "/site-image.png";
const title = meta?.title || SITE_TITLE;
const url = meta?.url || `${SITE_URL}${router.asPath}`;
const description = meta?.description || SITE_DESCRIPTION;
return (
<>
<Head>
<title>{title}</title>
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:title" content={title} />
<meta property="og:url" content={url} />
<meta name="description" content={description} />
<meta property="og:description" content={description} />
<meta name="keywords" content={SITE_KEYWORDS} />
<meta name="twitter:site" content={`@${TWITTER_USER_NAME}`} />
<meta
name="twitter:card"
content={image ? "summary_large_image" : "summary"}
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicon/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest.json" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
{image && (
<meta
property="og:image"
content={
image.startsWith("https://") ? image : `${SITE_URL}${image}`
}
/>
)}
</Head>
{children}
</>
);
};
export default Container;

21
layouts/DefaultLayout.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from "react";
// layouts
import Container from "layouts/Container";
import DefaultTopBar from "layouts/Navbar/DefaultTopBar";
// types
import type { Props } from "./types";
const DefaultLayout: React.FC<Props> = ({ meta, children }) => {
return (
<Container meta={meta}>
<div className="w-full h-screen overflow-auto bg-gray-50">
{/* <DefaultTopBar /> */}
<>{children}</>
</div>
</Container>
);
};
export default DefaultLayout;

View File

@ -0,0 +1,35 @@
import React from "react";
// next js
import Link from "next/link";
// hooks
import useUser from "lib/hooks/useUser";
const DefaultTopBar: React.FC = () => {
const { user } = useUser();
return (
<div className="flex justify-between items-center px-4 h-16 sm:px-6 md:justify-start md:space-x-10 absolute top-0 w-full">
<div className="w-full flex items-center justify-between">
<div>
<Link href="/">
<a className="flex">
<span className="sr-only">Plane</span>
<h2 className="text-2xl font-semibold">
Plan<span className="text-indigo-600">e</span>
</h2>
</a>
</Link>
</div>
{user && (
<div>
<p className="text-sm text-gray-500">
logged in as {user.first_name}
</p>
</div>
)}
</div>
</div>
);
};
export default DefaultTopBar;

501
layouts/Navbar/Sidebar.tsx Normal file
View File

@ -0,0 +1,501 @@
// next
import Link from "next/link";
import { useRouter } from "next/router";
// react
import React, { useState } from "react";
// services
import useUser from "lib/hooks/useUser";
import userService from "lib/services/user.service";
// components
import CreateProjectModal from "components/project/CreateProjectModal";
// types
import { IUser } from "types";
// headless ui
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
// icons
import {
ArrowPathIcon,
Bars3Icon,
ChevronDownIcon,
Cog6ToothIcon,
HomeIcon,
ClipboardDocumentListIcon,
PlusIcon,
RectangleStackIcon,
UserGroupIcon,
UserIcon,
XMarkIcon,
InboxIcon,
ArrowLongLeftIcon,
} from "@heroicons/react/24/outline";
// constants
import { classNames } from "constants/common";
import { Spinner } from "ui";
import useTheme from "lib/hooks/useTheme";
import authenticationService from "lib/services/authentication.service";
const navigation = (projectId: string) => [
{
name: "Issues",
href: `/projects/${projectId}/issues`,
icon: RectangleStackIcon,
},
{
name: "Cycles",
href: `/projects/${projectId}/cycles`,
icon: ArrowPathIcon,
},
{
name: "Members",
href: `/projects/${projectId}/members`,
icon: UserGroupIcon,
},
{
name: "Settings",
href: `/projects/${projectId}/settings`,
icon: Cog6ToothIcon,
},
];
const navLinks = [
{
icon: HomeIcon,
name: "Home",
href: `/workspace`,
},
{
icon: ClipboardDocumentListIcon,
name: "Projects",
href: "/projects",
},
{
icon: RectangleStackIcon,
name: "My Issues",
href: "/me/my-issues",
},
{
icon: UserGroupIcon,
name: "Members",
href: "/workspace/members",
},
// {
// icon: InboxIcon,
// name: "Inbox",
// href: "#",
// },
{
icon: Cog6ToothIcon,
name: "Settings",
href: "/workspace/settings",
},
];
const userLinks = [
{
name: "My Profile",
href: "/me/profile",
},
{
name: "Workspace Invites",
href: "/invitations",
},
];
const Sidebar: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isCreateProjectModal, setCreateProjectModal] = useState(false);
const router = useRouter();
const { projects } = useUser();
const { projectId } = router.query;
const { workspaces, activeWorkspace, mutateUser } = useUser();
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
return (
<nav className="h-screen">
<CreateProjectModal isOpen={isCreateProjectModal} setIsOpen={setCreateProjectModal} />
<Transition.Root show={sidebarOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-40 md:hidden" onClose={setSidebarOpen}>
<Transition.Child
as={React.Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={React.Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-white">
<Transition.Child
as={React.Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
</button>
</div>
</Transition.Child>
<div className="h-0 flex-1 overflow-y-auto pt-5 pb-4">
<nav className="mt-5 space-y-1 px-2">
{projectId &&
navigation(projectId as string).map((item) => (
<Link href={item.href} key={item.name}>
<a
className={classNames(
item.href === router.asPath
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
"group flex items-center px-2 py-2 text-base font-medium rounded-md"
)}
>
<item.icon
className={classNames(
item.href === router.asPath
? "text-gray-500"
: "text-gray-400 group-hover:text-gray-500",
"mr-4 flex-shrink-0 h-6 w-6"
)}
aria-hidden="true"
/>
{item.name}
</a>
</Link>
))}
</nav>
</div>
</Dialog.Panel>
</Transition.Child>
<div className="w-14 flex-shrink-0" />
</div>
</Dialog>
</Transition.Root>
<div
className={`${
sidebarCollapse ? "" : "w-auto md:w-64"
} hidden md:inset-y-0 md:flex md:flex-col h-full`}
>
<div className="h-full flex flex-1 flex-col border-r border-gray-200">
<div className="h-full flex flex-1 flex-col pt-5">
<div className="px-2">
<div className={`relative ${sidebarCollapse ? "flex" : "grid grid-cols-5 gap-1"}`}>
<Menu as="div" className="col-span-4 inline-block text-left w-full">
<div className="w-full">
<Menu.Button
className={`inline-flex justify-between items-center w-full rounded-md px-2 py-2 text-sm font-semibold text-gray-700 focus:outline-none ${
!sidebarCollapse ? "hover:bg-gray-50 border border-gray-300 shadow-sm" : ""
}`}
>
<span className="flex gap-x-1 items-center">
<p className="h-5 w-5 p-4 flex items-center justify-center bg-gray-500 text-white rounded uppercase">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</p>
{!sidebarCollapse && (
<p className="truncate w-20 text-left ml-1">
{activeWorkspace?.name ?? "Loading..."}
</p>
)}
</span>
{!sidebarCollapse && (
<div className="flex-grow flex justify-end">
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" 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 fixed max-w-[15rem] ml-2 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">
{workspaces ? (
<>
{workspaces.length > 0 ? (
workspaces.map((workspace: any) => (
<Menu.Item key={workspace.id}>
{({ active }) => (
<button
type="button"
onClick={() => {
mutateUser(
(prevData) => ({
...(prevData as IUser),
last_workspace_id: workspace.id,
}),
false
);
userService
.updateUser({
last_workspace_id: workspace?.id,
})
.then((res) => {
router.push("/workspace");
})
.catch((err) => console.log);
}}
className={`${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-sm`}
>
{workspace.name}
</button>
)}
</Menu.Item>
))
) : (
<p>No workspace found!</p>
)}
<Menu.Item>
{(active) => (
<Link href="/create-workspace">
<a className="flex items-center gap-x-1 p-2 w-full text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-sm">
<PlusIcon className="w-5 h-5" />
<span>Create Workspace</span>
</a>
</Link>
)}
</Menu.Item>
</>
) : (
<div className="w-full flex justify-center">
<Spinner />
</div>
)}
</div>
</Menu.Items>
</Transition>
</Menu>
{!sidebarCollapse && (
<Menu as="div" className="inline-block text-left w-full">
<div className="h-full w-full">
<Menu.Button className="grid place-items-center h-full w-full rounded-md shadow-sm px-2 py-2 bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 focus:outline-none">
<UserIcon className="h-5 w-5" />
</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-right 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">
{userLinks.map((item) => (
<Menu.Item key={item.name} as="div">
{(active) => (
<Link href={item.href}>
<a className="flex items-center gap-x-1 p-2 w-full text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-sm">
{item.name}
</a>
</Link>
)}
</Menu.Item>
))}
<Menu.Item as="div">
<button
type="button"
className="flex items-center gap-x-1 p-2 w-full text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-sm"
onClick={async () => {
await authenticationService
.signOut({
refresh_token: authenticationService.getRefreshToken(),
})
.then((response) => {
console.log("user signed out", response);
})
.catch((error) => {
console.log("Failed to sign out", error);
})
.finally(() => {
mutateUser();
router.push("/signin");
});
}}
>
Sign Out
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
<div className="mt-3 flex-1 space-y-1 bg-white">
{navLinks.map((link, index) => (
<Link key={index} href={link.href}>
<a
className={`${
link.href === router.asPath ? "bg-theme text-white" : "hover:bg-indigo-100"
} group flex items-center gap-3 px-2 py-2 text-xs font-medium rounded-md ${
sidebarCollapse ? "justify-center" : ""
}`}
>
<link.icon
className={`${
link.href === router.asPath ? "text-white" : ""
} flex-shrink-0 h-4 w-4`}
aria-hidden="true"
/>
{!sidebarCollapse && link.name}
</a>
</Link>
))}
</div>
</div>
<div
className={`flex flex-col px-2 pt-5 pb-3 mt-3 space-y-2 bg-gray-50 h-full overflow-y-auto ${
sidebarCollapse ? "rounded-xl" : "rounded-t-3xl"
}`}
>
{projects ? (
<>
{projects.length > 0 ? (
projects.map((project) => (
<Disclosure key={project?.id} defaultOpen={projectId === project?.id}>
<Disclosure.Button
className={`w-full flex items-center gap-2 font-medium rounded-md p-2 text-sm ${
sidebarCollapse ? "justify-center" : ""
}`}
>
<span className="bg-gray-700 text-white rounded h-7 w-7 grid place-items-center uppercase">
{project?.name.charAt(0)}
</span>
{!sidebarCollapse && project?.name}
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel
className={`${
sidebarCollapse ? "" : "ml-[2.25rem]"
} flex flex-col gap-y-1`}
>
{navigation(project?.id).map((item) => (
<Link key={item.name} href={item.href}>
<a
className={classNames(
item.href === router.asPath
? "bg-gray-200 text-gray-900"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-900",
"group flex items-center px-2 py-2 text-xs font-medium rounded-md",
sidebarCollapse ? "justify-center" : ""
)}
>
<item.icon
className={classNames(
item.href === router.asPath
? "text-gray-900"
: "text-gray-500 group-hover:text-gray-900",
"flex-shrink-0 h-4 w-4",
!sidebarCollapse ? "mr-3" : ""
)}
aria-hidden="true"
/>
{!sidebarCollapse && item.name}
</a>
</Link>
))}
</Disclosure.Panel>
</Transition>
</Disclosure>
))
) : (
<div className="text-center space-y-3">
<h4 className="text-gray-700 text-sm">You don{"'"}t have any project yet</h4>
<button
type="button"
className="group flex justify-center items-center gap-2 w-full rounded-md p-2 text-sm bg-theme text-white"
onClick={() => setCreateProjectModal(true)}
>
<PlusIcon className="h-5 w-5" />
Create Project
</button>
</div>
)}
</>
) : (
<div className="w-full flex justify-center">
<Spinner />
</div>
)}
</div>
<div className="px-2 py-2 bg-gray-50 w-full self-baseline">
<button
type="button"
className={`flex items-center gap-3 px-2 py-2 text-xs font-medium rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 w-full ${
sidebarCollapse ? "justify-center" : ""
}`}
onClick={() => toggleCollapsed()}
>
<ArrowLongLeftIcon
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
sidebarCollapse ? "rotate-180" : ""
}`}
/>
{!sidebarCollapse && "Collapse"}
</button>
</div>
</div>
</div>
</div>
<div className="sticky top-0 z-10 bg-white pl-1 pt-1 sm:pl-3 sm:pt-3 md:hidden">
<button
type="button"
className="-ml-0.5 -mt-0.5 inline-flex h-12 w-12 items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</nav>
);
};
export default Sidebar;

27
layouts/ProjectLayout.tsx Normal file
View File

@ -0,0 +1,27 @@
import React, { useState } from "react";
// components
import CreateProjectModal from "components/project/CreateProjectModal";
// layouts
import AdminLayout from "layouts/AdminLayout";
// types
import type { Props } from "./types";
// components
import Sidebar from "./Navbar/Sidebar";
const ProjectLayouts: React.FC<Props> = ({ children, meta }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<AdminLayout meta={meta}>
<CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} />
<div className="h-full w-full overflow-x-hidden relative flex">
<Sidebar />
<main className="h-full w-full mx-auto min-w-0 pb-6 overflow-y-hidden">
<div className="h-full w-full px-8 py-6 overflow-auto">{children}</div>
</main>
</div>
</AdminLayout>
);
};
export default ProjectLayouts;

11
layouts/types.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
export type Meta = {
title?: string | null;
description?: string | null;
image?: string | null;
url?: string | null;
};
export type Props = {
meta?: Meta;
children: React.ReactNode;
};

3
lib/cookie.ts Normal file
View File

@ -0,0 +1,3 @@
export const setCookie = () => {
// add set cookie logic here
};

View File

@ -0,0 +1,30 @@
import React, { useEffect } from "react";
// next
import type { NextPage } from "next";
// axios configurations
import { setAxiosHeader } from "configuration/axios-configuration";
// redirect
import redirect from "lib/redirect";
const withAuthWrapper = (WrappedComponent: NextPage) => {
const Wrapper: NextPage<any> = (props) => {
useEffect(() => {
if (props?.tokenDetails && props?.tokenDetails?.access_token) {
setAxiosHeader(props.tokenDetails.access_token);
}
}, [props]);
return <WrappedComponent {...props} />;
};
Wrapper.getInitialProps = async (ctx) => {
const componentProps =
WrappedComponent.getInitialProps &&
(await WrappedComponent.getInitialProps(ctx));
return { ...componentProps };
};
return Wrapper;
};
export default withAuthWrapper;

View File

@ -0,0 +1,21 @@
import { useEffect } from "react";
// Updates the height of a <textarea> when the value changes.
const useAutosizeTextArea = (
textAreaRef: HTMLTextAreaElement | null,
value: any
) => {
useEffect(() => {
if (textAreaRef) {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
textAreaRef.style.height = "0px";
const scrollHeight = textAreaRef.scrollHeight;
// We then set the height directly, outside of the render loop
// Trying to set this with state or a ref will product an incorrect value.
textAreaRef.style.height = scrollHeight + "px";
}
}, [textAreaRef, value]);
};
export default useAutosizeTextArea;

View File

@ -0,0 +1,86 @@
import { useState, useContext, useEffect, useCallback } from "react";
// swr
import useSWR from "swr";
// api routes
import { ISSUE_PROPERTIES_ENDPOINT } from "constants/api-routes";
// services
import issueServices from "lib/services/issues.services";
// hooks
import useUser from "./useUser";
// types
import { IssuePriorities, Properties } from "types";
const initialValues: Properties = {
name: true,
key: true,
parent: false,
project: false,
state: true,
assignee: true,
description: false,
priority: false,
start_date: false,
target_date: false,
sequence_id: false,
attachments: false,
children: false,
cycle: false,
};
const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
const [properties, setProperties] = useState<Properties>(initialValues);
const { user } = useUser();
const { data: issueProperties } = useSWR<IssuePriorities>(
workspaceSlug && projectId ? ISSUE_PROPERTIES_ENDPOINT(workspaceSlug, projectId) : null,
workspaceSlug && projectId
? () => issueServices.getIssueProperties(workspaceSlug, projectId)
: null,
{
shouldRetryOnError: false,
}
);
useEffect(() => {
if (!issueProperties || !workspaceSlug || !projectId || !user) return;
setProperties({ ...initialValues, ...issueProperties.properties });
if (Object.keys(issueProperties).length === 0)
issueServices.createIssueProperties(workspaceSlug, projectId, {
properties: { ...initialValues },
user: user.id,
});
else if (Object.keys(issueProperties?.properties).length === 0)
issueServices.patchIssueProperties(workspaceSlug, projectId, issueProperties.id, {
properties: { ...initialValues },
user: user.id,
});
}, [issueProperties, workspaceSlug, projectId, user]);
const updateIssueProperties = useCallback(
(key: keyof Properties) => {
if (!workspaceSlug || !projectId || !issueProperties || !user) return;
setProperties((prev) => ({ ...prev, [key]: !prev[key] }));
if (Object.keys(issueProperties).length > 0) {
issueServices.patchIssueProperties(workspaceSlug, projectId, issueProperties.id, {
properties: {
...issueProperties.properties,
[key]: !issueProperties.properties[key],
},
user: user.id,
});
} else {
issueServices.createIssueProperties(workspaceSlug, projectId, {
properties: { ...initialValues },
user: user.id,
});
}
},
[workspaceSlug, projectId, issueProperties, user]
);
return [properties, updateIssueProperties] as const;
};
export default useIssuesProperties;

View File

@ -0,0 +1,38 @@
import { useState, useEffect, useCallback } from "react";
const useLocalStorage = <T,>(
key: string,
initialValue?: T extends Function ? never : T | (() => T)
) => {
const [value, setValue] = useState<T | string>("");
useEffect(() => {
const data = window.localStorage.getItem(key);
if (data !== null && data !== "undefined") setValue(JSON.parse(data));
else setValue(typeof initialValue === "function" ? initialValue() : initialValue);
}, [key, initialValue]);
const updateState = useCallback(
(value: T) => {
if (!value) window.localStorage.removeItem(key);
else window.localStorage.setItem(key, JSON.stringify(value));
setValue(value);
window.dispatchEvent(new Event(`local-storage-change-${key}`));
},
[key]
);
const reHydrateState = useCallback(() => {
const data = window.localStorage.getItem(key);
if (data !== null) setValue(JSON.parse(data));
}, [key]);
useEffect(() => {
window.addEventListener(`local-storage-change-${key}`, reHydrateState);
return () => window.removeEventListener(`local-storage-change-${key}`, reHydrateState);
}, [reHydrateState, key]);
return [value, updateState];
};
export default useLocalStorage;

11
lib/hooks/useTheme.tsx Normal file
View File

@ -0,0 +1,11 @@
import { useContext } from "react";
import { themeContext } from "contexts/theme.context";
const useTheme = () => {
const themeContextData = useContext(themeContext);
return themeContextData;
};
export default useTheme;

11
lib/hooks/useToast.tsx Normal file
View File

@ -0,0 +1,11 @@
import { useContext } from "react";
import { toastContext } from "contexts/toast.context";
const useToast = () => {
const toastContextData = useContext(toastContext);
return toastContextData;
};
export default useToast;

42
lib/hooks/useUser.tsx Normal file
View File

@ -0,0 +1,42 @@
import { useContext, useEffect } from "react";
import { useRouter } from "next/router";
// context
import { UserContext } from "contexts/user.context";
interface useUserOptions {
redirectTo?: string;
}
const useUser = (options: useUserOptions = {}) => {
// props
const { redirectTo = null } = options;
// context
const contextData = useContext(UserContext);
// router
const router = useRouter();
/**
* Checks for redirect url and user details from the API.
* if the user is not authenticated, user will be redirected
* to the provided redirectTo route.
*/
useEffect(() => {
if (!contextData?.user || !redirectTo) return;
if (!contextData?.user) {
if (redirectTo) {
router?.pathname !== redirectTo && router.push(redirectTo);
}
router?.pathname !== "/signin" && router.push("/signin");
}
if (contextData?.user) {
if (redirectTo) {
router?.pathname !== redirectTo && router.push(redirectTo);
}
}
}, [contextData?.user, redirectTo, router]);
return contextData;
};
export default useUser;

16
lib/redirect.ts Normal file
View File

@ -0,0 +1,16 @@
// next imports
import Router from "next/router";
const redirect = (context: any, target: any) => {
if (context.res) {
// server
// 303: "See other"
context.res.writeHead(301, { Location: target });
context.res.end();
} else {
// In the browser, we just pretend like this never even happened ;)
Router.push(target);
}
};
export default redirect;

107
lib/services/api.service.ts Normal file
View File

@ -0,0 +1,107 @@
import axios from "axios";
import Cookies from "js-cookie";
abstract class APIService {
protected baseURL: string;
protected headers: any = {};
constructor(baseURL: string) {
this.baseURL = baseURL;
}
setRefreshToken(token: string) {
Cookies.set("refreshToken", token);
}
getRefreshToken() {
return Cookies.get("refreshToken");
}
purgeRefreshToken() {
Cookies.remove("refreshToken", { path: "/" });
}
setAccessToken(token: string) {
Cookies.set("accessToken", token);
}
getAccessToken() {
return Cookies.get("accessToken");
}
purgeAccessToken() {
Cookies.remove("accessToken", { path: "/" });
}
getHeaders() {
return {
Authorization: `Bearer ${this.getAccessToken()}`,
};
}
get(url: string, config = {}): Promise<any> {
return axios({
method: "get",
url: this.baseURL + url,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
});
}
post(url: string, data = {}, config = {}): Promise<any> {
return axios({
method: "post",
url: this.baseURL + url,
data,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
});
}
put(url: string, data = {}, config = {}): Promise<any> {
return axios({
method: "put",
url: this.baseURL + url,
data,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
});
}
patch(url: string, data = {}, config = {}): Promise<any> {
return axios({
method: "patch",
url: this.baseURL + url,
data,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
});
}
delete(url: string, config = {}): Promise<any> {
return axios({
method: "delete",
url: this.baseURL + url,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
});
}
mediaUpload(url: string, data = {}, config = {}): Promise<any> {
return axios({
method: "post",
url: this.baseURL + url,
data,
headers: this.getAccessToken()
? { ...this.getHeaders(), "Content-Type": "multipart/form-data" }
: {},
...config,
});
}
request(config = {}) {
return axios(config);
}
}
export default APIService;

View File

@ -0,0 +1,71 @@
// api routes
import { SIGN_IN_ENDPOINT, SOCIAL_AUTH_ENDPOINT, MAGIC_LINK_GENERATE, MAGIC_LINK_SIGNIN } from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class AuthService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async emailLogin(data: any) {
return this.post(SIGN_IN_ENDPOINT, data, { headers: {} })
.then((response) => {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async socialAuth(data: any) {
return this.post(SOCIAL_AUTH_ENDPOINT, data, { headers: {} })
.then((response) => {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async emailCode(data: any) {
return this.post(MAGIC_LINK_GENERATE, data, { headers: {} })
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async magicSignIn(data: any) {
const response = await this.post(MAGIC_LINK_SIGNIN, data, { headers: {} });
if (response?.status === 200) {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
}
throw response.response.data;
}
async signOut(data = {}) {
return this.post("/api/sign-out/", data)
.then((response) => {
this.purgeAccessToken();
this.purgeRefreshToken();
return response?.data;
})
.catch((error) => {
this.purgeAccessToken();
this.purgeRefreshToken();
throw error?.response?.data;
});
}
}
export default new AuthService();

View File

@ -0,0 +1,84 @@
// api routes
import { CYCLES_ENDPOINT, CYCLE_DETAIL } from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class ProjectCycleServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async createCycle(workspace_slug: string, projectId: string, data: any): Promise<any> {
return this.post(CYCLES_ENDPOINT(workspace_slug, projectId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getCycles(workspace_slug: string, projectId: string): Promise<any> {
return this.get(CYCLES_ENDPOINT(workspace_slug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getCycleIssues(workspace_slug: string, projectId: string, cycleId: string): Promise<any> {
return this.get(CYCLE_DETAIL(workspace_slug, projectId, cycleId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getCycle(workspace_slug: string, projectId: string, cycleId: string): Promise<any> {
return this.get(CYCLE_DETAIL(workspace_slug, projectId, cycleId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateCycle(
workspace_slug: string,
projectId: string,
cycleId: string,
data: any
): Promise<any> {
return this.put(
CYCLE_DETAIL(workspace_slug, projectId, cycleId).replace("cycle-issues/", ""),
data
)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteCycle(workspace_slug: string, projectId: string, cycleId: string): Promise<any> {
return this.delete(
CYCLE_DETAIL(workspace_slug, projectId, cycleId).replace("cycle-issues/", "")
)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new ProjectCycleServices();

View File

@ -0,0 +1,24 @@
// api routes
import { S3_URL } from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class FileServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async uploadFile(file: FormData): Promise<any> {
return this.mediaUpload(S3_URL, file)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new FileServices();

View File

@ -0,0 +1,242 @@
// api routes
import {
ISSUES_ENDPOINT,
ISSUE_DETAIL,
ISSUE_ACTIVITIES,
ISSUE_COMMENTS,
ISSUE_COMMENT_DETAIL,
ISSUE_PROPERTIES_ENDPOINT,
CYCLE_DETAIL,
ISSUE_LABELS,
} from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
import { IIssue, IIssueComment } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class ProjectIssuesServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async createIssues(workspace_slug: string, projectId: string, data: any): Promise<any> {
return this.post(ISSUES_ENDPOINT(workspace_slug, projectId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getIssues(workspace_slug: string, projectId: string): Promise<any> {
return this.get(ISSUES_ENDPOINT(workspace_slug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getIssue(workspace_slug: string, projectId: string, issueId: string): Promise<any> {
return this.get(ISSUE_DETAIL(workspace_slug, projectId, issueId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueActivities(
workspace_slug: string,
projectId: string,
issueId: string
): Promise<any> {
return this.get(ISSUE_ACTIVITIES(workspace_slug, projectId, issueId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueComments(workspace_slug: string, projectId: string, issueId: string): Promise<any> {
return this.get(ISSUE_COMMENTS(workspace_slug, projectId, issueId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueProperties(workspace_slug: string, projectId: string): Promise<any> {
return this.get(ISSUE_PROPERTIES_ENDPOINT(workspace_slug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async addIssueToSprint(
workspace_slug: string,
projectId: string,
cycleId: string,
data: {
issue: string;
}
) {
console.log(data);
return this.post(CYCLE_DETAIL(workspace_slug, projectId, cycleId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async createIssueProperties(workspace_slug: string, projectId: string, data: any): Promise<any> {
return this.post(ISSUE_PROPERTIES_ENDPOINT(workspace_slug, projectId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async patchIssueProperties(
workspace_slug: string,
projectId: string,
issuePropertyId: string,
data: any
): Promise<any> {
return this.patch(
ISSUE_PROPERTIES_ENDPOINT(workspace_slug, projectId) + `${issuePropertyId}/`,
data
)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async createIssueComment(
workspace_slug: string,
projectId: string,
issueId: string,
data: any
): Promise<any> {
return this.post(ISSUE_COMMENTS(workspace_slug, projectId, issueId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async patchIssueComment(
workspace_slug: string,
projectId: string,
issueId: string,
commentId: string,
data: IIssueComment
): Promise<any> {
return this.patch(ISSUE_COMMENT_DETAIL(workspace_slug, projectId, issueId, commentId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteIssueComment(
workspace_slug: string,
projectId: string,
issueId: string,
commentId: string
): Promise<any> {
return this.delete(ISSUE_COMMENT_DETAIL(workspace_slug, projectId, issueId, commentId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueLabels(workspace_slug: string, projectId: string): Promise<any> {
return this.get(ISSUE_LABELS(workspace_slug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async createIssueLabel(workspace_slug: string, projectId: string, data: any): Promise<any> {
return this.post(ISSUE_LABELS(workspace_slug, projectId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateIssue(
workspace_slug: string,
projectId: string,
issueId: string,
data: any
): Promise<any> {
return this.put(ISSUE_DETAIL(workspace_slug, projectId, issueId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async patchIssue(
workspace_slug: string,
projectId: string,
issueId: string,
data: Partial<IIssue>
): Promise<any> {
return this.patch(ISSUE_DETAIL(workspace_slug, projectId, issueId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteIssue(workspace_slug: string, projectId: string, issuesId: string): Promise<any> {
return this.delete(ISSUE_DETAIL(workspace_slug, projectId, issuesId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new ProjectIssuesServices();

View File

@ -0,0 +1,192 @@
// api routes
import {
CHECK_PROJECT_IDENTIFIER,
INVITE_PROJECT,
JOIN_PROJECT,
PROJECTS_ENDPOINT,
PROJECT_DETAIL,
PROJECT_INVITATIONS,
PROJECT_INVITATION_DETAIL,
PROJECT_MEMBERS,
PROJECT_MEMBER_DETAIL,
USER_PROJECT_INVITATIONS,
} from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class ProjectServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async createProject(workspace_slug: string, data: any): Promise<any> {
return this.post(PROJECTS_ENDPOINT(workspace_slug), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async checkProjectIdentifierAvailability(workspaceSlug: string, data: string): Promise<any> {
return this.get(CHECK_PROJECT_IDENTIFIER(workspaceSlug), {
params: {
name: data,
},
})
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getProjects(workspace_slug: string): Promise<any> {
return this.get(PROJECTS_ENDPOINT(workspace_slug))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getProject(workspace_slug: string, project_id: string): Promise<any> {
return this.get(PROJECT_DETAIL(workspace_slug, project_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateProject(workspace_slug: string, project_id: string, data: any): Promise<any> {
return this.patch(PROJECT_DETAIL(workspace_slug, project_id), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteProject(workspace_slug: string, project_id: string): Promise<any> {
return this.delete(PROJECT_DETAIL(workspace_slug, project_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async inviteProject(workspace_slug: string, project_id: string, data: any): Promise<any> {
return this.post(INVITE_PROJECT(workspace_slug, project_id), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async joinProject(workspace_slug: string, data: any): Promise<any> {
return this.post(JOIN_PROJECT(workspace_slug), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async joinProjects(data: any): Promise<any> {
return this.post(USER_PROJECT_INVITATIONS, data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async projectMembers(workspace_slug: string, project_id: string): Promise<any> {
return this.get(PROJECT_MEMBERS(workspace_slug, project_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateProjectMember(
workspace_slug: string,
project_id: string,
memberId: string
): Promise<any> {
return this.put(PROJECT_MEMBER_DETAIL(workspace_slug, project_id, memberId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteProjectMember(
workspace_slug: string,
project_id: string,
memberId: string
): Promise<any> {
return this.delete(PROJECT_MEMBER_DETAIL(workspace_slug, project_id, memberId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async projectInvitations(workspace_slug: string, project_id: string): Promise<any> {
return this.get(PROJECT_INVITATIONS(workspace_slug, project_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateProjectInvitation(
workspace_slug: string,
project_id: string,
invitation_id: string
): Promise<any> {
return this.put(PROJECT_INVITATION_DETAIL(workspace_slug, project_id, invitation_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteProjectInvitation(
workspace_slug: string,
project_id: string,
invitation_id: string
): Promise<any> {
return this.delete(PROJECT_INVITATION_DETAIL(workspace_slug, project_id, invitation_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new ProjectServices();

View File

@ -0,0 +1,98 @@
// api routes
import { STATES_ENDPOINT, STATE_DETAIL, ISSUES_BY_STATE } from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
// types
import type { IState } from "types";
class ProjectStateServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async createState(workspace_slug: string, projectId: string, data: any): Promise<any> {
return this.post(STATES_ENDPOINT(workspace_slug, projectId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getStates(workspace_slug: string, projectId: string): Promise<any> {
return this.get(STATES_ENDPOINT(workspace_slug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getIssuesByState(workspace_slug: string, projectId: string): Promise<any> {
return this.get(ISSUES_BY_STATE(workspace_slug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getState(workspace_slug: string, projectId: string, stateId: string): Promise<any> {
return this.get(STATE_DETAIL(workspace_slug, projectId, stateId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateState(
workspace_slug: string,
projectId: string,
stateId: string,
data: IState
): Promise<any> {
return this.put(STATE_DETAIL(workspace_slug, projectId, stateId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async patchState(
workspace_slug: string,
projectId: string,
stateId: string,
data: Partial<IState>
): Promise<any> {
return this.patch(STATE_DETAIL(workspace_slug, projectId, stateId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteState(workspace_slug: string, projectId: string, stateId: string): Promise<any> {
return this.delete(STATE_DETAIL(workspace_slug, projectId, stateId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new ProjectStateServices();

View File

@ -0,0 +1,60 @@
// services
import { USER_ENDPOINT, USER_ISSUES_ENDPOINT, USER_ONBOARD_ENDPOINT } from "constants/api-routes";
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class UserService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
currentUserConfig() {
return {
url: `${this.baseURL}/api/users/me/`,
headers: this.getHeaders(),
};
}
async userIssues(): Promise<any> {
return this.get(USER_ISSUES_ENDPOINT)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async currentUser(): Promise<any> {
return this.get(USER_ENDPOINT)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateUser(data = {}): Promise<any> {
return this.patch(USER_ENDPOINT, data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateUserOnBoard(): Promise<any> {
return this.patch(USER_ONBOARD_ENDPOINT, { is_onboarded: true })
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new UserService();

View File

@ -0,0 +1,173 @@
// api routes
import {
USER_WORKSPACES,
WORKSPACES_ENDPOINT,
INVITE_WORKSPACE,
WORKSPACE_DETAIL,
JOIN_WORKSPACE,
WORKSPACE_MEMBERS,
WORKSPACE_MEMBER_DETAIL,
WORKSPACE_INVITATIONS,
WORKSPACE_INVITATION_DETAIL,
USER_WORKSPACE_INVITATION,
USER_WORKSPACE_INVITATIONS,
} from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class WorkspaceService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async userWorkspaces(): Promise<any> {
return this.get(USER_WORKSPACES)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async createWorkspace(data: any): Promise<any> {
return this.post(WORKSPACES_ENDPOINT, data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateWorkspace(workspace_slug: string, data: any): Promise<any> {
return this.patch(WORKSPACE_DETAIL(workspace_slug), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteWorkspace(workspace_slug: string): Promise<any> {
return this.delete(WORKSPACE_DETAIL(workspace_slug))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async inviteWorkspace(workspace_slug: string, data: any): Promise<any> {
return this.post(INVITE_WORKSPACE(workspace_slug), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async joinWorkspace(workspace_slug: string, InvitationId: string, data: any): Promise<any> {
return this.post(JOIN_WORKSPACE(workspace_slug, InvitationId), data, {
headers: {},
})
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async joinWorkspaces(data: any): Promise<any> {
return this.post(USER_WORKSPACE_INVITATIONS, data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async userWorkspaceInvitations(): Promise<any> {
return this.get(USER_WORKSPACE_INVITATIONS)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async workspaceMembers(workspace_slug: string): Promise<any> {
return this.get(WORKSPACE_MEMBERS(workspace_slug))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateWorkspaceMember(workspace_slug: string, memberId: string): Promise<any> {
return this.put(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteWorkspaceMember(workspace_slug: string, memberId: string): Promise<any> {
return this.delete(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async workspaceInvitations(workspace_slug: string): Promise<any> {
return this.get(WORKSPACE_INVITATIONS(workspace_slug))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getWorkspaceInvitation(invitation_id: string): Promise<any> {
return this.get(USER_WORKSPACE_INVITATION(invitation_id), { headers: {} })
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateWorkspaceInvitation(workspace_slug: string, invitation_id: string): Promise<any> {
return this.put(WORKSPACE_INVITATION_DETAIL(workspace_slug, invitation_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteWorkspaceInvitations(workspace_slug: string, invitation_id: string): Promise<any> {
return this.delete(WORKSPACE_INVITATION_DETAIL(workspace_slug, invitation_id))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new WorkspaceService();

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

10
next.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
swcMinify: true,
images: {
domains: ["vinci-web.s3.amazonaws.com"],
},
};
module.exports = nextConfig;

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "trello-clone",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.12",
"axios": "^1.1.3",
"js-cookie": "^3.0.1",
"next": "12.2.2",
"prosemirror-example-setup": "^1.2.1",
"prosemirror-model": "^1.18.1",
"prosemirror-schema-basic": "^1.2.0",
"prosemirror-schema-list": "^1.2.2",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.29.0",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-color": "^2.19.3",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.38.0",
"swr": "^1.3.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/js-cookie": "^3.0.2",
"@types/lodash": "^4.14.188",
"@types/node": "18.0.6",
"@types/react": "18.0.15",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-color": "^3.0.6",
"@types/react-dom": "18.0.6",
"@types/uuid": "^8.3.4",
"autoprefixer": "^10.4.7",
"eslint": "8.20.0",
"eslint-config-next": "12.2.2",
"postcss": "^8.4.14",
"tailwindcss": "^3.1.6",
"typescript": "4.7.4"
}
}

20
pages/_app.tsx Normal file
View File

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

13
pages/api/hello.ts Normal file
View File

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

147
pages/create-workspace.tsx Normal file
View File

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

41
pages/editor.tsx Normal file
View File

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

42
pages/index.tsx Normal file
View 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
View 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
View 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
View 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;

View File

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

View File

@ -0,0 +1,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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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;

View 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
View 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
View 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);

View 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;

Some files were not shown because too many files have changed in this diff Show More