mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
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