forked from github/plane
dev: hello world
This commit is contained in:
commit
6037fed3f4
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal 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
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
201
LICENSE.txt
Normal file
201
LICENSE.txt
Normal 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
42
README.md
Normal 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.
|
282
components/command-palette/index.tsx
Normal file
282
components/command-palette/index.tsx
Normal 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;
|
108
components/command-palette/shortcuts.tsx
Normal file
108
components/command-palette/shortcuts.tsx
Normal 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;
|
25
components/dnd/StrictModeDroppable.tsx
Normal file
25
components/dnd/StrictModeDroppable.tsx
Normal 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;
|
131
components/forms/EmailCodeForm.tsx
Normal file
131
components/forms/EmailCodeForm.tsx
Normal 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;
|
111
components/forms/EmailPasswordForm.tsx
Normal file
111
components/forms/EmailPasswordForm.tsx
Normal 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;
|
144
components/project/ConfirmProjectDeletion.tsx
Normal file
144
components/project/ConfirmProjectDeletion.tsx
Normal 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;
|
233
components/project/CreateProjectModal.tsx
Normal file
233
components/project/CreateProjectModal.tsx
Normal 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;
|
285
components/project/SendProjectInvitationModal.tsx
Normal file
285
components/project/SendProjectInvitationModal.tsx
Normal 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;
|
144
components/project/cycles/ConfirmCycleDeletion.tsx
Normal file
144
components/project/cycles/ConfirmCycleDeletion.tsx
Normal 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;
|
238
components/project/cycles/CreateUpdateCyclesModal.tsx
Normal file
238
components/project/cycles/CreateUpdateCyclesModal.tsx
Normal 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;
|
258
components/project/cycles/CycleView.tsx
Normal file
258
components/project/cycles/CycleView.tsx
Normal 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;
|
355
components/project/issues/BoardView/SingleBoard.tsx
Normal file
355
components/project/issues/BoardView/SingleBoard.tsx
Normal 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;
|
218
components/project/issues/BoardView/index.tsx
Normal file
218
components/project/issues/BoardView/index.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
150
components/project/issues/ConfirmIssueDeletion.tsx
Normal file
150
components/project/issues/ConfirmIssueDeletion.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
124
components/project/issues/CreateUpdateIssueModal/SelectState.tsx
Normal file
124
components/project/issues/CreateUpdateIssueModal/SelectState.tsx
Normal 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;
|
370
components/project/issues/CreateUpdateIssueModal/index.tsx
Normal file
370
components/project/issues/CreateUpdateIssueModal/index.tsx
Normal 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;
|
411
components/project/issues/ListView/index.tsx
Normal file
411
components/project/issues/ListView/index.tsx
Normal 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;
|
177
components/project/issues/comment/IssueCommentCard.tsx
Normal file
177
components/project/issues/comment/IssueCommentCard.tsx
Normal 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;
|
130
components/project/issues/comment/IssueCommentSection.tsx
Normal file
130
components/project/issues/comment/IssueCommentSection.tsx
Normal 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;
|
334
components/project/issues/issue-detail/IssueDetailSidebar.tsx
Normal file
334
components/project/issues/issue-detail/IssueDetailSidebar.tsx
Normal 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;
|
110
components/project/issues/my-issues/ChangeStateDropdown.tsx
Normal file
110
components/project/issues/my-issues/ChangeStateDropdown.tsx
Normal 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;
|
135
components/project/memberInvitations.tsx
Normal file
135
components/project/memberInvitations.tsx
Normal 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;
|
40
components/socialbuttons/google-login.tsx
Normal file
40
components/socialbuttons/google-login.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
67
components/toast-alert/index.tsx
Normal file
67
components/toast-alert/index.tsx
Normal 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;
|
143
components/workspace/ConfirmWorkspaceDeletion.tsx
Normal file
143
components/workspace/ConfirmWorkspaceDeletion.tsx
Normal 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;
|
189
components/workspace/SendWorkspaceInvitationModal.tsx
Normal file
189
components/workspace/SendWorkspaceInvitationModal.tsx
Normal 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;
|
32
configuration/axios-configuration.ts
Normal file
32
configuration/axios-configuration.ts
Normal 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
117
constants/api-routes.ts
Normal 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
109
constants/common.ts
Normal 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
32
constants/fetch-keys.ts
Normal 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";
|
8
constants/seo/seo-variables.ts
Normal file
8
constants/seo/seo-variables.ts
Normal 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";
|
4
constants/theme.context.constants.ts
Normal file
4
constants/theme.context.constants.ts
Normal 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";
|
2
constants/toast.context.constants.ts
Normal file
2
constants/toast.context.constants.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const SET_TOAST_ALERT = "SET_TOAST_ALERT";
|
||||||
|
export const REMOVE_TOAST_ALERT = "REMOVE_TOAST_ALERT";
|
15
contexts/globalContextProvider.tsx
Normal file
15
contexts/globalContextProvider.tsx
Normal 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
144
contexts/theme.context.tsx
Normal 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
101
contexts/toast.context.tsx
Normal 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
154
contexts/user.context.tsx
Normal 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
91
google.d.ts
vendored
Normal 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
19
layouts/AdminLayout.tsx
Normal 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
71
layouts/Container.tsx
Normal 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
21
layouts/DefaultLayout.tsx
Normal 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;
|
35
layouts/Navbar/DefaultTopBar.tsx
Normal file
35
layouts/Navbar/DefaultTopBar.tsx
Normal 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
501
layouts/Navbar/Sidebar.tsx
Normal 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
27
layouts/ProjectLayout.tsx
Normal 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
11
layouts/types.d.ts
vendored
Normal 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
3
lib/cookie.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const setCookie = () => {
|
||||||
|
// add set cookie logic here
|
||||||
|
};
|
30
lib/hoc/withAuthWrapper.tsx
Normal file
30
lib/hoc/withAuthWrapper.tsx
Normal 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;
|
21
lib/hooks/useAutosizeTextArea.tsx
Normal file
21
lib/hooks/useAutosizeTextArea.tsx
Normal 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;
|
86
lib/hooks/useIssuesProperties.tsx
Normal file
86
lib/hooks/useIssuesProperties.tsx
Normal 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;
|
38
lib/hooks/useLocalStorage.tsx
Normal file
38
lib/hooks/useLocalStorage.tsx
Normal 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
11
lib/hooks/useTheme.tsx
Normal 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
11
lib/hooks/useToast.tsx
Normal 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
42
lib/hooks/useUser.tsx
Normal 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
16
lib/redirect.ts
Normal 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
107
lib/services/api.service.ts
Normal 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;
|
71
lib/services/authentication.service.ts
Normal file
71
lib/services/authentication.service.ts
Normal 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();
|
84
lib/services/cycles.services.ts
Normal file
84
lib/services/cycles.services.ts
Normal 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();
|
24
lib/services/file.services.ts
Normal file
24
lib/services/file.services.ts
Normal 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();
|
242
lib/services/issues.services.ts
Normal file
242
lib/services/issues.services.ts
Normal 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();
|
192
lib/services/project.service.ts
Normal file
192
lib/services/project.service.ts
Normal 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();
|
98
lib/services/state.services.ts
Normal file
98
lib/services/state.services.ts
Normal 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();
|
60
lib/services/user.service.ts
Normal file
60
lib/services/user.service.ts
Normal 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();
|
173
lib/services/workspace.service.ts
Normal file
173
lib/services/workspace.service.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
10
next.config.js
Normal 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
48
package.json
Normal 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
20
pages/_app.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import "../styles/globals.css";
|
||||||
|
import "styles/editor.css";
|
||||||
|
import type { AppProps } from "next/app";
|
||||||
|
|
||||||
|
import GlobalContextProvider from "contexts/globalContextProvider";
|
||||||
|
|
||||||
|
import CommandPalette from "components/command-palette";
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }: AppProps) {
|
||||||
|
return (
|
||||||
|
<GlobalContextProvider>
|
||||||
|
<>
|
||||||
|
<CommandPalette />
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</>
|
||||||
|
</GlobalContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp;
|
13
pages/api/hello.ts
Normal file
13
pages/api/hello.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<Data>
|
||||||
|
) {
|
||||||
|
res.status(200).json({ name: 'John Doe' })
|
||||||
|
}
|
147
pages/create-workspace.tsx
Normal file
147
pages/create-workspace.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import React from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// react hook form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// services
|
||||||
|
import workspaceService from "lib/services/workspace.service";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// layouts
|
||||||
|
import DefaultLayout from "layouts/DefaultLayout";
|
||||||
|
// ui
|
||||||
|
import { Input, Button, Select } from "ui";
|
||||||
|
// types
|
||||||
|
import type { IWorkspace } from "types";
|
||||||
|
|
||||||
|
const CreateWorkspace: NextPage = () => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<IWorkspace>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutateWorkspaces, user } = useUser();
|
||||||
|
|
||||||
|
const onSubmit = async (formData: IWorkspace) => {
|
||||||
|
await workspaceService
|
||||||
|
.createWorkspace(formData)
|
||||||
|
.then((res) => {
|
||||||
|
console.log(res);
|
||||||
|
mutateWorkspaces((prevData) => [...(prevData ?? []), res], false);
|
||||||
|
router.push("/");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
Object.keys(err).map((key) => {
|
||||||
|
const errorMessage = err[key];
|
||||||
|
setError(key as keyof IWorkspace, {
|
||||||
|
message: Array.isArray(errorMessage) ? errorMessage.join(", ") : errorMessage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// const workspaceName = watch("name") ?? "";
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// workspaceName && workspaceName !== ""
|
||||||
|
// ? setValue(
|
||||||
|
// "url",
|
||||||
|
// `${window.location.origin}/${workspaceName
|
||||||
|
// .toLowerCase()
|
||||||
|
// .replace(/ /g, "")}`
|
||||||
|
// )
|
||||||
|
// : setValue("url", workspaceName);
|
||||||
|
// }, [workspaceName, setValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout>
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full px-4">
|
||||||
|
{user && (
|
||||||
|
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-indigo-600 mb-10 lg:mb-20">
|
||||||
|
<p className="text-sm text-center">logged in as {user.email}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rounded border p-4 px-6 w-full md:w-2/3 lg:w-1/3 space-y-4 flex flex-col justify-between bg-white">
|
||||||
|
<h2 className="text-2xl text-center font-medium mb-4">Create a new workspace</h2>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label="Workspace Name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="off"
|
||||||
|
register={register}
|
||||||
|
validations={{
|
||||||
|
required: "Name is required",
|
||||||
|
}}
|
||||||
|
error={errors.name}
|
||||||
|
placeholder="Enter workspace name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
id="url"
|
||||||
|
label="Workspace URL"
|
||||||
|
name="url"
|
||||||
|
autoComplete="off"
|
||||||
|
validations={{
|
||||||
|
required: "URL is required",
|
||||||
|
}}
|
||||||
|
placeholder="Enter workspace URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
id="size"
|
||||||
|
name="company_size"
|
||||||
|
label="How large is your company?"
|
||||||
|
register={register}
|
||||||
|
options={[
|
||||||
|
{ value: 5, label: "5" },
|
||||||
|
{ value: 10, label: "10" },
|
||||||
|
{ value: 25, label: "25" },
|
||||||
|
{ value: 50, label: "50" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
id="projects"
|
||||||
|
label="What is your role?"
|
||||||
|
name="projects"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Head of Engineering"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* <div>
|
||||||
|
<TextArea
|
||||||
|
id="description"
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
register={register}
|
||||||
|
error={errors.description}
|
||||||
|
placeholder="Enter workspace description"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating Workspace..." : "Create Workspace"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateWorkspace;
|
41
pages/editor.tsx
Normal file
41
pages/editor.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
// prose mirror
|
||||||
|
import { EditorState } from "prosemirror-state";
|
||||||
|
import { EditorView } from "prosemirror-view";
|
||||||
|
import { Schema, DOMParser } from "prosemirror-model";
|
||||||
|
import { schema } from "prosemirror-schema-basic";
|
||||||
|
import { addListNodes } from "prosemirror-schema-list";
|
||||||
|
import { exampleSetup } from "prosemirror-example-setup";
|
||||||
|
|
||||||
|
const Editor: NextPage = () => {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current || !contentRef.current) return;
|
||||||
|
|
||||||
|
const mySchema = new Schema({
|
||||||
|
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
|
||||||
|
marks: schema.spec.marks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const myEditorView = new EditorView(editorRef.current, {
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: DOMParser.fromSchema(mySchema)?.parse(contentRef.current),
|
||||||
|
plugins: exampleSetup({ schema: mySchema }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => myEditorView.destroy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="editor" ref={editorRef}>
|
||||||
|
<div id="content" ref={contentRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Editor;
|
42
pages/index.tsx
Normal file
42
pages/index.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// fetch keys
|
||||||
|
import { USER_ISSUE } from "constants/fetch-keys";
|
||||||
|
// services
|
||||||
|
import userService from "lib/services/user.service";
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "ui";
|
||||||
|
// icons
|
||||||
|
import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
|
// types
|
||||||
|
import type { IIssue } from "types";
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
|
||||||
|
const Home: NextPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { user, isUserLoading, activeWorkspace, projects, workspaces } = useUser();
|
||||||
|
|
||||||
|
const { data: myIssues } = useSWR<IIssue[]>(
|
||||||
|
user ? USER_ISSUE : null,
|
||||||
|
user ? () => userService.userIssues() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isUserLoading && !user) router.push("/signin");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeWorkspace && workspaces?.length === 0) router.push("/invitations");
|
||||||
|
else if (activeWorkspace) router.push(`/workspace/`);
|
||||||
|
}, [activeWorkspace, router, workspaces]);
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
202
pages/invitations.tsx
Normal file
202
pages/invitations.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// headless ui
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import workspaceService from "lib/services/workspace.service";
|
||||||
|
import userService from "lib/services/user.service";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// constants
|
||||||
|
import { USER_WORKSPACE_INVITATIONS } from "constants/api-routes";
|
||||||
|
// hoc
|
||||||
|
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||||
|
// layouts
|
||||||
|
import DefaultLayout from "layouts/DefaultLayout";
|
||||||
|
// ui
|
||||||
|
import { Button, Spinner } from "ui";
|
||||||
|
// types
|
||||||
|
import type { IWorkspaceInvitation } from "types";
|
||||||
|
import { ChartBarIcon, ChevronRightIcon, CubeIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||||
|
|
||||||
|
const OnBoard: NextPage = () => {
|
||||||
|
const [canRedirect, setCanRedirect] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { workspaces, mutateWorkspaces, user } = useUser();
|
||||||
|
|
||||||
|
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data: invitations, mutate } = useSWR<IWorkspaceInvitation[]>(
|
||||||
|
USER_WORKSPACE_INVITATIONS,
|
||||||
|
() => workspaceService.userWorkspaceInvitations()
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInvitation = (
|
||||||
|
workspace_invitation: IWorkspaceInvitation,
|
||||||
|
action: "accepted" | "withdraw"
|
||||||
|
) => {
|
||||||
|
if (action === "accepted") {
|
||||||
|
setInvitationsRespond((prevData) => {
|
||||||
|
return [...prevData, workspace_invitation.id];
|
||||||
|
});
|
||||||
|
} else if (action === "withdraw") {
|
||||||
|
setInvitationsRespond((prevData) => {
|
||||||
|
return prevData.filter((item: string) => item !== workspace_invitation.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitInvitations = () => {
|
||||||
|
userService.updateUserOnBoard().then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
});
|
||||||
|
workspaceService
|
||||||
|
.joinWorkspaces({ invitations: invitationsRespond })
|
||||||
|
.then(async (res: any) => {
|
||||||
|
console.log(res);
|
||||||
|
await mutate();
|
||||||
|
await mutateWorkspaces();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!invitations) return;
|
||||||
|
// else
|
||||||
|
// invitations.forEach((invite) => {
|
||||||
|
// if (invite.accepted)
|
||||||
|
// setInvitationsRespond((prevData) => {
|
||||||
|
// return [...prevData, invite.workspace.id];
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }, [invitations, router, workspaces]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (workspaces && workspaces.length === 0) {
|
||||||
|
setCanRedirect(false);
|
||||||
|
}
|
||||||
|
}, [workspaces]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout
|
||||||
|
meta={{
|
||||||
|
title: "Plane - Welcome to Plane",
|
||||||
|
description:
|
||||||
|
"Please fasten your seatbelts because we are about to take your productivity to the next level.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-full flex-col items-center justify-center p-4 sm:p-0">
|
||||||
|
{user && (
|
||||||
|
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-indigo-600 mb-10">
|
||||||
|
<p className="text-sm text-center">logged in as {user.email}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full md:w-2/3 lg:w-1/3 p-8 rounded-lg">
|
||||||
|
{invitations ? (
|
||||||
|
invitations.length > 0 ? (
|
||||||
|
<div className="mt-3 sm:mt-5">
|
||||||
|
<div className="mt-2">
|
||||||
|
<h2 className="text-2xl font-medium mb-4">Join your workspaces</h2>
|
||||||
|
<div className="space-y-2 mb-12">
|
||||||
|
{invitations.map((item) => (
|
||||||
|
<div
|
||||||
|
className="relative flex items-center border px-4 py-2 rounded"
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<div className="ml-3 text-sm flex flex-col items-start w-full">
|
||||||
|
<h3 className="font-medium text-xl text-gray-700">
|
||||||
|
{item.workspace.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm">invited by {item.workspace.owner.first_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-x-2 h-5 items-center">
|
||||||
|
<div className="h-full flex items-center gap-x-1">
|
||||||
|
<input
|
||||||
|
id={`${item.id}`}
|
||||||
|
aria-describedby="workspaces"
|
||||||
|
name={`${item.id}`}
|
||||||
|
checked={invitationsRespond.includes(item.id)}
|
||||||
|
value={item.workspace.name}
|
||||||
|
onChange={() => {
|
||||||
|
handleInvitation(
|
||||||
|
item,
|
||||||
|
invitationsRespond.includes(item.id) ? "withdraw" : "accepted"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor={item.id} className="text-sm">
|
||||||
|
Accept
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/* <div className="h-full flex items-center gap-x-1">
|
||||||
|
<input
|
||||||
|
id={`${item.id}`}
|
||||||
|
aria-describedby="workspaces"
|
||||||
|
name={`${item.id}`}
|
||||||
|
checked={invitationsRespond.includes(item.workspace.id)}
|
||||||
|
value={item.workspace.name}
|
||||||
|
onChange={() => {
|
||||||
|
handleInvitation(item, invitationsRespond.includes(item.workspace.id) ? "withdraw" : "accepted");
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor={item.id} className="text-sm">
|
||||||
|
Reject
|
||||||
|
</label>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-4">
|
||||||
|
<Button className="w-full" onClick={submitInvitations}>
|
||||||
|
Continue to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptySpace
|
||||||
|
title={`${
|
||||||
|
canRedirect
|
||||||
|
? `You haven't been invited to any workspace yet.`
|
||||||
|
: "You don't have any workspaces, Start by creating one."
|
||||||
|
}`}
|
||||||
|
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
|
||||||
|
>
|
||||||
|
<EmptySpaceItem
|
||||||
|
Icon={canRedirect ? CubeIcon : PlusIcon}
|
||||||
|
title={canRedirect ? "Continue to Dashboard" : "Create your Workspace"}
|
||||||
|
action={() => {
|
||||||
|
userService.updateUserOnBoard().then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
});
|
||||||
|
router.push(canRedirect ? "/" : "/create-workspace");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EmptySpace>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withAuthWrapper(OnBoard);
|
169
pages/me/my-issues.tsx
Normal file
169
pages/me/my-issues.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
// react
|
||||||
|
import React, { useState } from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// components
|
||||||
|
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "ui";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||||
|
import HeaderButton from "ui/HeaderButton";
|
||||||
|
// icons
|
||||||
|
import { PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||||
|
// services
|
||||||
|
import userService from "lib/services/user.service";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
// constants
|
||||||
|
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
|
||||||
|
import { USER_ISSUE } from "constants/fetch-keys";
|
||||||
|
import { classNames } from "constants/common";
|
||||||
|
|
||||||
|
const MyIssues: NextPage = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { data: myIssues } = useSWR<IIssue[]>(
|
||||||
|
user ? USER_ISSUE : null,
|
||||||
|
user ? () => userService.userIssues() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout>
|
||||||
|
<CreateUpdateIssuesModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5">
|
||||||
|
{myIssues ? (
|
||||||
|
<>
|
||||||
|
{myIssues.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title="My Issues" />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="flex items-center justify-between cursor-pointer w-full">
|
||||||
|
<h2 className="text-2xl font-medium">My Issues</h2>
|
||||||
|
<div className="flex items-center gap-x-3">
|
||||||
|
<HeaderButton
|
||||||
|
Icon={PlusIcon}
|
||||||
|
label="Add Issue"
|
||||||
|
action={() => {
|
||||||
|
const e = new KeyboardEvent("keydown", {
|
||||||
|
key: "i",
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col">
|
||||||
|
<div className="overflow-x-auto ">
|
||||||
|
<div className="inline-block min-w-full align-middle px-0.5 py-2">
|
||||||
|
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead className="bg-gray-100">
|
||||||
|
<tr className="text-left">
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
NAME
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
DESCRIPTION
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
PROJECT
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
PRIORITY
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
STATUS
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white">
|
||||||
|
{myIssues.map((myIssue, index) => (
|
||||||
|
<tr
|
||||||
|
key={myIssue.id}
|
||||||
|
className={classNames(
|
||||||
|
index === 0 ? "border-gray-300" : "border-gray-200",
|
||||||
|
"border-t text-sm text-gray-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-4 text-sm font-medium text-gray-900 max-w-[15rem]">
|
||||||
|
{myIssue.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4 max-w-[15rem]">{myIssue.description}</td>
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
{myIssue.project_detail.name}
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">{`(${myIssue.project_detail.identifier}-${myIssue.sequence_id})`}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4 capitalize">{myIssue.priority}</td>
|
||||||
|
<td className="relative px-3 py-4">
|
||||||
|
<ChangeStateDropdown issue={myIssue} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center px-4">
|
||||||
|
<EmptySpace
|
||||||
|
title="You don't have any issue assigned to you yet."
|
||||||
|
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||||
|
Icon={RectangleStackIcon}
|
||||||
|
>
|
||||||
|
<EmptySpaceItem
|
||||||
|
title="Create a new issue"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Use{" "}
|
||||||
|
<pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + I</pre>{" "}
|
||||||
|
shortcut to create a new issue
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
Icon={PlusIcon}
|
||||||
|
action={() => setIsOpen(true)}
|
||||||
|
/>
|
||||||
|
</EmptySpace>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyIssues;
|
226
pages/me/profile.tsx
Normal file
226
pages/me/profile.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
// next
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
// react hook form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// react dropzone
|
||||||
|
import Dropzone from "react-dropzone";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// services
|
||||||
|
import userService from "lib/services/user.service";
|
||||||
|
import fileServices from "lib/services/file.services";
|
||||||
|
// ui
|
||||||
|
import { Button, Input, Spinner } from "ui";
|
||||||
|
// types
|
||||||
|
import type { IUser } from "types";
|
||||||
|
import { UserIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
const defaultValues: Partial<IUser> = {
|
||||||
|
avatar: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const Profile: NextPage = () => {
|
||||||
|
const [image, setImage] = useState<File | null>(null);
|
||||||
|
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||||
|
|
||||||
|
const { user: myProfile, mutateUser } = useUser();
|
||||||
|
|
||||||
|
const onSubmit = (formData: IUser) => {
|
||||||
|
userService
|
||||||
|
.updateUser(formData)
|
||||||
|
.then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
mutateUser(response, false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<IUser>({ defaultValues });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset({ ...defaultValues, ...myProfile });
|
||||||
|
}, [myProfile, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout
|
||||||
|
meta={{
|
||||||
|
title: "Plane - My Profile",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full h-full md:px-20 p-8 flex flex-wrap overflow-auto gap-y-10 justify-center items-center">
|
||||||
|
{myProfile ? (
|
||||||
|
<>
|
||||||
|
<div className="w-2/5">
|
||||||
|
<Dropzone
|
||||||
|
multiple={false}
|
||||||
|
accept={{
|
||||||
|
"image/*": [],
|
||||||
|
}}
|
||||||
|
onDrop={(files) => {
|
||||||
|
setImage(files[0]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ getRootProps, getInputProps }) => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<h2 className="font-semibold text-xl">Profile Picture</h2>
|
||||||
|
<div className="relative">
|
||||||
|
<span
|
||||||
|
className="inline-block h-24 w-24 rounded-full overflow-hidden bg-gray-100"
|
||||||
|
{...getRootProps()}
|
||||||
|
>
|
||||||
|
{(!watch("avatar") || watch("avatar") === "") &&
|
||||||
|
(!image || image === null) ? (
|
||||||
|
<UserIcon className="h-full w-full text-gray-300" />
|
||||||
|
) : (
|
||||||
|
<div className="relative h-24 w-24 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={image ? URL.createObjectURL(image) : watch("avatar")}
|
||||||
|
alt={myProfile.first_name}
|
||||||
|
layout="fill"
|
||||||
|
objectFit="cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
Max file size is 500kb. Supported file types are .jpg and .png.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => {
|
||||||
|
if (image === null) return;
|
||||||
|
setIsImageUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("asset", image);
|
||||||
|
formData.append("attributes", JSON.stringify({}));
|
||||||
|
fileServices
|
||||||
|
.uploadFile(formData)
|
||||||
|
.then((response) => {
|
||||||
|
const imageUrl = response.asset;
|
||||||
|
setValue("avatar", imageUrl);
|
||||||
|
handleSubmit(onSubmit)();
|
||||||
|
setIsImageUploading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setIsImageUploading(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImageUploading ? "Uploading..." : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 w-3/5">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-semibold text-xl">Details</h2>
|
||||||
|
<div className="flex gap-x-4">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Input
|
||||||
|
name="first_name"
|
||||||
|
id="first_name"
|
||||||
|
register={register}
|
||||||
|
error={errors.first_name}
|
||||||
|
label="First Name"
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
autoComplete="off"
|
||||||
|
validations={{
|
||||||
|
required: "This field is required.",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Input
|
||||||
|
name="last_name"
|
||||||
|
register={register}
|
||||||
|
error={errors.last_name}
|
||||||
|
id="last_name"
|
||||||
|
label="Last Name"
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
register={register}
|
||||||
|
error={errors.email}
|
||||||
|
name="email"
|
||||||
|
validations={{
|
||||||
|
required: "Email is required",
|
||||||
|
}}
|
||||||
|
label="Email"
|
||||||
|
placeholder="Enter email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button disabled={isSubmitting} type="submit">
|
||||||
|
{isSubmitting ? "Updating Profile..." : "Update Profile"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div>
|
||||||
|
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Submitting..." : "Update"}
|
||||||
|
</Button>
|
||||||
|
{myProfile.is_email_verified || (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-2 text-indigo-600"
|
||||||
|
onClick={() => {
|
||||||
|
requestEmailVerification()
|
||||||
|
.then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Verification email sent.",
|
||||||
|
message: "Please check your email.",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verify Your Email
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full mx-auto h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
154
pages/me/workspace-invites.tsx
Normal file
154
pages/me/workspace-invites.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
// next
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// services
|
||||||
|
import workspaceService from "lib/services/workspace.service";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// hoc
|
||||||
|
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||||
|
// layouts
|
||||||
|
import AdminLayout from "layouts/AdminLayout";
|
||||||
|
// ui
|
||||||
|
import { Button } from "ui";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
const MyWorkspacesInvites: NextPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [invitationsRespond, setInvitationsRespond] = useState<any>([]);
|
||||||
|
const { workspaces } = useUser();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: workspaceInvitations,
|
||||||
|
isValidating,
|
||||||
|
mutate: mutateInvitations,
|
||||||
|
} = useSWR<any[]>("WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations());
|
||||||
|
|
||||||
|
const handleInvitation = (workspace_invitation: any, action: string) => {
|
||||||
|
if (action === "accepted") {
|
||||||
|
setInvitationsRespond((prevData: any) => {
|
||||||
|
return [...prevData, workspace_invitation.workspace.id];
|
||||||
|
});
|
||||||
|
} else if (action === "withdraw") {
|
||||||
|
setInvitationsRespond((prevData: any) => {
|
||||||
|
return prevData.filter((item: string) => item !== workspace_invitation.workspace.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitInvitations = () => {
|
||||||
|
workspaceService
|
||||||
|
.joinWorkspaces({ workspace_ids: invitationsRespond })
|
||||||
|
.then(async (res) => {
|
||||||
|
console.log(res);
|
||||||
|
await mutateInvitations();
|
||||||
|
|
||||||
|
router.push("/");
|
||||||
|
})
|
||||||
|
.catch((err: any) => console.log);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout
|
||||||
|
meta={{
|
||||||
|
title: "Plane - My Workspace Invites",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full">
|
||||||
|
<div className="relative rounded bg-gray-50 px-4 pt-5 pb-4 text-left shadow sm:w-full sm:max-w-2xl sm:p-6">
|
||||||
|
{(workspaceInvitations as any)?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="mt-3 sm:mt-5">
|
||||||
|
<div className="mt-2">
|
||||||
|
<h2 className="text-lg mb-4">Workspace Invitations</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{workspaceInvitations?.map((item: any) => (
|
||||||
|
<div className="relative flex items-start" key={item.id}>
|
||||||
|
<div className="flex h-5 items-center">
|
||||||
|
<input
|
||||||
|
id={`${item.id}`}
|
||||||
|
aria-describedby="workspaces"
|
||||||
|
name={`${item.id}`}
|
||||||
|
checked={
|
||||||
|
item.workspace.accepted ||
|
||||||
|
invitationsRespond.includes(item.workspace.id)
|
||||||
|
}
|
||||||
|
value={item.workspace.name}
|
||||||
|
onChange={() =>
|
||||||
|
handleInvitation(
|
||||||
|
item,
|
||||||
|
item.accepted
|
||||||
|
? "withdraw"
|
||||||
|
: invitationsRespond.includes(item.workspace.id)
|
||||||
|
? "withdraw"
|
||||||
|
: "accepted"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm flex justify-between w-full">
|
||||||
|
<label htmlFor={`${item.id}`} className="font-medium text-gray-700">
|
||||||
|
{item.workspace.name}
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
{invitationsRespond.includes(item.workspace.id) ? (
|
||||||
|
<div className="flex gap-x-2">
|
||||||
|
<p>Accepted</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInvitation(item, "withdraw")}
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInvitation(item, "accepted")}
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-4">
|
||||||
|
<Link href={workspaces?.length === 0 ? "/create-workspace" : "/"}>
|
||||||
|
<button type="button" className="text-sm text-gray-700">
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Button onClick={submitInvitations}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span>No Invitaions Found</span>
|
||||||
|
<p>
|
||||||
|
<Link href="/">
|
||||||
|
<a>Click Here </a>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span>to redirect home</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withAuthWrapper(MyWorkspacesInvites);
|
199
pages/projects/[projectId]/cycles.tsx
Normal file
199
pages/projects/[projectId]/cycles.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
// next
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
// swr
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
// services
|
||||||
|
import issuesServices from "lib/services/issues.services";
|
||||||
|
import sprintService from "lib/services/cycles.services";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// fetching keys
|
||||||
|
import { CYCLE_ISSUES, CYCLE_LIST } from "constants/fetch-keys";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// components
|
||||||
|
import SprintView from "components/project/cycles/CycleView";
|
||||||
|
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
|
||||||
|
import ConfirmSprintDeletion from "components/project/cycles/ConfirmCycleDeletion";
|
||||||
|
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||||
|
import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal";
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "ui";
|
||||||
|
// icons
|
||||||
|
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||||
|
// types
|
||||||
|
import { IIssue, ICycle, SelectSprintType, SelectIssue } from "types";
|
||||||
|
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||||
|
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||||
|
import HeaderButton from "ui/HeaderButton";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
|
||||||
|
const ProjectSprints: NextPage = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedSprint, setSelectedSprint] = useState<SelectSprintType>();
|
||||||
|
|
||||||
|
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
|
||||||
|
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
|
||||||
|
const [deleteIssue, setDeleteIssue] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { activeWorkspace, activeProject } = useUser();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { projectId } = router.query;
|
||||||
|
|
||||||
|
const { data: sprints } = useSWR<ICycle[]>(
|
||||||
|
projectId && activeWorkspace ? CYCLE_LIST(projectId as string) : null,
|
||||||
|
activeWorkspace && projectId
|
||||||
|
? () => sprintService.getCycles(activeWorkspace.slug, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const openIssueModal = (
|
||||||
|
sprintId: string,
|
||||||
|
issue?: IIssue,
|
||||||
|
actionType: "create" | "edit" | "delete" = "create"
|
||||||
|
) => {
|
||||||
|
const sprint = sprints?.find((sprint) => sprint.id === sprintId);
|
||||||
|
if (sprint) {
|
||||||
|
setSelectedSprint({
|
||||||
|
...sprint,
|
||||||
|
actionType: "create-issue",
|
||||||
|
});
|
||||||
|
if (issue) setSelectedIssues({ ...issue, actionType });
|
||||||
|
setIsIssueModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addIssueToSprint = (sprintId: string, issueId: string) => {
|
||||||
|
if (!activeWorkspace || !projectId) return;
|
||||||
|
|
||||||
|
issuesServices
|
||||||
|
.addIssueToSprint(activeWorkspace.slug, projectId as string, sprintId, {
|
||||||
|
issue: issueId,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
mutate(CYCLE_ISSUES(sprintId));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSelectedSprint(undefined);
|
||||||
|
clearTimeout(timer);
|
||||||
|
}, 500);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIssues?.actionType === "delete") {
|
||||||
|
setDeleteIssue(selectedIssues.id);
|
||||||
|
}
|
||||||
|
}, [selectedIssues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout
|
||||||
|
meta={{
|
||||||
|
title: "Plane - Cycles",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CreateUpdateSprintsModal
|
||||||
|
isOpen={
|
||||||
|
isOpen &&
|
||||||
|
selectedSprint?.actionType !== "delete" &&
|
||||||
|
selectedSprint?.actionType !== "create-issue"
|
||||||
|
}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
data={selectedSprint}
|
||||||
|
projectId={projectId as string}
|
||||||
|
/>
|
||||||
|
<ConfirmSprintDeletion
|
||||||
|
isOpen={isOpen && !!selectedSprint && selectedSprint.actionType === "delete"}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
data={selectedSprint}
|
||||||
|
/>
|
||||||
|
<ConfirmIssueDeletion
|
||||||
|
handleClose={() => setDeleteIssue(undefined)}
|
||||||
|
isOpen={!!deleteIssue}
|
||||||
|
data={selectedIssues}
|
||||||
|
/>
|
||||||
|
<CreateUpdateIssuesModal
|
||||||
|
isOpen={
|
||||||
|
isIssueModalOpen &&
|
||||||
|
selectedSprint?.actionType === "create-issue" &&
|
||||||
|
selectedIssues?.actionType !== "delete"
|
||||||
|
}
|
||||||
|
data={selectedIssues}
|
||||||
|
prePopulateData={{ sprints: selectedSprint?.id }}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
projectId={projectId as string}
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5">
|
||||||
|
{sprints ? (
|
||||||
|
sprints.length > 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title="Projects" link="/projects" />
|
||||||
|
<BreadcrumbItem title={`${activeProject?.name} Cycles`} />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="flex items-center justify-between cursor-pointer w-full">
|
||||||
|
<h2 className="text-2xl font-medium">Project Cycle</h2>
|
||||||
|
<HeaderButton Icon={PlusIcon} label="Add Cycle" action={() => setIsOpen(true)} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-full pr-2 overflow-auto">
|
||||||
|
{sprints.map((sprint) => (
|
||||||
|
<SprintView
|
||||||
|
sprint={sprint}
|
||||||
|
selectSprint={setSelectedSprint}
|
||||||
|
projectId={projectId as string}
|
||||||
|
workspaceSlug={activeWorkspace?.slug as string}
|
||||||
|
openIssueModal={openIssueModal}
|
||||||
|
addIssueToSprint={addIssueToSprint}
|
||||||
|
key={sprint.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center px-4">
|
||||||
|
<EmptySpace
|
||||||
|
title="You don't have any cycle yet."
|
||||||
|
description="A cycle is a fixed time period where a team commits to a set number of issues from their backlog. Cycles are usually one, two, or four weeks long."
|
||||||
|
Icon={ArrowPathIcon}
|
||||||
|
>
|
||||||
|
<EmptySpaceItem
|
||||||
|
title="Create a new cycle"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Use{" "}
|
||||||
|
<pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + Q</pre>{" "}
|
||||||
|
shortcut to create a new cycle
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
Icon={PlusIcon}
|
||||||
|
action={() => setIsOpen(true)}
|
||||||
|
/>
|
||||||
|
</EmptySpace>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectSprints;
|
375
pages/projects/[projectId]/issues/[issueId].tsx
Normal file
375
pages/projects/[projectId]/issues/[issueId].tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
// next
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import Image from "next/image";
|
||||||
|
// react
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// react hook form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// headless ui
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import issuesServices from "lib/services/issues.services";
|
||||||
|
// fetch keys
|
||||||
|
import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUES_COMMENTS, STATE_LIST } from "constants/fetch-keys";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// components
|
||||||
|
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||||
|
import IssueCommentSection from "components/project/issues/comment/IssueCommentSection";
|
||||||
|
// common
|
||||||
|
import { timeAgo, debounce, addSpaceIfCamelCase } from "constants/common";
|
||||||
|
// components
|
||||||
|
import IssueDetailSidebar from "components/project/issues/issue-detail/IssueDetailSidebar";
|
||||||
|
// ui
|
||||||
|
import { Spinner, TextArea } from "ui";
|
||||||
|
// types
|
||||||
|
import { IIssue, IIssueComment, IssueResponse, IState } from "types";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
import stateServices from "lib/services/state.services";
|
||||||
|
|
||||||
|
const IssueDetail: NextPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { issueId, projectId } = router.query;
|
||||||
|
|
||||||
|
const { activeWorkspace, activeProject, issues, mutateIssues } = useUser();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const [issueDetail, setIssueDetail] = useState<IIssue | undefined>(undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
} = useForm<IIssue>({});
|
||||||
|
|
||||||
|
const { data: issueActivities } = useSWR<any[]>(
|
||||||
|
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null,
|
||||||
|
activeWorkspace && projectId && issueId
|
||||||
|
? () =>
|
||||||
|
issuesServices.getIssueActivities(
|
||||||
|
activeWorkspace.slug,
|
||||||
|
projectId as string,
|
||||||
|
issueId as string
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: issueComments } = useSWR<IIssueComment[]>(
|
||||||
|
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS : null,
|
||||||
|
activeWorkspace && projectId && issueId
|
||||||
|
? () =>
|
||||||
|
issuesServices.getIssueComments(
|
||||||
|
activeWorkspace.slug,
|
||||||
|
projectId as string,
|
||||||
|
issueId as string
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: states } = useSWR<IState[]>(
|
||||||
|
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
|
||||||
|
activeWorkspace && activeProject
|
||||||
|
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitChanges = useCallback(
|
||||||
|
(formData: Partial<IIssue>) => {
|
||||||
|
if (!activeWorkspace || !activeProject || !issueId) return;
|
||||||
|
mutateIssues(
|
||||||
|
(prevData) => ({
|
||||||
|
...(prevData as IssueResponse),
|
||||||
|
results: (prevData?.results ?? []).map((issue) => {
|
||||||
|
if (issue.id === issueId) {
|
||||||
|
return { ...issue, ...formData };
|
||||||
|
}
|
||||||
|
return issue;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
issuesServices
|
||||||
|
.patchIssue(activeWorkspace.slug, projectId as string, issueId as string, formData)
|
||||||
|
.then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[activeProject, activeWorkspace, issueId, projectId, mutateIssues]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (issueDetail)
|
||||||
|
reset({
|
||||||
|
...issueDetail,
|
||||||
|
blockers_list:
|
||||||
|
issueDetail.blockers_list ??
|
||||||
|
issueDetail.blocker_issues?.map((issue) => issue.blocker_issue_detail?.id),
|
||||||
|
blocked_list:
|
||||||
|
issueDetail.blocked_list ??
|
||||||
|
issueDetail.blocked_issues?.map((issue) => issue.blocked_issue_detail?.id),
|
||||||
|
assignees_list:
|
||||||
|
issueDetail.assignees_list ?? issueDetail.assignee_details?.map((user) => user.id),
|
||||||
|
labels_list: issueDetail.labels_list ?? issueDetail.labels?.map((label) => label),
|
||||||
|
});
|
||||||
|
}, [issueDetail, reset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const issueIndex = issues?.results.findIndex((issue) => issue.id === issueId);
|
||||||
|
if (issueIndex === undefined) return;
|
||||||
|
const issueDetail = issues?.results[issueIndex];
|
||||||
|
setIssueDetail(issueDetail);
|
||||||
|
}, [issues, issueId]);
|
||||||
|
|
||||||
|
const prevIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) - 1];
|
||||||
|
const nextIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) + 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout>
|
||||||
|
<CreateUpdateIssuesModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
projectId={projectId as string}
|
||||||
|
data={isOpen ? issueDetail : undefined}
|
||||||
|
isUpdatingSingleIssue
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem
|
||||||
|
title={`${activeProject?.name} Issues`}
|
||||||
|
link={`/projects/${activeProject?.id}/issues`}
|
||||||
|
/>
|
||||||
|
<BreadcrumbItem
|
||||||
|
title={`Issue ${activeProject?.identifier}-${issueDetail?.sequence_id} Details`}
|
||||||
|
/>
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="bg-gray-50 rounded-xl overflow-hidden">
|
||||||
|
{issueDetail && activeProject ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full py-4 px-10 bg-gray-200 flex justify-between items-center">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
<Link href={`/projects/${activeProject.id}/issues`}>{activeProject.name}</Link>/
|
||||||
|
{activeProject.identifier}-{issueDetail.sequence_id}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-4 py-1.5 bg-white rounded-lg ${
|
||||||
|
prevIssue ? "hover:bg-gray-100" : "bg-gray-100"
|
||||||
|
}`}
|
||||||
|
disabled={prevIssue ? false : true}
|
||||||
|
onClick={() => {
|
||||||
|
if (!prevIssue) return;
|
||||||
|
router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-4 py-1.5 bg-white rounded-lg ${
|
||||||
|
nextIssue ? "hover:bg-gray-100" : "bg-gray-100"
|
||||||
|
}`}
|
||||||
|
disabled={nextIssue ? false : true}
|
||||||
|
onClick={() => {
|
||||||
|
if (!nextIssue) return;
|
||||||
|
router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap">
|
||||||
|
<div className="w-full lg:w-3/4 h-full px-2 md:px-10 py-10 overflow-auto">
|
||||||
|
<div className="w-full h-full space-y-5">
|
||||||
|
<TextArea
|
||||||
|
id="name"
|
||||||
|
placeholder="Enter issue name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="off"
|
||||||
|
validations={{ required: true }}
|
||||||
|
register={register}
|
||||||
|
onChange={debounce(() => {
|
||||||
|
handleSubmit(submitChanges)();
|
||||||
|
}, 5000)}
|
||||||
|
mode="transparent"
|
||||||
|
className="text-3xl sm:text-3xl"
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
error={errors.description}
|
||||||
|
validations={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
onChange={debounce(() => {
|
||||||
|
handleSubmit(submitChanges)();
|
||||||
|
}, 5000)}
|
||||||
|
placeholder="Enter issue description"
|
||||||
|
mode="transparent"
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="bg-gray-50 px-2 text-sm text-gray-500">
|
||||||
|
Activity/Comments
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<Tab.Group>
|
||||||
|
<Tab.List className="flex gap-x-3">
|
||||||
|
{["Comments", "Activity"].map((item) => (
|
||||||
|
<Tab
|
||||||
|
key={item}
|
||||||
|
className={({ selected }) =>
|
||||||
|
`px-3 py-1 text-sm rounded-md ${
|
||||||
|
selected ? "bg-gray-800 text-white" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels className="mt-5">
|
||||||
|
<Tab.Panel>
|
||||||
|
<IssueCommentSection
|
||||||
|
comments={issueComments}
|
||||||
|
workspaceSlug={activeWorkspace?.slug as string}
|
||||||
|
projectId={projectId as string}
|
||||||
|
issueId={issueId as string}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel>
|
||||||
|
{issueActivities ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{issueActivities.map((activity) => {
|
||||||
|
if (activity.field !== "updated_by")
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
className="relative flex gap-x-2 w-full"
|
||||||
|
>
|
||||||
|
{/* <span
|
||||||
|
className="absolute top-5 left-5 -ml-1 h-full w-0.5 bg-gray-200"
|
||||||
|
aria-hidden="true"
|
||||||
|
/> */}
|
||||||
|
<div className="flex-shrink-0 -ml-1.5">
|
||||||
|
{activity.actor_detail.avatar &&
|
||||||
|
activity.actor_detail.avatar !== "" ? (
|
||||||
|
<Image
|
||||||
|
src={activity.actor_detail.avatar}
|
||||||
|
alt={activity.actor_detail.name}
|
||||||
|
height={30}
|
||||||
|
width={30}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`h-8 w-8 bg-gray-500 text-white border-2 border-white grid place-items-center rounded-full`}
|
||||||
|
>
|
||||||
|
{activity.actor_detail.first_name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<p>
|
||||||
|
{activity.actor_detail.first_name}{" "}
|
||||||
|
{activity.actor_detail.last_name}{" "}
|
||||||
|
<span>{activity.verb}</span>{" "}
|
||||||
|
{activity.verb !== "created" ? (
|
||||||
|
<span>{activity.field ?? "commented"}</span>
|
||||||
|
) : (
|
||||||
|
" this issue"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{timeAgo(activity.created_at)}
|
||||||
|
</p>
|
||||||
|
<div className="w-full mt-2">
|
||||||
|
{activity.verb !== "created" && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>
|
||||||
|
From:{" "}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{activity.field === "state"
|
||||||
|
? activity.old_value
|
||||||
|
? addSpaceIfCamelCase(
|
||||||
|
states?.find(
|
||||||
|
(s) => s.id === activity.old_value
|
||||||
|
)?.name ?? ""
|
||||||
|
)
|
||||||
|
: "None"
|
||||||
|
: activity.old_value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
To:{" "}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{activity.field === "state"
|
||||||
|
? activity.new_value
|
||||||
|
? addSpaceIfCamelCase(
|
||||||
|
states?.find(
|
||||||
|
(s) => s.id === activity.new_value
|
||||||
|
)?.name ?? ""
|
||||||
|
)
|
||||||
|
: "None"
|
||||||
|
: activity.new_value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full lg:w-1/4 h-full border-l px-2 md:px-10 py-10">
|
||||||
|
<IssueDetailSidebar control={control} submitChanges={submitChanges} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IssueDetail;
|
329
pages/projects/[projectId]/issues/index.tsx
Normal file
329
pages/projects/[projectId]/issues/index.tsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
// react
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// headless ui
|
||||||
|
import { Menu, Popover, Transition } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import stateServices from "lib/services/state.services";
|
||||||
|
import issuesServices from "lib/services/issues.services";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
import useTheme from "lib/hooks/useTheme";
|
||||||
|
import useIssuesProperties from "lib/hooks/useIssuesProperties";
|
||||||
|
// fetching keys
|
||||||
|
import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys";
|
||||||
|
// commons
|
||||||
|
import { groupBy } from "constants/common";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// components
|
||||||
|
import ListView from "components/project/issues/ListView";
|
||||||
|
import BoardView from "components/project/issues/BoardView";
|
||||||
|
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
|
||||||
|
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "ui";
|
||||||
|
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||||
|
import HeaderButton from "ui/HeaderButton";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
// icons
|
||||||
|
import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { PlusIcon, EyeIcon, EyeSlashIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||||
|
// types
|
||||||
|
import type { IIssue, IssueResponse, Properties, IState, NestedKeyOf } from "types";
|
||||||
|
|
||||||
|
const PRIORITIES = ["high", "medium", "low"];
|
||||||
|
|
||||||
|
const ProjectIssues: NextPage = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { issueView, setIssueView, groupByProperty, setGroupByProperty } = useTheme();
|
||||||
|
|
||||||
|
const [selectedIssue, setSelectedIssue] = useState<
|
||||||
|
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [editIssue, setEditIssue] = useState<string | undefined>();
|
||||||
|
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { activeWorkspace, activeProject } = useUser();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { projectId } = router.query;
|
||||||
|
|
||||||
|
const [properties, setProperties] = useIssuesProperties(
|
||||||
|
activeWorkspace?.slug,
|
||||||
|
projectId as string
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: projectIssues } = useSWR<IssueResponse>(
|
||||||
|
projectId && activeWorkspace
|
||||||
|
? PROJECT_ISSUES_LIST(activeWorkspace.slug, projectId as string)
|
||||||
|
: null,
|
||||||
|
activeWorkspace && projectId
|
||||||
|
? () => issuesServices.getIssues(activeWorkspace.slug, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: states } = useSWR<IState[]>(
|
||||||
|
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
|
||||||
|
activeWorkspace && activeProject
|
||||||
|
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSelectedIssue(undefined);
|
||||||
|
clearTimeout(timer);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const groupedByIssues: {
|
||||||
|
[key: string]: IIssue[];
|
||||||
|
} = {
|
||||||
|
...(groupByProperty === "state_detail.name"
|
||||||
|
? Object.fromEntries(
|
||||||
|
states
|
||||||
|
?.sort((a, b) => a.sequence - b.sequence)
|
||||||
|
?.map((state) => [
|
||||||
|
state.name,
|
||||||
|
projectIssues?.results.filter((issue) => issue.state === state.name) ?? [],
|
||||||
|
]) ?? []
|
||||||
|
)
|
||||||
|
: groupByProperty === "priority"
|
||||||
|
? Object.fromEntries(
|
||||||
|
PRIORITIES.map((priority) => [
|
||||||
|
priority,
|
||||||
|
projectIssues?.results.filter((issue) => issue.priority === priority) ?? [],
|
||||||
|
])
|
||||||
|
)
|
||||||
|
: {}),
|
||||||
|
...groupBy(projectIssues?.results ?? [], groupByProperty ?? ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
|
||||||
|
{ name: "State", key: "state_detail.name" },
|
||||||
|
{ name: "Priority", key: "priority" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout>
|
||||||
|
<CreateUpdateIssuesModal
|
||||||
|
isOpen={isOpen && selectedIssue?.actionType !== "delete"}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
projectId={projectId as string}
|
||||||
|
data={selectedIssue}
|
||||||
|
/>
|
||||||
|
<ConfirmIssueDeletion
|
||||||
|
handleClose={() => setDeleteIssue(undefined)}
|
||||||
|
isOpen={!!deleteIssue}
|
||||||
|
data={projectIssues?.results.find((issue) => issue.id === deleteIssue)}
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5 pb-6 mb-10">
|
||||||
|
{!projectIssues ? (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : projectIssues.count > 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full px-2 pb-8">
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title="Projects" link="/projects" />
|
||||||
|
<BreadcrumbItem title={`${activeProject?.name} Issues`} />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="flex items-center justify-between cursor-pointer w-full">
|
||||||
|
<h2 className="text-2xl font-medium">Project Issues</h2>
|
||||||
|
<div className="flex items-center gap-x-3">
|
||||||
|
<div className="flex items-center gap-x-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
|
||||||
|
issueView === "list" ? "bg-gray-200" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIssueView("list");
|
||||||
|
setGroupByProperty(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListBulletIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
|
||||||
|
issueView === "kanban" ? "bg-gray-200" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIssueView("kanban");
|
||||||
|
setGroupByProperty("state_detail.name");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Squares2X2Icon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Menu as="div" className="relative inline-block w-40">
|
||||||
|
<div className="w-full">
|
||||||
|
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md shadow-sm p-2 bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none">
|
||||||
|
<span className="flex gap-x-1 items-center">
|
||||||
|
{groupByOptions.find((option) => option.key === groupByProperty)?.name ??
|
||||||
|
"No Grouping"}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow flex justify-end">
|
||||||
|
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</Menu.Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items className="origin-top-left absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
|
||||||
|
<div className="p-1">
|
||||||
|
{groupByOptions.map((option) => (
|
||||||
|
<Menu.Item key={option.key}>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${
|
||||||
|
active ? "bg-theme text-white" : "text-gray-900"
|
||||||
|
} group flex w-full items-center rounded-md p-2 text-xs`}
|
||||||
|
onClick={() => setGroupByProperty(option.key)}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
{issueView === "list" ? (
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`hover:bg-theme hover:text-white ${
|
||||||
|
active ? "bg-theme text-white" : "text-gray-900"
|
||||||
|
} group flex w-full items-center rounded-md p-2 text-xs`}
|
||||||
|
onClick={() => setGroupByProperty(null)}
|
||||||
|
>
|
||||||
|
No grouping
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
<Popover className="relative">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Popover.Button className="inline-flex justify-between items-center rounded-md shadow-sm p-2 bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none w-40">
|
||||||
|
<span>Properties</span>
|
||||||
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
|
</Popover.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="transition ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 translate-y-1"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transition ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<Popover.Panel className="absolute left-1/2 z-10 mt-1 -translate-x-1/2 transform px-2 sm:px-0 w-full">
|
||||||
|
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5">
|
||||||
|
<div className="relative grid bg-white p-1">
|
||||||
|
{Object.keys(properties).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`text-gray-900 hover:bg-theme hover:text-white flex justify-between w-full items-center rounded-md p-2 text-xs`}
|
||||||
|
onClick={() => setProperties(key as keyof Properties)}
|
||||||
|
>
|
||||||
|
<p className="capitalize">{key.replace("_", " ")}</p>
|
||||||
|
<span className="self-end">
|
||||||
|
{properties[key as keyof Properties] ? (
|
||||||
|
<EyeIcon width="18" height="18" />
|
||||||
|
) : (
|
||||||
|
<EyeSlashIcon width="18" height="18" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
<HeaderButton
|
||||||
|
Icon={PlusIcon}
|
||||||
|
label="Add Issue"
|
||||||
|
action={() => {
|
||||||
|
const e = new KeyboardEvent("keydown", {
|
||||||
|
key: "i",
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
{issueView === "list" ? (
|
||||||
|
<ListView
|
||||||
|
properties={properties}
|
||||||
|
groupedByIssues={groupedByIssues}
|
||||||
|
selectedGroup={groupByProperty}
|
||||||
|
setSelectedIssue={setSelectedIssue}
|
||||||
|
handleDeleteIssue={setDeleteIssue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BoardView
|
||||||
|
properties={properties}
|
||||||
|
selectedGroup={groupByProperty}
|
||||||
|
groupedByIssues={groupedByIssues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center px-4">
|
||||||
|
<EmptySpace
|
||||||
|
title="You don't have any issue yet."
|
||||||
|
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||||
|
Icon={RectangleStackIcon}
|
||||||
|
>
|
||||||
|
<EmptySpaceItem
|
||||||
|
title="Create a new issue"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Use <pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + I</pre>{" "}
|
||||||
|
shortcut to create a new issue
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
Icon={PlusIcon}
|
||||||
|
action={() => setIsOpen(true)}
|
||||||
|
/>
|
||||||
|
</EmptySpace>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectIssues;
|
203
pages/projects/[projectId]/members.tsx
Normal file
203
pages/projects/[projectId]/members.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
// next
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// headless ui
|
||||||
|
import { Menu } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import projectService from "lib/services/project.service";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// fetching keys
|
||||||
|
import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// components
|
||||||
|
import SendProjectInvitationModal from "components/project/SendProjectInvitationModal";
|
||||||
|
// ui
|
||||||
|
import { Spinner, Button } from "ui";
|
||||||
|
// icons
|
||||||
|
import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||||
|
import HeaderButton from "ui/HeaderButton";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
|
||||||
|
const ROLE = {
|
||||||
|
5: "Guest",
|
||||||
|
10: "Viewer",
|
||||||
|
15: "Member",
|
||||||
|
20: "Admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectMembers: NextPage = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { activeWorkspace, activeProject } = useUser();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { projectId } = router.query;
|
||||||
|
|
||||||
|
const { data: projectMembers, mutate: mutateMembers } = useSWR(
|
||||||
|
activeWorkspace && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||||
|
activeWorkspace && projectId
|
||||||
|
? () => projectService.projectMembers(activeWorkspace.slug, projectId as any)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const { data: projectInvitations, mutate: mutateInvitations } = useSWR(
|
||||||
|
activeWorkspace && projectId ? PROJECT_INVITATIONS : null,
|
||||||
|
activeWorkspace && projectId
|
||||||
|
? () => projectService.projectInvitations(activeWorkspace.slug, projectId as any)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
let members = [
|
||||||
|
...(projectMembers?.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
email: item.member?.email,
|
||||||
|
role: item.role,
|
||||||
|
status: true,
|
||||||
|
member: true,
|
||||||
|
})) || []),
|
||||||
|
...(projectInvitations?.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
email: item.email,
|
||||||
|
role: item.role,
|
||||||
|
status: item.accepted,
|
||||||
|
member: false,
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout>
|
||||||
|
<SendProjectInvitationModal isOpen={isOpen} setIsOpen={setIsOpen} members={members} />
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5">
|
||||||
|
{!projectMembers || !projectInvitations ? (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5 pb-10 overflow-auto">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title="Projects" link="/projects" />
|
||||||
|
<BreadcrumbItem title={`${activeProject?.name} Members`} />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="flex items-center justify-between cursor-pointer w-full">
|
||||||
|
<h2 className="text-2xl font-medium">Invite Members</h2>
|
||||||
|
<HeaderButton Icon={PlusIcon} label="Add Member" action={() => setIsOpen(true)} />
|
||||||
|
</div>
|
||||||
|
{members && members.length === 0 ? null : (
|
||||||
|
<>
|
||||||
|
<table className="min-w-full table-fixed border border-gray-300 md:rounded-lg divide-y divide-gray-300">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6 w-10">
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{members?.map((member: any) => (
|
||||||
|
<tr key={member.id}>
|
||||||
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||||
|
{member.email ?? "No email has been added."}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||||
|
{ROLE[member.role as keyof typeof ROLE] ?? "None"}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
|
||||||
|
{member?.member ? (
|
||||||
|
"Member"
|
||||||
|
) : member.status ? (
|
||||||
|
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
|
||||||
|
Accepted
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||||
|
<Menu>
|
||||||
|
<Menu.Button>
|
||||||
|
<EllipsisHorizontalIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="inline text-gray-500"
|
||||||
|
/>
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute z-50 w-28 bg-white rounded border cursor-pointer -left-20 top-9">
|
||||||
|
<Menu.Item>
|
||||||
|
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||||
|
<button
|
||||||
|
className="w-full text-left py-2 pl-2"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||||
|
<button
|
||||||
|
className="w-full text-left py-2 pl-2"
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
member.member
|
||||||
|
? (await projectService.deleteProjectMember(
|
||||||
|
activeWorkspace?.slug as string,
|
||||||
|
projectId as any,
|
||||||
|
member.id
|
||||||
|
),
|
||||||
|
await mutateMembers())
|
||||||
|
: (await projectService.deleteProjectInvitation(
|
||||||
|
activeWorkspace?.slug as string,
|
||||||
|
projectId as any,
|
||||||
|
member.id
|
||||||
|
),
|
||||||
|
await mutateInvitations());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectMembers;
|
375
pages/projects/[projectId]/settings.tsx
Normal file
375
pages/projects/[projectId]/settings.tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
// swr
|
||||||
|
import { mutate } from "swr";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// react hook form
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
// headless ui
|
||||||
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// service
|
||||||
|
import projectServices from "lib/services/project.service";
|
||||||
|
import workspaceService from "lib/services/workspace.service";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
import useToast from "lib/hooks/useToast";
|
||||||
|
// fetch keys
|
||||||
|
import { PROJECT_DETAILS, PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||||
|
// ui
|
||||||
|
import { Spinner, Button, Input, TextArea, Select } from "ui";
|
||||||
|
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
|
||||||
|
// icons
|
||||||
|
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||||
|
// types
|
||||||
|
import type { IProject, IWorkspace, WorkspaceMember } from "types";
|
||||||
|
|
||||||
|
const defaultValues: Partial<IProject> = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
|
||||||
|
|
||||||
|
const ProjectSettings: NextPage = () => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<IProject>({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { projectId } = router.query;
|
||||||
|
|
||||||
|
const { activeWorkspace, activeProject } = useUser();
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { data: projectDetails } = useSWR<IProject>(
|
||||||
|
activeWorkspace && projectId ? PROJECT_DETAILS : null,
|
||||||
|
activeWorkspace
|
||||||
|
? () => projectServices.getProject(activeWorkspace.slug, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: people } = useSWR<WorkspaceMember[]>(
|
||||||
|
activeWorkspace ? WORKSPACE_MEMBERS : null,
|
||||||
|
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
projectDetails &&
|
||||||
|
reset({
|
||||||
|
...projectDetails,
|
||||||
|
default_assignee: projectDetails.default_assignee?.id,
|
||||||
|
project_lead: projectDetails.project_lead?.id,
|
||||||
|
workspace: (projectDetails.workspace as IWorkspace).id,
|
||||||
|
});
|
||||||
|
}, [projectDetails, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: IProject) => {
|
||||||
|
if (!activeWorkspace) return;
|
||||||
|
const payload: Partial<IProject> = {
|
||||||
|
name: formData.name,
|
||||||
|
network: formData.network,
|
||||||
|
description: formData.description,
|
||||||
|
default_assignee: formData.default_assignee,
|
||||||
|
project_lead: formData.project_lead,
|
||||||
|
};
|
||||||
|
await projectServices
|
||||||
|
.updateProject(activeWorkspace.slug, projectId as string, payload)
|
||||||
|
.then((res) => {
|
||||||
|
mutate<IProject>(PROJECT_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||||
|
mutate<IProject[]>(
|
||||||
|
PROJECTS_LIST(activeWorkspace.slug),
|
||||||
|
(prevData) => {
|
||||||
|
const newData = prevData?.map((item) => {
|
||||||
|
if (item.id === res.id) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
setToastAlert({
|
||||||
|
title: "Success",
|
||||||
|
type: "success",
|
||||||
|
message: "Project updated successfully",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout>
|
||||||
|
<div className="w-full h-full space-y-5">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title="Projects" link="/projects" />
|
||||||
|
<BreadcrumbItem title={`${activeProject?.name} Settings`} />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="w-full h-full flex flex-col space-y-3">
|
||||||
|
{projectDetails ? (
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="mt-3">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium leading-6 text-gray-900">General</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
This information will be displayed to every member of the project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
error={errors.name}
|
||||||
|
register={register}
|
||||||
|
placeholder="Project Name"
|
||||||
|
label="Name"
|
||||||
|
validations={{
|
||||||
|
required: "Name is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
name="network"
|
||||||
|
id="network"
|
||||||
|
options={Object.keys(NETWORK_CHOICES).map((key) => ({
|
||||||
|
value: key,
|
||||||
|
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
|
||||||
|
}))}
|
||||||
|
label="Network"
|
||||||
|
register={register}
|
||||||
|
validations={{
|
||||||
|
required: "Network is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
id="identifier"
|
||||||
|
name="identifier"
|
||||||
|
error={errors.identifier}
|
||||||
|
register={register}
|
||||||
|
placeholder="Enter identifier"
|
||||||
|
label="Identifier"
|
||||||
|
validations={{
|
||||||
|
required: "Identifier is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TextArea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
error={errors.description}
|
||||||
|
register={register}
|
||||||
|
label="Description"
|
||||||
|
placeholder="Enter project description"
|
||||||
|
validations={{
|
||||||
|
required: "Description is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium leading-6 text-gray-900">Control</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Set the control for the project.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="project_lead"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Listbox value={value} onChange={onChange}>
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Listbox.Label>
|
||||||
|
<div className="text-gray-500 mb-2">Project Lead</div>
|
||||||
|
</Listbox.Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||||
|
<span className="block truncate">
|
||||||
|
{people?.find((person) => person.member.id === value)
|
||||||
|
?.member.first_name ?? "Select Lead"}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Listbox.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
as={React.Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
|
{people?.map((person) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={person.id}
|
||||||
|
className={({ active }) =>
|
||||||
|
`${
|
||||||
|
active ? "text-white bg-theme" : "text-gray-900"
|
||||||
|
} cursor-default select-none relative py-2 pl-3 pr-9`
|
||||||
|
}
|
||||||
|
value={person.member.id}
|
||||||
|
>
|
||||||
|
{({ selected, active }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`${
|
||||||
|
selected ? "font-semibold" : "font-normal"
|
||||||
|
} block truncate`}
|
||||||
|
>
|
||||||
|
{person.member.first_name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<span
|
||||||
|
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||||
|
active ? "text-white" : "text-indigo-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="default_assignee"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Listbox value={value} onChange={onChange}>
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Listbox.Label>
|
||||||
|
<div className="text-gray-500 mb-2">Default Assignee</div>
|
||||||
|
</Listbox.Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||||
|
<span className="block truncate">
|
||||||
|
{people?.find((p) => p.member.id === value)?.member
|
||||||
|
.first_name ?? "Select Default Assignee"}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Listbox.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
as={React.Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
|
{people?.map((person) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={person.id}
|
||||||
|
className={({ active }) =>
|
||||||
|
`${
|
||||||
|
active ? "text-white bg-theme" : "text-gray-900"
|
||||||
|
} cursor-default select-none relative py-2 pl-3 pr-9`
|
||||||
|
}
|
||||||
|
value={person.member.id}
|
||||||
|
>
|
||||||
|
{({ selected, active }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`${
|
||||||
|
selected ? "font-semibold" : "font-normal"
|
||||||
|
} block truncate`}
|
||||||
|
>
|
||||||
|
{person.member.first_name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<span
|
||||||
|
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||||
|
active ? "text-white" : "text-indigo-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Updating Project..." : "Update Project"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectSettings;
|
144
pages/projects/index.tsx
Normal file
144
pages/projects/index.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// components
|
||||||
|
import CreateProjectModal from "components/project/CreateProjectModal";
|
||||||
|
import ConfirmProjectDeletion from "components/project/ConfirmProjectDeletion";
|
||||||
|
// ui
|
||||||
|
import { Button, Spinner } from "ui";
|
||||||
|
// types
|
||||||
|
import { IProject } from "types";
|
||||||
|
// services
|
||||||
|
import projectService from "lib/services/project.service";
|
||||||
|
import ProjectMemberInvitations from "components/project/memberInvitations";
|
||||||
|
import { ClipboardDocumentListIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||||
|
import HeaderButton from "ui/HeaderButton";
|
||||||
|
|
||||||
|
const Projects: NextPage = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [deleteProject, setDeleteProject] = useState<IProject | undefined>();
|
||||||
|
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { projects, activeWorkspace, mutateProjects } = useUser();
|
||||||
|
|
||||||
|
const handleInvitation = (project_invitation: any, action: "accepted" | "withdraw") => {
|
||||||
|
if (action === "accepted") {
|
||||||
|
setInvitationsRespond((prevData) => {
|
||||||
|
return [...prevData, project_invitation.id];
|
||||||
|
});
|
||||||
|
} else if (action === "withdraw") {
|
||||||
|
setInvitationsRespond((prevData) => {
|
||||||
|
return prevData.filter((item: string) => item !== project_invitation.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitInvitations = () => {
|
||||||
|
projectService
|
||||||
|
.joinProject((activeWorkspace as any)?.slug, { project_ids: invitationsRespond })
|
||||||
|
.then(async (res: any) => {
|
||||||
|
console.log(res);
|
||||||
|
setInvitationsRespond([]);
|
||||||
|
await mutateProjects();
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDeleteProject(undefined);
|
||||||
|
clearTimeout(timer);
|
||||||
|
}, 300);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout>
|
||||||
|
<CreateProjectModal isOpen={isOpen && !deleteProject} setIsOpen={setIsOpen} />
|
||||||
|
<ConfirmProjectDeletion
|
||||||
|
isOpen={isOpen && !!deleteProject}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
data={deleteProject}
|
||||||
|
/>
|
||||||
|
{projects ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5 pb-10">
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center px-4">
|
||||||
|
<EmptySpace
|
||||||
|
title="You don't have any project yet."
|
||||||
|
description="Projects are a collection of issues. They can be used to represent the development work for a product, project, or service."
|
||||||
|
Icon={ClipboardDocumentListIcon}
|
||||||
|
>
|
||||||
|
<EmptySpaceItem
|
||||||
|
title="Create a new project"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Use{" "}
|
||||||
|
<pre className="inline bg-gray-100 px-2 py-1 rounded">
|
||||||
|
Ctrl/Command + P
|
||||||
|
</pre>{" "}
|
||||||
|
shortcut to create a new project
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
Icon={PlusIcon}
|
||||||
|
action={() => setIsOpen(true)}
|
||||||
|
/>
|
||||||
|
</EmptySpace>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title={`${activeWorkspace?.name} Projects`} />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="flex items-center justify-between cursor-pointer w-full">
|
||||||
|
<h2 className="text-2xl font-medium">Projects</h2>
|
||||||
|
<HeaderButton
|
||||||
|
Icon={PlusIcon}
|
||||||
|
label="Add Project"
|
||||||
|
action={() => setIsOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{projects.map((item) => (
|
||||||
|
<ProjectMemberInvitations
|
||||||
|
key={item.id}
|
||||||
|
project={item}
|
||||||
|
slug={(activeWorkspace as any).slug}
|
||||||
|
invitationsRespond={invitationsRespond}
|
||||||
|
handleInvitation={handleInvitation}
|
||||||
|
setDeleteProject={setDeleteProject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{invitationsRespond.length > 0 && (
|
||||||
|
<div className="flex justify-between mt-4">
|
||||||
|
<Button onClick={submitInvitations}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Projects;
|
173
pages/signin.tsx
Normal file
173
pages/signin.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import Image from "next/image";
|
||||||
|
// react
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// services
|
||||||
|
import authenticationService from "lib/services/authentication.service";
|
||||||
|
// hoc
|
||||||
|
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||||
|
// layouts
|
||||||
|
import DefaultLayout from "layouts/DefaultLayout";
|
||||||
|
// social button
|
||||||
|
import { GoogleLoginButton } from "components/socialbuttons/google-login";
|
||||||
|
import EmailCodeForm from "components/forms/EmailCodeForm";
|
||||||
|
import EmailPasswordForm from "components/forms/EmailPasswordForm";
|
||||||
|
// logos
|
||||||
|
import Logo from "public/logo.png";
|
||||||
|
import GitHubLogo from "public/logos/github.png";
|
||||||
|
import { KeyIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// types
|
||||||
|
type SignIn = {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
medium?: string;
|
||||||
|
key?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SignIn: NextPage = () => {
|
||||||
|
const [useCode, setUseCode] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutateUser, mutateWorkspaces } = useUser();
|
||||||
|
|
||||||
|
const [githubToken, setGithubToken] = React.useState(undefined);
|
||||||
|
const [loginCallBackURL, setLoginCallBackURL] = React.useState(undefined);
|
||||||
|
|
||||||
|
const onSignInSuccess = useCallback(
|
||||||
|
async (res: any) => {
|
||||||
|
await mutateUser();
|
||||||
|
await mutateWorkspaces();
|
||||||
|
if (res.user.is_onboarded) router.push("/");
|
||||||
|
else router.push("/invitations");
|
||||||
|
},
|
||||||
|
[mutateUser, mutateWorkspaces, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const githubTokenMemo = React.useMemo(() => {
|
||||||
|
return githubToken;
|
||||||
|
}, [githubToken]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const {
|
||||||
|
query: { code },
|
||||||
|
} = router;
|
||||||
|
if (code && !githubTokenMemo) {
|
||||||
|
setGithubToken(code as any);
|
||||||
|
}
|
||||||
|
}, [router, githubTokenMemo]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (githubToken) {
|
||||||
|
authenticationService
|
||||||
|
.socialAuth({
|
||||||
|
medium: "github",
|
||||||
|
credential: githubToken,
|
||||||
|
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
await onSignInSuccess(response);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [githubToken, mutateUser, mutateWorkspaces, router, onSignInSuccess]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const origin =
|
||||||
|
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||||
|
setLoginCallBackURL(`${origin}/signin` as any);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout
|
||||||
|
meta={{
|
||||||
|
title: "Plane - Sign In",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full h-screen flex justify-center items-center bg-gray-50 overflow-auto">
|
||||||
|
<div className="min-h-full w-full flex flex-col justify-center py-12 px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="text-center">
|
||||||
|
<Image src={Logo} height={40} width={179} alt="Plane Web Logo" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 text-center text-3xl font-bold text-gray-900">
|
||||||
|
Sign in to your account
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white mt-16 py-8 px-4 sm:rounded-lg sm:px-10">
|
||||||
|
{useCode ? (
|
||||||
|
<EmailCodeForm onSuccess={onSignInSuccess} />
|
||||||
|
) : (
|
||||||
|
<EmailPasswordForm onSuccess={onSignInSuccess} />
|
||||||
|
)}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 w-full flex flex-col items-stretch gap-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full border border-gray-300 hover:bg-gray-100 px-3 py-2 rounded text-sm flex items-center duration-300"
|
||||||
|
onClick={() => setUseCode((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<KeyIcon className="h-[25px] w-[25px]" />
|
||||||
|
<span className="text-center w-full font-medium">
|
||||||
|
{useCode ? "Continue with Password" : "Continue with Code"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<GoogleLoginButton
|
||||||
|
onSuccess={({ clientId, credential }) => {
|
||||||
|
authenticationService
|
||||||
|
.socialAuth({
|
||||||
|
medium: "google",
|
||||||
|
credential,
|
||||||
|
clientId,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
await onSignInSuccess(response);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onFailure={(err) => {
|
||||||
|
console.log(err);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}`}
|
||||||
|
>
|
||||||
|
<button className="w-full bg-black opacity-90 hover:opacity-100 text-white text-sm flex items-center px-3 py-2 rounded duration-300">
|
||||||
|
<Image
|
||||||
|
src={GitHubLogo}
|
||||||
|
height={25}
|
||||||
|
width={25}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
alt="GitHub Logo"
|
||||||
|
/>
|
||||||
|
<span className="text-center w-full font-medium">Continue with GitHub</span>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withAuthWrapper(SignIn);
|
31
pages/testing.tsx
Normal file
31
pages/testing.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const assignees = [
|
||||||
|
{
|
||||||
|
name: "Wade Cooper",
|
||||||
|
value: "wade-cooper",
|
||||||
|
},
|
||||||
|
{ name: "Unassigned", value: "null" },
|
||||||
|
];
|
||||||
|
|
||||||
|
import { SearchListbox } from "ui";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
const [assigned, setAssigned] = useState(assignees[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-screen w-full">
|
||||||
|
<SearchListbox
|
||||||
|
display="Assign"
|
||||||
|
name="assignee"
|
||||||
|
options={assignees}
|
||||||
|
onChange={(value) => {
|
||||||
|
setAssigned(assignees.find((assignee) => assignee.value === value) ?? assignees[0]);
|
||||||
|
}}
|
||||||
|
value={assigned.value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
152
pages/workspace-member-invitation/[invitationId].tsx
Normal file
152
pages/workspace-member-invitation/[invitationId].tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import React from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// services
|
||||||
|
import workspaceService from "lib/services/workspace.service";
|
||||||
|
// constants
|
||||||
|
import { WORKSPACE_INVITATION } from "constants/fetch-keys";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// hoc
|
||||||
|
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||||
|
// layouts
|
||||||
|
import DefaultLayout from "layouts/DefaultLayout";
|
||||||
|
// ui
|
||||||
|
import { Button } from "ui";
|
||||||
|
// icons
|
||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CubeIcon,
|
||||||
|
ShareIcon,
|
||||||
|
StarIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||||
|
|
||||||
|
const WorkspaceInvitation: NextPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { invitationId, email } = router.query;
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { data: invitationDetail, error } = useSWR(WORKSPACE_INVITATION, () =>
|
||||||
|
workspaceService.getWorkspaceInvitation(invitationId as string)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAccept = () => {
|
||||||
|
workspaceService
|
||||||
|
.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
|
||||||
|
accepted: true,
|
||||||
|
email: invitationDetail.email,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
router.push("/signin");
|
||||||
|
})
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout>
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center px-3">
|
||||||
|
{invitationDetail ? (
|
||||||
|
<>
|
||||||
|
{error ? (
|
||||||
|
<div className="bg-gray-50 rounded shadow-2xl border px-4 py-8 w-full md:w-1/3 space-y-4 flex flex-col text-center">
|
||||||
|
<h2 className="text-xl uppercase">INVITATION NOT FOUND</h2>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-gray-50 rounded shadow-2xl border px-4 py-8 w-full md:w-1/3 space-y-4 flex flex-col justify-between">
|
||||||
|
{invitationDetail.accepted ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl">
|
||||||
|
You are already a member of {invitationDetail.workspace.name}
|
||||||
|
</h2>
|
||||||
|
<div className="w-full flex gap-x-4">
|
||||||
|
<Link href="/signin">
|
||||||
|
<a className="w-full">
|
||||||
|
<Button className="w-full">Go To Login Page</Button>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl">
|
||||||
|
You have been invited to{" "}
|
||||||
|
<span className="font-semibold italic">
|
||||||
|
{invitationDetail.workspace.name}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="w-full flex gap-x-4">
|
||||||
|
<Link href="/">
|
||||||
|
<a className="w-full">
|
||||||
|
<Button theme="secondary" className="w-full">
|
||||||
|
Ignore
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Button className="w-full" onClick={handleAccept}>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EmptySpace
|
||||||
|
title="This invitation link is not active anymore."
|
||||||
|
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
|
||||||
|
link={{ text: "Or start from an empty project", href: "/" }}
|
||||||
|
>
|
||||||
|
{!user ? (
|
||||||
|
<EmptySpaceItem
|
||||||
|
Icon={UserIcon}
|
||||||
|
title="Sign in to continue"
|
||||||
|
action={() => {
|
||||||
|
router.push("/signin");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptySpaceItem
|
||||||
|
Icon={CubeIcon}
|
||||||
|
title="Continue to Dashboard"
|
||||||
|
action={() => {
|
||||||
|
router.push("/");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<EmptySpaceItem
|
||||||
|
Icon={StarIcon}
|
||||||
|
title="Star us on GitHub"
|
||||||
|
action={() => {
|
||||||
|
router.push("https://github.com/makeplane");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EmptySpaceItem
|
||||||
|
Icon={ShareIcon}
|
||||||
|
title="Join our community of active creators"
|
||||||
|
action={() => {
|
||||||
|
router.push("https://discord.com/invite/8SR2N9PAcJ");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EmptySpace>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withAuthWrapper(WorkspaceInvitation);
|
162
pages/workspace/index.tsx
Normal file
162
pages/workspace/index.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
// react
|
||||||
|
import React from "react";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// fetch keys
|
||||||
|
import { USER_ISSUE } from "constants/fetch-keys";
|
||||||
|
// services
|
||||||
|
import userService from "lib/services/user.service";
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "ui";
|
||||||
|
// icons
|
||||||
|
import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
|
// types
|
||||||
|
import type { IIssue } from "types";
|
||||||
|
|
||||||
|
const Workspace: NextPage = () => {
|
||||||
|
const { user, activeWorkspace, projects } = useUser();
|
||||||
|
|
||||||
|
const { data: myIssues } = useSWR<IIssue[]>(
|
||||||
|
user ? USER_ISSUE : null,
|
||||||
|
user ? () => userService.userIssues() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
numbers: projects?.length ?? 0,
|
||||||
|
title: "Projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
numbers: myIssues?.length ?? 0,
|
||||||
|
title: "Issues",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout>
|
||||||
|
<div className="h-full w-full px-2 space-y-5">
|
||||||
|
<div>
|
||||||
|
{user ? (
|
||||||
|
<div className="font-medium text-2xl">Good Morning, {user.first_name}!!</div>
|
||||||
|
) : (
|
||||||
|
<div className="animate-pulse" role="status">
|
||||||
|
<div className="font-semibold text-2xl h-8 bg-gray-200 rounded dark:bg-gray-700 w-60"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* dashboard */}
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="grid grid-cols-2 gap-5">
|
||||||
|
{cards.map(({ id, title, numbers }) => (
|
||||||
|
<div className="py-6 px-6 min-w-[150px] flex-1 bg-white rounded-lg shadow" key={id}>
|
||||||
|
<p className="text-gray-500 mt-2 uppercase">#{title}</p>
|
||||||
|
<h2 className="text-2xl font-semibold">{numbers}</h2>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-5">
|
||||||
|
<div className="max-h-[30rem] overflow-y-auto w-full border border-gray-200 bg-white rounded-lg shadow-sm col-span-2">
|
||||||
|
{myIssues ? (
|
||||||
|
myIssues.length > 0 ? (
|
||||||
|
<table className="h-full w-full overflow-y-auto">
|
||||||
|
<thead className="border-b bg-gray-50 text-sm">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-3 py-4 text-left">
|
||||||
|
ISSUE
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-4 text-left">
|
||||||
|
KEY
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-4 text-left">
|
||||||
|
STATUS
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{myIssues?.map((issue, index) => (
|
||||||
|
<tr
|
||||||
|
className="border-t transition duration-300 ease-in-out hover:bg-gray-100 text-gray-900 gap-3 text-sm"
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-4 font-medium">
|
||||||
|
<Link href={`/projects/${issue.project}/issues/${issue.id}`}>
|
||||||
|
<a className="hover:text-theme duration-300">{issue.name}</a>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4">{issue.sequence_id}</td>
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<span
|
||||||
|
className="rounded px-2 py-1 text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
border: `2px solid ${issue.state_detail.color}`,
|
||||||
|
backgroundColor: `${issue.state_detail.color}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{issue.state_detail.name ?? "None"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="m-10">
|
||||||
|
<p className="text-gray-500 text-center">No Issues Found</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center p-10">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="py-6 px-6 min-w-[150px] flex-1 bg-white rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold mb-5">PROJECTS</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projects && activeWorkspace ? (
|
||||||
|
projects.length > 0 ? (
|
||||||
|
projects
|
||||||
|
.sort((a, b) => Date.parse(`${a.updated_at}`) - Date.parse(`${b.updated_at}`))
|
||||||
|
.map(
|
||||||
|
(project, index) =>
|
||||||
|
index < 3 && (
|
||||||
|
<Link href={`/projects/${project.id}/issues`} key={project.id}>
|
||||||
|
<a className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<ArrowRightIcon className="w-5" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No projects has been create for this workspace.</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Workspace;
|
211
pages/workspace/members.tsx
Normal file
211
pages/workspace/members.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
// next
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
// headless ui
|
||||||
|
import { Menu } from "@headlessui/react";
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
// services
|
||||||
|
import workspaceService from "lib/services/workspace.service";
|
||||||
|
// constants
|
||||||
|
import { WORKSPACE_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||||
|
// hoc
|
||||||
|
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||||
|
// layouts
|
||||||
|
import AdminLayout from "layouts/AdminLayout";
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
// components
|
||||||
|
import SendWorkspaceInvitationModal from "components/workspace/SendWorkspaceInvitationModal";
|
||||||
|
// ui
|
||||||
|
import { Spinner, Button } from "ui";
|
||||||
|
// icons
|
||||||
|
import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||||
|
import HeaderButton from "ui/HeaderButton";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
// types
|
||||||
|
|
||||||
|
const ROLE = {
|
||||||
|
5: "Guest",
|
||||||
|
10: "Viewer",
|
||||||
|
15: "Member",
|
||||||
|
20: "Admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspaceInvite: NextPage = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { activeWorkspace } = useUser();
|
||||||
|
|
||||||
|
const { data: workspaceMembers, mutate: mutateMembers } = useSWR<any[]>(
|
||||||
|
activeWorkspace ? WORKSPACE_MEMBERS : null,
|
||||||
|
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
|
||||||
|
);
|
||||||
|
const { data: workspaceInvitations, mutate: mutateInvitations } = useSWR<any[]>(
|
||||||
|
activeWorkspace ? WORKSPACE_INVITATIONS : null,
|
||||||
|
activeWorkspace ? () => workspaceService.workspaceInvitations(activeWorkspace.slug) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const members = [
|
||||||
|
...(workspaceMembers?.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
email: item.member?.email,
|
||||||
|
role: item.role,
|
||||||
|
status: true,
|
||||||
|
member: true,
|
||||||
|
})) || []),
|
||||||
|
...(workspaceInvitations?.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
email: item.email,
|
||||||
|
role: item.role,
|
||||||
|
status: item.accepted,
|
||||||
|
member: false,
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout
|
||||||
|
meta={{
|
||||||
|
title: "Plane - Workspace Invite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SendWorkspaceInvitationModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
workspace_slug={activeWorkspace?.slug as string}
|
||||||
|
members={members}
|
||||||
|
/>
|
||||||
|
{!workspaceMembers || !workspaceInvitations ? (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||||
|
<div className="w-full h-full flex flex-col space-y-5 pb-10">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title={`${activeWorkspace?.name} Members`} />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="flex items-center justify-between cursor-pointer w-full">
|
||||||
|
<h2 className="text-2xl font-medium">Invite Members</h2>
|
||||||
|
<HeaderButton Icon={PlusIcon} label="Add Member" action={() => setIsOpen(true)} />
|
||||||
|
</div>
|
||||||
|
{members && members.length === 0 ? null : (
|
||||||
|
<>
|
||||||
|
<table className="min-w-full table-fixed border border-gray-300 md:rounded-lg divide-y divide-gray-300">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6 w-10">
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{members?.map((member: any) => (
|
||||||
|
<tr key={member.id}>
|
||||||
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||||
|
{member.email ?? "No email has been added."}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||||
|
{ROLE[member.role as keyof typeof ROLE] ?? "None"}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
|
||||||
|
{member?.member ? (
|
||||||
|
"Accepted"
|
||||||
|
) : member.status ? (
|
||||||
|
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
|
||||||
|
Accepted
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||||
|
<Menu>
|
||||||
|
<Menu.Button>
|
||||||
|
<EllipsisHorizontalIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="inline text-gray-500"
|
||||||
|
/>
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute z-50 w-28 bg-white rounded border cursor-pointer -left-20 top-9">
|
||||||
|
<Menu.Item>
|
||||||
|
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||||
|
<button
|
||||||
|
className="w-full text-left py-2 pl-2"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||||
|
<button
|
||||||
|
className="w-full text-left py-2 pl-2"
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
member.member
|
||||||
|
? (await workspaceService.deleteWorkspaceMember(
|
||||||
|
activeWorkspace?.slug as string,
|
||||||
|
member.id
|
||||||
|
),
|
||||||
|
await mutateMembers((prevData) => [
|
||||||
|
...(prevData ?? [])?.filter(
|
||||||
|
(m: any) => m.id !== member.id
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
false)
|
||||||
|
: (await workspaceService.deleteWorkspaceInvitations(
|
||||||
|
activeWorkspace?.slug as string,
|
||||||
|
member.id
|
||||||
|
),
|
||||||
|
await mutateInvitations((prevData) => [
|
||||||
|
...(prevData ?? []).filter((m) => m.id !== member.id),
|
||||||
|
false,
|
||||||
|
]));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withAuthWrapper(WorkspaceInvite);
|
226
pages/workspace/settings.tsx
Normal file
226
pages/workspace/settings.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
// next
|
||||||
|
import Image from "next/image";
|
||||||
|
// react hook form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// react dropzone
|
||||||
|
import Dropzone from "react-dropzone";
|
||||||
|
// services
|
||||||
|
import workspaceService from "lib/services/workspace.service";
|
||||||
|
import fileServices from "lib/services/file.services";
|
||||||
|
// layouts
|
||||||
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useUser from "lib/hooks/useUser";
|
||||||
|
import useToast from "lib/hooks/useToast";
|
||||||
|
// components
|
||||||
|
import ConfirmWorkspaceDeletion from "components/workspace/ConfirmWorkspaceDeletion";
|
||||||
|
// ui
|
||||||
|
import { Spinner, Button, Input, Select } from "ui";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
|
// types
|
||||||
|
import type { IWorkspace } from "types";
|
||||||
|
|
||||||
|
const defaultValues: Partial<IWorkspace> = {
|
||||||
|
name: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspaceSettings = () => {
|
||||||
|
const { activeWorkspace, mutateWorkspaces } = useUser();
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const [image, setImage] = useState<File | null>(null);
|
||||||
|
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<IWorkspace>({
|
||||||
|
defaultValues: { ...defaultValues, ...activeWorkspace },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeWorkspace && reset({ ...activeWorkspace });
|
||||||
|
}, [activeWorkspace, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: IWorkspace) => {
|
||||||
|
if (!activeWorkspace) return;
|
||||||
|
const payload: Partial<IWorkspace> = {
|
||||||
|
logo: formData.logo,
|
||||||
|
name: formData.name,
|
||||||
|
company_size: formData.company_size,
|
||||||
|
};
|
||||||
|
await workspaceService
|
||||||
|
.updateWorkspace(activeWorkspace.slug, payload)
|
||||||
|
.then(async (res) => {
|
||||||
|
await mutateWorkspaces((workspaces) => {
|
||||||
|
return (workspaces ?? []).map((workspace) => {
|
||||||
|
if (workspace.slug === activeWorkspace.slug) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return workspace;
|
||||||
|
});
|
||||||
|
}, false);
|
||||||
|
setToastAlert({
|
||||||
|
title: "Success",
|
||||||
|
type: "success",
|
||||||
|
message: "Workspace updated successfully",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectLayout
|
||||||
|
meta={{
|
||||||
|
title: "Plane - Workspace Settings",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ConfirmWorkspaceDeletion isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||||
|
|
||||||
|
<div className="w-full h-full space-y-5">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<BreadcrumbItem title={`${activeWorkspace?.name} Settings`} />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<div className="w-full h-full flex flex-col space-y-3">
|
||||||
|
{activeWorkspace ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium leading-6 text-gray-900">General</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
This information will be displayed to every member of the workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<Dropzone
|
||||||
|
multiple={false}
|
||||||
|
accept={{
|
||||||
|
"image/*": [],
|
||||||
|
}}
|
||||||
|
onDrop={(files) => {
|
||||||
|
setImage(files[0]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ getRootProps, getInputProps }) => (
|
||||||
|
<div>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className="text-gray-500 mb-2">Logo</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-60 bg-blue-50" {...getRootProps()}>
|
||||||
|
{((watch("logo") && watch("logo") !== null && watch("logo") !== "") ||
|
||||||
|
(image && image !== null)) && (
|
||||||
|
<div className="relative flex mx-auto h-60">
|
||||||
|
<Image
|
||||||
|
src={image ? URL.createObjectURL(image) : watch("logo") ?? ""}
|
||||||
|
alt="Workspace Logo"
|
||||||
|
objectFit="cover"
|
||||||
|
layout="fill"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Max file size is 500kb. Supported file types are .jpg and .png.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (image === null) return;
|
||||||
|
setIsImageUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("asset", image);
|
||||||
|
formData.append("attributes", JSON.stringify({}));
|
||||||
|
fileServices
|
||||||
|
.uploadFile(formData)
|
||||||
|
.then((response) => {
|
||||||
|
const imageUrl = response.asset;
|
||||||
|
setValue("logo", imageUrl);
|
||||||
|
handleSubmit(onSubmit)();
|
||||||
|
setIsImageUploading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setIsImageUploading(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImageUploading ? "Uploading..." : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
label="Name"
|
||||||
|
placeholder="Name"
|
||||||
|
autoComplete="off"
|
||||||
|
register={register}
|
||||||
|
error={errors.name}
|
||||||
|
validations={{
|
||||||
|
required: "Name is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
id="company_size"
|
||||||
|
name="company_size"
|
||||||
|
label="How large is your company?"
|
||||||
|
options={[
|
||||||
|
{ value: 5, label: "5" },
|
||||||
|
{ value: 10, label: "10" },
|
||||||
|
{ value: 25, label: "25" },
|
||||||
|
{ value: 50, label: "50" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<Button onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Updating..." : "Update"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium leading-6 text-gray-900">Actions</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Once deleted, it will be gone forever. Please be certain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button theme="danger" onClick={() => setIsOpen(true)}>
|
||||||
|
Delete the workspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProjectLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceSettings;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user