resolved merge conflict

This commit is contained in:
Dakshesh Jain 2022-12-17 19:31:25 +05:30
commit eb175ba823
64 changed files with 4347 additions and 3562 deletions

3
Procfile Normal file
View File

@ -0,0 +1,3 @@
web: node apps/app/server.js
backend_web: cd apiserver && gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: cd apiserver && python manage.py rqworker

View File

@ -58,6 +58,7 @@ from plane.api.views import (
ModuleIssueViewSet, ModuleIssueViewSet,
UserLastProjectWithWorkspaceEndpoint, UserLastProjectWithWorkspaceEndpoint,
UserWorkSpaceIssues, UserWorkSpaceIssues,
ProjectMemberUserEndpoint,
) )
from plane.api.views.project import AddTeamToProjectEndpoint from plane.api.views.project import AddTeamToProjectEndpoint
@ -320,6 +321,12 @@ urlpatterns = [
ProjectUserViewsEndpoint.as_view(), ProjectUserViewsEndpoint.as_view(),
name="project-view", name="project-view",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/me/",
ProjectMemberUserEndpoint.as_view(),
name="project-view",
),
# End Projects
# States # States
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/", "workspaces/<str:slug>/projects/<uuid:project_id>/states/",

View File

@ -10,6 +10,7 @@ from .project import (
AddMemberToProjectEndpoint, AddMemberToProjectEndpoint,
ProjectJoinEndpoint, ProjectJoinEndpoint,
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
) )
from .people import ( from .people import (
PeopleEndpoint, PeopleEndpoint,

View File

@ -625,3 +625,27 @@ class ProjectUserViewsEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
project_member = ProjectMember.objects.get(
project=project_id, workpsace__slug=slug, member=request.user
)
serializer = ProjectMemberSerializer(project_member)
return Response(serializer.data, status=status.HTTP_200_OK)
except ProjectMember.DoesNotExist:
return Response(
{"error": "User not a member of the project"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

View File

@ -5,16 +5,24 @@
"logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4", "logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4",
"website": "https://plane.so/", "website": "https://plane.so/",
"success_url": "/", "success_url": "/",
"stack": "container", "stack": "heroku-22",
"keywords": ["plane", "project management", "django", "next"], "keywords": ["plane", "project management", "django", "next"],
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"], "addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
},
{
"url": "https://github.com/heroku/heroku-buildpack-nodejs#v176"
}
],
"env": { "env": {
"EMAIL_HOST": { "EMAIL_HOST": {
"description": "Email host to send emails from", "description": "Email host to send emails from",
"value": "" "value": ""
}, },
"EMAIL_HOST_USER": { "EMAIL_HOST_USER": {
"description" : "Email host to send emails from", "description": "Email host to send emails from",
"value": "" "value": ""
}, },
"EMAIL_HOST_PASSWORD": { "EMAIL_HOST_PASSWORD": {
@ -22,7 +30,7 @@
"value": "" "value": ""
}, },
"AWS_REGION": { "AWS_REGION": {
"description" : "AWS Region to use for S3", "description": "AWS Region to use for S3",
"value": "false" "value": "false"
}, },
"AWS_ACCESS_KEY_ID": { "AWS_ACCESS_KEY_ID": {
@ -66,4 +74,4 @@
"value": "" "value": ""
} }
} }
} }

View File

@ -24,7 +24,7 @@ import {
import ShortcutsModal from "components/command-palette/shortcuts"; import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/create-project-modal"; import CreateProjectModal from "components/project/create-project-modal";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui // ui
import { Button } from "ui"; import { Button } from "ui";
// types // types
@ -33,6 +33,7 @@ import { IIssue, IssueResponse } from "types";
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// constants // constants
import { classNames, copyTextToClipboard } from "constants/common"; import { classNames, copyTextToClipboard } from "constants/common";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
type FormInput = { type FormInput = {
issue_ids: string[]; issue_ids: string[];
@ -47,6 +48,7 @@ const CommandPalette: React.FC = () => {
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false); const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const [isCreateModuleModalOpen, setisCreateModuleModalOpen] = useState(false);
const { activeWorkspace, activeProject, issues } = useUser(); const { activeWorkspace, activeProject, issues } = useUser();
@ -109,6 +111,9 @@ const CommandPalette: React.FC = () => {
} else if ((e.ctrlKey || e.metaKey) && e.key === "q") { } else if ((e.ctrlKey || e.metaKey) && e.key === "q") {
e.preventDefault(); e.preventDefault();
setIsCreateCycleModalOpen(true); setIsCreateCycleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
e.preventDefault();
setisCreateModuleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") { } else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
e.preventDefault(); e.preventDefault();
@ -184,11 +189,18 @@ const CommandPalette: React.FC = () => {
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} /> <ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} /> <CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
{activeProject && ( {activeProject && (
<CreateUpdateCycleModal <>
isOpen={isCreateCycleModalOpen} <CreateUpdateCycleModal
setIsOpen={setIsCreateCycleModalOpen} isOpen={isCreateCycleModalOpen}
projectId={activeProject.id} setIsOpen={setIsCreateCycleModalOpen}
/> projectId={activeProject.id}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
setIsOpen={setisCreateModuleModalOpen}
projectId={activeProject.id}
/>
</>
)} )}
<CreateUpdateIssuesModal <CreateUpdateIssuesModal
isOpen={isIssueModalOpen} isOpen={isIssueModalOpen}

View File

@ -1,662 +0,0 @@
// react
import React, { useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
// swr
import useSWR from "swr";
// services
import cycleServices from "lib/services/cycles.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Spinner } from "ui";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
CalendarDaysIcon,
PlusIcon,
EllipsisHorizontalIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import {
CycleIssueResponse,
ICycle,
IIssue,
IWorkspaceMember,
NestedKeyOf,
Properties,
} from "types";
// constants
import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys";
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import { Menu, Transition } from "@headlessui/react";
import workspaceService from "lib/services/workspace.service";
type Props = {
properties: Properties;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor?: string;
openCreateIssueModal: (
sprintId: string,
issue?: IIssue,
actionType?: "create" | "edit" | "delete"
) => void;
openIssuesListModal: (cycleId: string) => void;
removeIssueFromCycle: (cycleId: string, bridgeId: string) => void;
};
const SingleCycleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromCycle,
}) => {
// Collapse/Expand
const [show, setState] = useState(true);
const { activeWorkspace, activeProject } = useUser();
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
}`}
>
<div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
<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`,
}}
>
<span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
style={{
backgroundColor: Boolean(bgColor) ? bgColor : undefined,
}}
/>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
</div>
</div>
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
!show ? "hidden" : "block"
}`}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<div key={childIssue.id} className={`border rounded bg-white shadow-sm`}>
<div className="group/card relative p-2 select-none">
<div className="opacity-0 group-hover/card:opacity-100 absolute top-1 right-1">
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded text-red-500 hover:bg-red-50 duration-300 outline-none"
// onClick={() => handleDeleteIssue(childIssue.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}>
<a>
{properties.key && (
<div className="text-xs font-medium text-gray-500 mb-2">
{activeProject?.identifier}-{childIssue.sequence_id}
</div>
)}
<h5
className="group-hover:text-theme text-sm break-all mb-3"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{childIssue.name}
</h5>
</a>
</Link>
<div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group flex-shrink-0 flex items-center gap-1 rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
childIssue.priority === "urgent"
? "bg-red-100 text-red-600"
: childIssue.priority === "high"
? "bg-orange-100 text-orange-500"
: childIssue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: childIssue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(childIssue.priority ?? "")} */}
{childIssue.priority ?? "None"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
childIssue.priority === "urgent"
? "text-red-600"
: childIssue.priority === "high"
? "text-orange-500"
: childIssue.priority === "medium"
? "text-yellow-500"
: childIssue.priority === "low"
? "text-green-500"
: ""
}`}
>
{childIssue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: childIssue.state_detail.color }}
></span>
{addSpaceIfCamelCase(childIssue.state_detail.name)}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{childIssue.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.start_date
? renderShortNumericDateFormat(childIssue.start_date)
: "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>{renderShortNumericDateFormat(childIssue.start_date ?? "")}</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`group flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
childIssue.target_date === null
? ""
: childIssue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(childIssue.target_date) <= 3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.target_date
? renderShortNumericDateFormat(childIssue.target_date)
: "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Target date</h5>
<div>{renderShortNumericDateFormat(childIssue.target_date ?? "")}</div>
<div>
{childIssue.target_date &&
(childIssue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: findHowManyDaysLeft(childIssue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
{properties.assignee && (
<div className="group flex items-center gap-1 text-xs">
{childIssue.assignee_details?.length > 0 ? (
childIssue.assignee_details?.map((assignee, 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-700 text-white border-2 border-white grid place-items-center rounded-full`}
>
{assignee.first_name.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{childIssue.assignee_details?.length > 0
? childIssue.assignee_details
.map((assignee) => assignee.first_name)
.join(", ")
: "No one"}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
<button
type="button"
className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none"
>
<PlusIcon className="h-3 w-3 mr-1" />
Create
</button>
</div>
</div>
</div>
);
// return (
// <div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
// <div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
// <div
// className={`flex justify-between p-3 pb-0 ${
// !show ? "flex-col bg-gray-50 rounded-md border" : ""
// }`}
// >
// <div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
// <div
// className={`flex items-center gap-x-1 rounded-md cursor-pointer ${
// !show ? "py-2 mb-2 flex-col gap-y-2" : ""
// }`}
// >
// <h2
// className={`text-[0.9rem] font-medium capitalize`}
// style={{
// writingMode: !show ? "vertical-rl" : "horizontal-tb",
// }}
// >
// {cycle.name}
// </h2>
// <span className="text-gray-500 text-sm ml-0.5">{cycleIssues?.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);
// }}
// >
// {show ? (
// <ArrowsPointingInIcon className="h-4 w-4" />
// ) : (
// <ArrowsPointingOutIcon className="h-4 w-4" />
// )}
// </button>
// <Menu as="div" className="relative inline-block">
// <Menu.Button className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none">
// <EllipsisHorizontalIcon className="h-4 w-4" />
// </Menu.Button>
// <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 right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
// <div className="p-1">
// <Menu.Item as="div">
// {(active) => (
// <button
// type="button"
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// onClick={() => openCreateIssueModal(cycle.id)}
// >
// Create new
// </button>
// )}
// </Menu.Item>
// <Menu.Item as="div">
// {(active) => (
// <button
// type="button"
// className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
// onClick={() => openIssuesListModal(cycle.id)}
// >
// Add an existing issue
// </button>
// )}
// </Menu.Item>
// </div>
// </Menu.Items>
// </Transition>
// </Menu>
// </div>
// </div>
// <div
// className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
// !show ? "hidden" : "block"
// }`}
// >
// {cycleIssues ? (
// cycleIssues.map((issue, index: number) => (
// <div key={childIssue.id} className="border rounded bg-white shadow-sm">
// <div className="group/card relative p-2 select-none">
// <div className="opacity-0 group-hover/card:opacity-100 absolute top-1 right-1">
// <Menu as="div" className="relative">
// <Menu.Button
// as="button"
// className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none`}
// >
// <EllipsisHorizontalIcon className="h-4 w-4" />
// </Menu.Button>
// <Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
// <Menu.Item>
// <div className="hover:bg-gray-100 border-b last:border-0">
// <button
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// type="button"
// onClick={() => removeIssueFromCycle(issue.cycle, issue.id)}
// >
// Remove from cycle
// </button>
// </div>
// </Menu.Item>
// <Menu.Item>
// <div className="hover:bg-gray-100 border-b last:border-0">
// <button
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// type="button"
// onClick={() =>
// openCreateIssueModal(cycle.id, childIssue, "delete")
// }
// >
// Delete permanently
// </button>
// </div>
// </Menu.Item>
// </Menu.Items>
// </Menu>
// </div>
// <Link
// href={`/projects/${childIssue.project}/issues/${childIssue.id}`}
// >
// <a>
// {properties.key && (
// <div className="text-xs font-medium text-gray-500 mb-2">
// {activeProject?.identifier}-{childIssue.sequence_id}
// </div>
// )}
// <h5
// className="group-hover:text-theme text-sm break-all mb-3"
// style={{ lineClamp: 3, WebkitLineClamp: 3 }}
// >
// {childIssue.name}
// </h5>
// </a>
// </Link>
// <div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
// {properties.priority && (
// <div
// className={`group flex-shrink-0 flex items-center gap-1 rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
// childIssue.priority === "urgent"
// ? "bg-red-100 text-red-600"
// : childIssue.priority === "high"
// ? "bg-orange-100 text-orange-500"
// : childIssue.priority === "medium"
// ? "bg-yellow-100 text-yellow-500"
// : childIssue.priority === "low"
// ? "bg-green-100 text-green-500"
// : "bg-gray-100"
// }`}
// >
// {/* {getPriorityIcon(childIssue.priority ?? "")} */}
// {childIssue.priority ?? "None"}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1 text-gray-900">Priority</h5>
// <div
// className={`capitalize ${
// childIssue.priority === "urgent"
// ? "text-red-600"
// : childIssue.priority === "high"
// ? "text-orange-500"
// : childIssue.priority === "medium"
// ? "text-yellow-500"
// : childIssue.priority === "low"
// ? "text-green-500"
// : ""
// }`}
// >
// {childIssue.priority ?? "None"}
// </div>
// </div>
// </div>
// )}
// {properties.state && (
// <div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
// <span
// className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
// style={{ backgroundColor: childIssue.state_detail.color }}
// ></span>
// {addSpaceIfCamelCase(childIssue.state_detail.name)}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">State</h5>
// <div>{childIssue.state_detail.name}</div>
// </div>
// </div>
// )}
// {properties.start_date && (
// <div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
// <CalendarDaysIcon className="h-4 w-4" />
// {childIssue.start_date
// ? renderShortNumericDateFormat(childIssue.start_date)
// : "N/A"}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">Started at</h5>
// <div>
// {renderShortNumericDateFormat(childIssue.start_date ?? "")}
// </div>
// </div>
// </div>
// )}
// {properties.target_date && (
// <div
// className={`group flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
// childIssue.target_date === null
// ? ""
// : childIssue.target_date < new Date().toISOString()
// ? "text-red-600"
// : findHowManyDaysLeft(childIssue.target_date) <= 3 &&
// "text-orange-400"
// }`}
// >
// <CalendarDaysIcon className="h-4 w-4" />
// {childIssue.target_date
// ? renderShortNumericDateFormat(childIssue.target_date)
// : "N/A"}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1 text-gray-900">Target date</h5>
// <div>
// {renderShortNumericDateFormat(childIssue.target_date ?? "")}
// </div>
// <div>
// {childIssue.target_date &&
// (childIssue.target_date < new Date().toISOString()
// ? `Target date has passed by ${findHowManyDaysLeft(
// childIssue.target_date
// )} days`
// : findHowManyDaysLeft(childIssue.target_date) <= 3
// ? `Target date is in ${findHowManyDaysLeft(
// childIssue.target_date
// )} days`
// : "Target date")}
// </div>
// </div>
// </div>
// )}
// {properties.assignee && (
// <div className="group flex items-center gap-1 text-xs">
// {childIssue.assignee_details?.length > 0 ? (
// childIssue.assignee_details?.map((assignee, 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-700 text-white border-2 border-white grid place-items-center rounded-full`}
// >
// {assignee.first_name.charAt(0)}
// </div>
// )}
// </div>
// ))
// ) : (
// <div className="h-5 w-5 border-2 bg-white border-white rounded-full">
// <Image
// src={User}
// height="100%"
// width="100%"
// className="rounded-full"
// alt="No user"
// />
// </div>
// )}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">Assigned to</h5>
// <div>
// {childIssue.assignee_details?.length > 0
// ? childIssue.assignee_details
// .map((assignee) => assignee.first_name)
// .join(", ")
// : "No one"}
// </div>
// </div>
// </div>
// )}
// </div>
// </div>
// </div>
// ))
// ) : (
// <div className="w-full h-full flex justify-center items-center">
// <Spinner />
// </div>
// )}
// <button
// type="button"
// className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none"
// onClick={() => openCreateIssueModal(cycle.id)}
// >
// <PlusIcon className="h-3 w-3 mr-1" />
// Create
// </button>
// </div>
// </div>
// </div>
// );
};
export default SingleCycleBoard;

View File

@ -1,714 +0,0 @@
// react
import React from "react";
// next
import Link from "next/link";
// swr
import useSWR from "swr";
// headless ui
import { Disclosure, Transition, Menu } from "@headlessui/react";
// services
import cycleServices from "lib/services/cycles.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Spinner } from "ui";
// icons
import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, SelectSprintType } from "types";
// fetch keys
import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import workspaceService from "lib/services/workspace.service";
type Props = {
groupedByIssues: {
[key: string]: IIssue[];
};
properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null;
openCreateIssueModal: (
sprintId: string,
issue?: IIssue,
actionType?: "create" | "edit" | "delete"
) => void;
openIssuesListModal: (cycleId: string) => void;
removeIssueFromCycle: (cycleId: string, bridgeId: string) => void;
};
const CyclesListView: React.FC<Props> = ({
groupedByIssues,
selectedGroup,
openCreateIssueModal,
openIssuesListModal,
properties,
removeIssueFromCycle,
}) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="bg-white rounded-lg">
<div className="bg-gray-100 px-4 py-3 rounded-t-lg">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium leading-5 capitalize">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-gray-500 text-sm">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</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="divide-y-2">
{groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue: IIssue) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find(
(p) => p.member.id === assignee
)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<div
key={issue.id}
className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/projects/${activeProject?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
)}
<span className="">{issue.name}</span>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div>
</a>
</Link>
</div>
<div className="flex-shrink-0 flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group relative flex-shrink-0 flex items-center gap-1 text-xs rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(issue.priority ?? "")} */}
{issue.priority ?? "None"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
></span>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date
? renderShortNumericDateFormat(issue.start_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(issue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 &&
"text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Target date
</h5>
<div>
{renderShortNumericDateFormat(issue.target_date ?? "")}
</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
<Menu as="div" className="relative">
<Menu.Button
as="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none`}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<Menu.Item>
<button
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
type="button"
// onClick={() =>
// openCreateIssueModal(cycle.id, issue, "edit")
// }
>
Edit
</button>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
type="button"
// onClick={() =>
// removeIssueFromCycle(issue.cycle, issue.id)
// }
>
Remove from cycle
</button>
</div>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
type="button"
// onClick={() =>
// openCreateIssueModal(cycle.id, issue, "delete")
// }
>
Delete permanently
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
);
})
) : (
<p className="text-sm px-4 py-3 text-gray-500">No issues.</p>
)
) : (
<div className="h-full w-full flex items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
<button
type="button"
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium"
// onClick={() => {
// setIsCreateIssuesModalOpen(true);
// if (selectedGroup !== null) {
// const stateId =
// selectedGroup === "state_detail.name"
// ? states?.find((s) => s.name === singleGroup)?.id ?? null
// : null;
// setPreloadedData({
// state: stateId !== null ? stateId : undefined,
// [selectedGroup]: singleGroup,
// actionType: "createIssue",
// });
// }
// }}
>
<PlusIcon className="h-3 w-3" />
Add issue
</button>
</div>
</div>
)}
</Disclosure>
))}
</div>
);
// return (
// <>
// <Disclosure as="div" defaultOpen>
// {({ open }) => (
// <div className="bg-white rounded-lg">
// <div className="flex justify-between items-center bg-gray-100 px-4 py-3 rounded-t-lg">
// <div className="flex items-center gap-2">
// <Disclosure.Button>
// <ChevronDownIcon
// className={`h-4 w-4 text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
// />
// </Disclosure.Button>
// <Link href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}>
// <a className="flex items-center gap-2">
// <h2 className="font-medium leading-5">{cycle.name}</h2>
// <p className="flex gap-2 text-xs text-gray-500">
// <span>
// {cycle.status === "started"
// ? cycle.start_date
// ? `${renderShortNumericDateFormat(cycle.start_date)} - `
// : ""
// : cycle.status}
// </span>
// <span>
// {cycle.end_date ? renderShortNumericDateFormat(cycle.end_date) : ""}
// </span>
// </p>
// </a>
// </Link>
// <p className="text-gray-500 text-sm ml-0.5">{cycleIssues?.length}</p>
// </div>
// <Menu as="div" className="relative inline-block">
// <Menu.Button
// as="button"
// className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none`}
// >
// <EllipsisHorizontalIcon className="h-4 w-4" />
// </Menu.Button>
// <Menu.Items className="absolute origin-top-right right-0 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
// <Menu.Item>
// <button
// type="button"
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// onClick={() => selectSprint({ ...cycle, actionType: "edit" })}
// >
// Edit
// </button>
// </Menu.Item>
// <Menu.Item>
// <button
// type="button"
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// onClick={() => selectSprint({ ...cycle, actionType: "delete" })}
// >
// Delete
// </button>
// </Menu.Item>
// </Menu.Items>
// </Menu>
// </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>
// <StrictModeDroppable droppableId={cycle.id}>
// {(provided) => (
// <div
// ref={provided.innerRef}
// {...provided.droppableProps}
// className="divide-y-2"
// >
// {cycleIssues ? (
// cycleIssues.length > 0 ? (
// cycleIssues.map((issue, index) => (
// <Draggable
// key={issue.id}
// draggableId={`${issue.id},${issue.issue}`} // bridge id, issue id
// index={index}
// >
// {(provided, snapshot) => (
// <div
// className={`px-2 py-3 text-sm rounded flex justify-between items-center gap-2 ${
// snapshot.isDragging
// ? "bg-gray-100 shadow-lg border border-theme"
// : ""
// }`}
// ref={provided.innerRef}
// {...provided.draggableProps}
// >
// <div className="flex items-center gap-2">
// <button
// type="button"
// className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 rotate-90 outline-none`}
// {...provided.dragHandleProps}
// >
// <EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
// <EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
// </button>
// <span
// className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
// style={{
// backgroundColor: issue?.state_detail?.color,
// }}
// />
// <Link
// href={`/projects/${projectId}/issues/${issue.id}`}
// >
// <a className="flex items-center gap-2">
// {properties.key && (
// <span className="flex-shrink-0 text-xs text-gray-500">
// {activeProject?.identifier}-
// {issue.sequence_id}
// </span>
// )}
// <span>{issue.name}</span>
// </a>
// </Link>
// </div>
// <div className="flex items-center gap-2">
// {properties.priority && (
// <div
// className={`group relative flex-shrink-0 flex items-center gap-1 text-xs rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
// issue.priority === "urgent"
// ? "bg-red-100 text-red-600"
// : issue.priority === "high"
// ? "bg-orange-100 text-orange-500"
// : issue.priority === "medium"
// ? "bg-yellow-100 text-yellow-500"
// : issue.priority === "low"
// ? "bg-green-100 text-green-500"
// : "bg-gray-100"
// }`}
// >
// {/* {getPriorityIcon(issue.priority ?? "")} */}
// {issue.priority ?? "None"}
// <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1 text-gray-900">
// Priority
// </h5>
// <div
// className={`capitalize ${
// issue.priority === "urgent"
// ? "text-red-600"
// : issue.priority === "high"
// ? "text-orange-500"
// : issue.priority === "medium"
// ? "text-yellow-500"
// : issue.priority === "low"
// ? "text-green-500"
// : ""
// }`}
// >
// {issue.priority ?? "None"}
// </div>
// </div>
// </div>
// )}
// {properties.state && (
// <div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
// <span
// className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
// style={{
// backgroundColor:
// issue?.state_detail?.color,
// }}
// ></span>
// {addSpaceIfCamelCase(
// issue?.state_detail.name
// )}
// <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">State</h5>
// <div>{issue?.state_detail.name}</div>
// </div>
// </div>
// )}
// {properties.start_date && (
// <div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
// <CalendarDaysIcon className="h-4 w-4" />
// {issue.start_date
// ? renderShortNumericDateFormat(
// issue.start_date
// )
// : "N/A"}
// <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">Started at</h5>
// <div>
// {renderShortNumericDateFormat(
// issue.start_date ?? ""
// )}
// </div>
// </div>
// </div>
// )}
// {properties.target_date && (
// <div
// className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
// issue.target_date === null
// ? ""
// : issue.target_date <
// new Date().toISOString()
// ? "text-red-600"
// : findHowManyDaysLeft(
// issue.target_date
// ) <= 3 && "text-orange-400"
// }`}
// >
// <CalendarDaysIcon className="h-4 w-4" />
// {issue.target_date
// ? renderShortNumericDateFormat(
// issue.target_date
// )
// : "N/A"}
// <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1 text-gray-900">
// Target date
// </h5>
// <div>
// {renderShortNumericDateFormat(
// issue.target_date ?? ""
// )}
// </div>
// <div>
// {issue.target_date &&
// (issue.target_date <
// new Date().toISOString()
// ? `Target date has passed by ${findHowManyDaysLeft(
// issue.target_date
// )} days`
// : findHowManyDaysLeft(
// issue.target_date
// ) <= 3
// ? `Target date is in ${findHowManyDaysLeft(
// issue.target_date
// )} days`
// : "Target date")}
// </div>
// </div>
// </div>
// )}
// <Menu as="div" className="relative">
// <Menu.Button
// as="button"
// className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none`}
// >
// <EllipsisHorizontalIcon className="h-4 w-4" />
// </Menu.Button>
// <Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
// <Menu.Item>
// <button
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// type="button"
// onClick={() =>
// openCreateIssueModal(
// cycle.id,
// issue,
// "edit"
// )
// }
// >
// Edit
// </button>
// </Menu.Item>
// <Menu.Item>
// <div className="hover:bg-gray-100 border-b last:border-0">
// <button
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// type="button"
// onClick={() =>
// removeIssueFromCycle(issue.cycle, issue.id)
// }
// >
// Remove from cycle
// </button>
// </div>
// </Menu.Item>
// <Menu.Item>
// <div className="hover:bg-gray-100 border-b last:border-0">
// <button
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// type="button"
// onClick={() =>
// openCreateIssueModal(
// cycle.id,
// issue,
// "delete"
// )
// }
// >
// Delete permanently
// </button>
// </div>
// </Menu.Item>
// </Menu.Items>
// </Menu>
// </div>
// </div>
// )}
// </Draggable>
// ))
// ) : (
// <p className="text-sm px-4 py-3 text-gray-500">
// This cycle has no issue.
// </p>
// )
// ) : (
// <div className="w-full h-full flex items-center justify-center">
// <Spinner />
// </div>
// )}
// {provided.placeholder}
// </div>
// )}
// </StrictModeDroppable>
// </Disclosure.Panel>
// </Transition>
// <div className="p-3">
// <Menu as="div" className="relative inline-block">
// <Menu.Button className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium">
// <PlusIcon className="h-3 w-3" />
// Add issue
// </Menu.Button>
// <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-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
// <div className="p-1">
// <Menu.Item as="div">
// {(active) => (
// <button
// type="button"
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// onClick={() => openCreateIssueModal(cycle.id)}
// >
// Create new
// </button>
// )}
// </Menu.Item>
// <Menu.Item as="div">
// {(active) => (
// <button
// type="button"
// className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
// onClick={() => openIssuesListModal(cycle.id)}
// >
// Add an existing issue
// </button>
// )}
// </Menu.Item>
// </div>
// </Menu.Items>
// </Transition>
// </Menu>
// </div>
// </div>
// )}
// </Disclosure>
// </>
// );
};
export default CyclesListView;

View File

@ -1,5 +1,5 @@
// components // components
import SingleBoard from "components/project/cycles/BoardView/single-board"; import SingleBoard from "components/project/cycles/board-view/single-board";
// ui // ui
import { Spinner } from "ui"; import { Spinner } from "ui";
// types // types
@ -13,13 +13,9 @@ type Props = {
properties: Properties; properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null; selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined; members: IProjectMember[] | undefined;
openCreateIssueModal: ( openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
sprintId: string, openIssuesListModal: () => void;
issue?: IIssue, removeIssueFromCycle: (bridgeId: string) => void;
actionType?: "create" | "edit" | "delete"
) => void;
openIssuesListModal: (cycleId: string) => void;
removeIssueFromCycle: (cycleId: string, bridgeId: string) => void;
}; };
const CyclesBoardView: React.FC<Props> = ({ const CyclesBoardView: React.FC<Props> = ({

View File

@ -0,0 +1,371 @@
// react
import React, { useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
// swr
import useSWR from "swr";
// services
import cycleServices from "lib/services/cycles.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Spinner } from "ui";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
CalendarDaysIcon,
PlusIcon,
EllipsisHorizontalIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import {
CycleIssueResponse,
ICycle,
IIssue,
IWorkspaceMember,
NestedKeyOf,
Properties,
} from "types";
// constants
import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys";
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import { Menu, Transition } from "@headlessui/react";
import workspaceService from "lib/services/workspace.service";
type Props = {
properties: Properties;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor?: string;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void;
};
const SingleCycleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromCycle,
}) => {
// Collapse/Expand
const [show, setState] = useState(true);
const { activeWorkspace, activeProject } = useUser();
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
}`}
>
<div
className={`w-full flex justify-between items-center ${
!show ? "flex-col gap-2" : "gap-1"
}`}
>
<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`,
}}
>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
<Menu as="div" className="relative inline-block">
<Menu.Button className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none">
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<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 right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10 text-xs">
<div className="py-1">
<Menu.Item as="div">
{(active) => (
<button
type="button"
className="w-full text-left p-2 text-gray-900 hover:bg-indigo-50 whitespace-nowrap"
onClick={() => openCreateIssueModal()}
>
Create new
</button>
)}
</Menu.Item>
<Menu.Item as="div">
{(active) => (
<button
type="button"
className="w-full text-left p-2 text-gray-900 hover:bg-indigo-50 whitespace-nowrap"
onClick={() => openIssuesListModal()}
>
Add an existing issue
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
!show ? "hidden" : "block"
}`}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<div key={childIssue.id} className={`border rounded bg-white shadow-sm`}>
<div className="relative p-2 select-none">
<Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}>
<a>
{properties.key && (
<div className="text-xs font-medium text-gray-500 mb-2">
{activeProject?.identifier}-{childIssue.sequence_id}
</div>
)}
<h5
className="group-hover:text-theme text-sm break-all mb-3"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{childIssue.name}
</h5>
</a>
</Link>
<div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group flex-shrink-0 flex items-center gap-1 rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
childIssue.priority === "urgent"
? "bg-red-100 text-red-600"
: childIssue.priority === "high"
? "bg-orange-100 text-orange-500"
: childIssue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: childIssue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(childIssue.priority ?? "")} */}
{childIssue.priority ?? "None"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
childIssue.priority === "urgent"
? "text-red-600"
: childIssue.priority === "high"
? "text-orange-500"
: childIssue.priority === "medium"
? "text-yellow-500"
: childIssue.priority === "low"
? "text-green-500"
: ""
}`}
>
{childIssue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: childIssue.state_detail.color }}
></span>
{addSpaceIfCamelCase(childIssue.state_detail.name)}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{childIssue.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.start_date
? renderShortNumericDateFormat(childIssue.start_date)
: "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>{renderShortNumericDateFormat(childIssue.start_date ?? "")}</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`group flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
childIssue.target_date === null
? ""
: childIssue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(childIssue.target_date) <= 3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.target_date
? renderShortNumericDateFormat(childIssue.target_date)
: "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Target date</h5>
<div>{renderShortNumericDateFormat(childIssue.target_date ?? "")}</div>
<div>
{childIssue.target_date &&
(childIssue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: findHowManyDaysLeft(childIssue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
{properties.assignee && (
<div className="group flex items-center gap-1 text-xs">
{childIssue.assignee_details?.length > 0 ? (
childIssue.assignee_details?.map((assignee, 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-700 text-white border-2 border-white grid place-items-center rounded-full`}
>
{assignee.first_name.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{childIssue.assignee_details?.length > 0
? childIssue.assignee_details
.map((assignee) => assignee.first_name)
.join(", ")
: "No one"}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
<button
type="button"
className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none"
>
<PlusIcon className="h-3 w-3 mr-1" />
Create
</button>
</div>
</div>
</div>
);
};
export default SingleCycleBoard;

View File

@ -145,37 +145,37 @@ const CycleIssuesListModal: React.FC<Props> = ({
)} )}
<ul className="text-sm text-gray-700"> <ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => { {filteredIssues.map((issue) => {
// if (issue.cycle !== cycleId) if (!issue.issue_cycle)
return ( return (
<Combobox.Option <Combobox.Option
key={issue.id} key={issue.id}
as="label" as="label"
htmlFor={`issue-${issue.id}`} htmlFor={`issue-${issue.id}`}
value={issue.id} value={issue.id}
className={({ active }) => className={({ active }) =>
classNames( classNames(
"flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2", "flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : "" active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
) )
} }
> >
{({ selected }) => ( {({ selected }) => (
<> <>
<input type="checkbox" checked={selected} readOnly /> <input type="checkbox" checked={selected} readOnly />
<span <span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full" className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{ style={{
backgroundColor: issue.state_detail.color, backgroundColor: issue.state_detail.color,
}} }}
/> />
<span className="flex-shrink-0 text-xs text-gray-500"> <span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id} {activeProject?.identifier}-{issue.sequence_id}
</span> </span>
{issue.name} {issue.name}
</> </>
)} )}
</Combobox.Option> </Combobox.Option>
); );
})} })}
</ul> </ul>
</li> </li>

View File

@ -0,0 +1,320 @@
// react
import React from "react";
// next
import Link from "next/link";
// swr
import useSWR from "swr";
// headless ui
import { Disclosure, Transition, Menu } from "@headlessui/react";
// services
import cycleServices from "lib/services/cycles.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { CustomMenu, Spinner } from "ui";
// icons
import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, SelectSprintType } from "types";
// fetch keys
import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import workspaceService from "lib/services/workspace.service";
type Props = {
groupedByIssues: {
[key: string]: IIssue[];
};
properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: (cycleId: string) => void;
removeIssueFromCycle: (bridgeId: string) => void;
};
const CyclesListView: React.FC<Props> = ({
groupedByIssues,
selectedGroup,
openCreateIssueModal,
openIssuesListModal,
properties,
removeIssueFromCycle,
}) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="bg-white rounded-lg">
<div className="bg-gray-100 px-4 py-3 rounded-t-lg">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium leading-5 capitalize">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-gray-500 text-sm">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</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="divide-y-2">
{groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue: IIssue) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find(
(p) => p.member.id === assignee
)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<div
key={issue.id}
className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/projects/${activeProject?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
)}
<span className="">{issue.name}</span>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div>
</a>
</Link>
</div>
<div className="flex-shrink-0 flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group relative flex-shrink-0 flex items-center gap-1 text-xs rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(issue.priority ?? "")} */}
{issue.priority ?? "None"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
></span>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date
? renderShortNumericDateFormat(issue.start_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(issue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 &&
"text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Target date
</h5>
<div>
{renderShortNumericDateFormat(issue.target_date ?? "")}
</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem
onClick={() => openCreateIssueModal(issue, "edit")}
>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => removeIssueFromCycle(issue.bridge ?? "")}
>
Remove from cycle
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>Delete permanently</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
);
})
) : (
<p className="text-sm px-4 py-3 text-gray-500">No issues.</p>
)
) : (
<div className="h-full w-full flex items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
<button
type="button"
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium"
// onClick={() => {
// setIsCreateIssuesModalOpen(true);
// if (selectedGroup !== null) {
// const stateId =
// selectedGroup === "state_detail.name"
// ? states?.find((s) => s.name === singleGroup)?.id ?? null
// : null;
// setPreloadedData({
// state: stateId !== null ? stateId : undefined,
// [selectedGroup]: singleGroup,
// actionType: "createIssue",
// });
// }
// }}
>
<PlusIcon className="h-3 w-3" />
Add issue
</button>
</div>
</div>
)}
</Disclosure>
))}
</div>
// <button
// type="button"
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// onClick={() => selectSprint({ ...cycle, actionType: "edit" })}
// >
// Edit
// </button>
// </Menu.Item>
// <Menu.Item>
// <button
// type="button"
// className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
// onClick={() => selectSprint({ ...cycle, actionType: "delete" })}
// >
// Delete
// </button>
// </Menu.Item
);
};
export default CyclesListView;

View File

@ -0,0 +1,56 @@
// react
import { useState } from "react";
// components
import SingleStat from "components/project/cycles/stats-view/single-stat";
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
// types
import { ICycle, SelectSprintType } from "types";
type Props = {
cycles: ICycle[];
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectSprintType>>;
};
const CycleStatsView: React.FC<Props> = ({
cycles,
setCreateUpdateCycleModal,
setSelectedCycle,
}) => {
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectSprintType>();
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
setCycleDeleteModal(true);
};
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycle({ ...cycle, actionType: "edit" });
setCreateUpdateCycleModal(true);
};
return (
<>
<ConfirmCycleDeletion
isOpen={
cycleDeleteModal &&
!!selectedCycleForDelete &&
selectedCycleForDelete.actionType === "delete"
}
setIsOpen={setCycleDeleteModal}
data={selectedCycleForDelete}
/>
{cycles.map((cycle) => (
<SingleStat
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
/>
))}
</>
);
};
export default CycleStatsView;

View File

@ -0,0 +1,177 @@
// react
import React, { useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
// swr
import useSWR from "swr";
// services
import cyclesService from "lib/services/cycles.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Button, CustomMenu } from "ui";
// types
import { CycleIssueResponse, ICycle } from "types";
// fetch-keys
import { CYCLE_ISSUES } from "constants/fetch-keys";
import { groupBy, renderShortNumericDateFormat } from "constants/common";
import { ArrowPathIcon, CheckIcon, UserIcon } from "@heroicons/react/24/outline";
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import { useRouter } from "next/router";
type Props = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
};
const stateGroupColours: {
[key: string]: string;
} = {
backlog: "#3f76ff",
unstarted: "#ff9e9e",
started: "#d687ff",
cancelled: "#ff5353",
completed: "#096e8d",
};
const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle }) => {
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
activeWorkspace && activeProject && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
activeWorkspace && activeProject && cycle.id
? () =>
cyclesService.getCycleIssues(activeWorkspace?.slug, activeProject?.id, cycle.id as string)
: null
);
const groupedIssues = {
backlog: [],
unstarted: [],
started: [],
cancelled: [],
completed: [],
...groupBy(cycleIssues ?? [], "issue_details.state_detail.group"),
};
const startDate = new Date(cycle.start_date ?? "");
const endDate = new Date(cycle.end_date ?? "");
const today = new Date();
return (
<>
<div className="bg-white p-3">
<div className="grid grid-cols-8 gap-2 divide-x">
<div className="col-span-3 space-y-3">
<Link href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}>
<a className="flex justify-between items-center">
<h2 className="font-medium">{cycle.name}</h2>
<div className="flex items-center gap-2">
<span className="text-xs bg-gray-100 px-2 py-1 rounded-xl">
{today.getDate() < startDate.getDate()
? "Not started"
: today.getDate() > endDate.getDate()
? "Over"
: "Active"}
</span>
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
Delete cycle permanently
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</a>
</Link>
<div className="grid grid-cols-2 gap-x-2 gap-y-3 text-xs">
<div className="flex items-center gap-2 text-gray-500">
<CalendarDaysIcon className="h-4 w-4" />
Cycle dates
</div>
<div>
{renderShortNumericDateFormat(startDate)} - {renderShortNumericDateFormat(endDate)}
</div>
<div className="flex items-center gap-2 text-gray-500">
<UserIcon className="h-4 w-4" />
Created by
</div>
<div className="flex items-center gap-2">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="h-5 w-5 capitalize bg-gray-700 text-white grid place-items-center rounded-full">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
{cycle.owned_by.first_name}
</div>
<div className="flex items-center gap-2 text-gray-500">
<CalendarDaysIcon className="h-4 w-4" />
Active members
</div>
<div></div>
</div>
<div className="flex items-center gap-2">
<Button theme="secondary" className="flex items-center gap-2" disabled>
<CheckIcon className="h-3 w-3" />
Participating
</Button>
<Button
theme="secondary"
className="flex items-center gap-2"
onClick={() => router.push(`/projects/${activeProject?.id}/cycles/${cycle.id}`)}
>
<ArrowPathIcon className="h-3 w-3" />
Open Cycle
</Button>
</div>
</div>
<div className="col-span-2 px-5 space-y-3">
<h4 className="text-sm tracking-widest">PROGRESS</h4>
<div className="text-xs space-y-3">
{Object.keys(groupedIssues).map((group) => {
return (
<div key={group} className="flex items-center gap-2">
<div className="flex items-center gap-2 basis-2/3">
<span
className="h-2 w-2 block rounded-full"
style={{
backgroundColor: stateGroupColours[group],
}}
></span>
<h6 className="capitalize text-xs">{group}</h6>
</div>
<div>
<span>
{groupedIssues[group].length}{" "}
<span className="text-gray-500">
-{" "}
{cycleIssues && cycleIssues.length > 0
? `${(groupedIssues[group].length / cycleIssues.length) * 100}%`
: "0%"}
</span>
</span>
</div>
</div>
);
})}
</div>
</div>
<div className="col-span-3"></div>
</div>
</div>
</>
);
};
export default SingleStat;

View File

@ -130,12 +130,6 @@ const SingleBoard: React.FC<Props> = ({
backgroundColor: `${bgColor}20`, backgroundColor: `${bgColor}20`,
}} }}
> >
<span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
style={{
backgroundColor: Boolean(bgColor) ? bgColor : undefined,
}}
/>
<h2 <h2
className={`text-[0.9rem] font-medium capitalize`} className={`text-[0.9rem] font-medium capitalize`}
style={{ style={{

View File

@ -6,7 +6,7 @@ import { Listbox, Transition } from "@headlessui/react";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// components // components
import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal"; import CreateUpdateSprintsModal from "components/project/cycles/create-update-cycle-modal";
// icons // icons
import { CheckIcon, ChevronDownIcon, PlusIcon } from "@heroicons/react/20/solid"; import { CheckIcon, ChevronDownIcon, PlusIcon } from "@heroicons/react/20/solid";
// types // types

View File

@ -8,7 +8,7 @@ import type { IIssue, IssueResponse } from "types";
// icons // icons
import { UserIcon } from "@heroicons/react/24/outline"; import { UserIcon } from "@heroicons/react/24/outline";
// components // components
import IssuesListModal from "components/project/issues/IssuesListModal"; import IssuesListModal from "components/project/issues/issues-list-modal";
type Props = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;

View File

@ -33,7 +33,7 @@ import SelectPriority from "./SelectPriority";
import SelectAssignee from "./SelectAssignee"; import SelectAssignee from "./SelectAssignee";
import SelectParent from "./SelectParentIssue"; import SelectParent from "./SelectParentIssue";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal"; import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// types // types
import type { IIssue, IssueResponse, CycleIssueResponse } from "types"; import type { IIssue, IssueResponse, CycleIssueResponse } from "types";
import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline"; import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline";

View File

@ -1,177 +0,0 @@
// react
import React, { useState } from "react";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types
import { IIssue } from "types";
import { classNames } from "constants/common";
import useUser from "lib/hooks/useUser";
type Props = {
isOpen: boolean;
handleClose: () => void;
value?: any;
onChange: (...event: any[]) => void;
issues: IIssue[];
title?: string;
multiple?: boolean;
};
const IssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
value,
onChange,
issues,
title = "Issues",
multiple = false,
}) => {
const [query, setQuery] = useState("");
const [values, setValues] = useState<string[]>([]);
const { activeProject } = useUser();
const handleClose = () => {
onClose();
setQuery("");
setValues([]);
};
const filteredIssues: IIssue[] =
query === ""
? issues ?? []
: issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<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-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="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<Combobox
value={value}
onChange={(val) => {
if (multiple) setValues(val);
else onChange(val);
}}
// multiple={multiple}
>
<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={(e) => setQuery(e.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">
{title}
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
onClick={() => {
if (!multiple) handleClose();
}}
>
{({ selected }) => (
<>
{multiple ? (
<input type="checkbox" checked={selected} readOnly />
) : null}
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
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>
{multiple ? (
<div className="flex justify-end items-center gap-2 p-3">
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button type="button" size="sm" onClick={() => onChange(values)}>
Add to Cycle
</Button>
</div>
) : null}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default IssuesListModal;

View File

@ -1,641 +0,0 @@
import React, { useState } from "react";
// swr
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// react hook form
import { useForm, Controller, UseFormWatch } from "react-hook-form";
// services
import stateServices from "lib/services/state.service";
import issuesServices from "lib/services/issues.service";
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// components
import IssuesListModal from "components/project/issues/IssuesListModal";
// fetching keys
import {
PROJECT_ISSUES_LIST,
STATE_LIST,
WORKSPACE_MEMBERS,
PROJECT_ISSUE_LABELS,
} from "constants/fetch-keys";
// commons
import { classNames, copyTextToClipboard } from "constants/common";
import { PRIORITIES } from "constants/";
// ui
import { Input, Button, Spinner } from "ui";
import { Popover } from "@headlessui/react";
// icons
import {
UserIcon,
TagIcon,
UserGroupIcon,
ChevronDownIcon,
Squares2X2Icon,
ChartBarIcon,
ClipboardDocumentIcon,
LinkIcon,
ArrowPathIcon,
CalendarDaysIcon,
TrashIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import type { Control } from "react-hook-form";
import type { IIssue, IIssueLabels, IssueResponse, IState, NestedKeyOf } from "types";
import { TwitterPicker } from "react-color";
import { positionEditorElement } from "components/lexical/helpers/editor";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
issueDetail: IIssue | undefined;
watch: UseFormWatch<IIssue>;
setDeleteIssueModal: React.Dispatch<React.SetStateAction<boolean>>;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
colour: "#ff0000",
};
const IssueDetailSidebar: React.FC<Props> = ({
control,
submitChanges,
issueDetail,
watch: watchIssue,
setDeleteIssueModal,
}) => {
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const [createLabelForm, setCreateLabelForm] = useState(false);
const { activeWorkspace, activeProject, cycles, issues } = useUser();
const { setToastAlert } = useToast();
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(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : 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,
watch,
control: controlLabel,
} = 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);
});
};
const sidebarSections: Array<
Array<{
label: string;
name: NestedKeyOf<IIssue>;
canSelectMultipleOptions: boolean;
icon: (props: any) => JSX.Element;
options?: Array<{ label: string; value: any; color?: string }>;
modal: boolean;
issuesList?: Array<IIssue>;
isOpen?: boolean;
setIsOpen?: (arg: boolean) => void;
}>
> = [
[
{
label: "Status",
name: "state",
canSelectMultipleOptions: false,
icon: Squares2X2Icon,
options: states?.map((state) => ({
label: state.name,
value: state.id,
color: state.color,
})),
modal: false,
},
{
label: "Assignees",
name: "assignees_list",
canSelectMultipleOptions: true,
icon: UserGroupIcon,
options: people?.map((person) => ({
label: person.member.first_name,
value: person.member.id,
})),
modal: false,
},
{
label: "Priority",
name: "priority",
canSelectMultipleOptions: false,
icon: ChartBarIcon,
options: PRIORITIES.map((property) => ({
label: property,
value: property,
})),
modal: false,
},
],
[
{
label: "Parent",
name: "parent",
canSelectMultipleOptions: false,
icon: UserIcon,
issuesList:
issues?.results.filter(
(i) =>
i.id !== issueDetail?.id &&
i.id !== issueDetail?.parent &&
i.parent !== issueDetail?.id
) ?? [],
modal: true,
isOpen: isParentModalOpen,
setIsOpen: setIsParentModalOpen,
},
// {
// label: "Blocker",
// name: "blockers_list",
// canSelectMultipleOptions: true,
// icon: UserIcon,
// issuesList: issues?.results.filter((i) => i.id !== issueDetail?.id) ?? [],
// modal: true,
// isOpen: isBlockerModalOpen,
// setIsOpen: setIsBlockerModalOpen,
// },
// {
// label: "Blocked",
// name: "blocked_list",
// canSelectMultipleOptions: true,
// icon: UserIcon,
// issuesList: issues?.results.filter((i) => i.id !== issueDetail?.id) ?? [],
// modal: true,
// isOpen: isBlockedModalOpen,
// setIsOpen: setIsBlockedModalOpen,
// },
{
label: "Target Date",
name: "target_date",
canSelectMultipleOptions: true,
icon: CalendarDaysIcon,
modal: false,
},
],
[
{
label: "Cycle",
name: "cycle",
canSelectMultipleOptions: false,
icon: ArrowPathIcon,
options: cycles?.map((cycle) => ({
label: cycle.name,
value: cycle.id,
})),
modal: false,
},
],
];
const handleCycleChange = (cycleId: string) => {
if (activeWorkspace && activeProject && issueDetail)
issuesServices.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
issue: issueDetail.id,
});
};
return (
<>
<div className="h-full w-full divide-y-2 divide-gray-100">
<div className="flex justify-between items-center pb-3">
<h4 className="text-sm font-medium">
{activeProject?.identifier}-{issueDetail?.sequence_id}
</h4>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${activeProject?.id}/issues/${issueDetail?.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(`${issueDetail?.id}`)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-red-50 text-red-500 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() => setDeleteIssueModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="divide-y-2 divide-gray-100">
{sidebarSections.map((section, index) => (
<div key={index} className="py-1">
{section.map((item) => (
<div key={item.label} className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<item.icon className="flex-shrink-0 h-4 w-4" />
<p>{item.label}</p>
</div>
<div className="sm:basis-1/2">
{item.name === "target_date" ? (
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
submitChanges({ target_date: e.target.value });
onChange(e.target.value);
}}
className="hover:bg-gray-100 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 text-xs duration-300 w-full"
/>
)}
/>
) : item.modal ? (
<Controller
control={control}
name={item.name as keyof IIssue}
render={({ field: { value, onChange } }) => (
<>
<IssuesListModal
isOpen={Boolean(item?.isOpen)}
handleClose={() => item.setIsOpen && item.setIsOpen(false)}
onChange={(val) => {
console.log(val);
// submitChanges({ [item.name]: val });
onChange(val);
}}
issues={item?.issuesList ?? []}
title={`Select ${item.label}`}
multiple={item.canSelectMultipleOptions}
value={value}
/>
<button
type="button"
className="flex justify-between items-center gap-1 hover:bg-gray-100 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 text-xs duration-300 w-full"
onClick={() => item.setIsOpen && item.setIsOpen(true)}
>
{watchIssue(`${item.name as keyof IIssue}`) &&
watchIssue(`${item.name as keyof IIssue}`) !== ""
? `${activeProject?.identifier}-
${
issues?.results.find(
(i) => i.id === watchIssue(`${item.name as keyof IIssue}`)
)?.sequence_id
}`
: `Select ${item.label}`}
</button>
</>
)}
/>
) : (
<Controller
control={control}
name={item.name as keyof IIssue}
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={item.canSelectMultipleOptions}
onChange={(value: any) => {
if (item.name === "cycle") handleCycleChange(value);
else submitChanges({ [item.name]: value });
}}
className="flex-shrink-0"
>
{({ open }) => (
<div className="relative">
<Listbox.Button className="flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 w-full py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left",
item.label === "Priority" ? "capitalize" : ""
)}
>
{value
? Array.isArray(value)
? value
.map(
(i: any) =>
item.options?.find((option) => option.value === i)
?.label
)
.join(", ") || item.label
: item.options?.find((option) => option.value === value)
?.label
: "None"}
</span>
<ChevronDownIcon className="h-3 w-3" />
</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 right-0 mt-1 w-40 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">
{item.options ? (
item.options.length > 0 ? (
item.options.map((option) => (
<Listbox.Option
key={option.value}
className={({ active, selected }) =>
`${
active || selected
? "text-white bg-theme"
: "text-gray-900"
} ${
item.label === "Priority" && "capitalize"
} flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate`
}
value={option.value}
>
{option.color && (
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: option.color }}
></span>
)}
{option.label}
</Listbox.Option>
))
) : (
<div className="text-center">No {item.label}s found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
)}
</div>
</div>
))}
</div>
))}
</div>
<div className="pt-3 space-y-3">
<div className="flex justify-between items-start">
<div className="flex items-center gap-x-2 text-sm basis-1/2">
<TagIcon className="w-4 h-4" />
<p>Label</p>
</div>
<div className="basis-1/2">
<div className="flex gap-1 flex-wrap">
{issueDetail?.label_details.map((label) => (
<span
key={label.id}
className="flex items-center gap-2 border rounded-2xl text-xs px-1 py-0.5 hover:bg-gray-100 cursor-pointer"
// onClick={() =>
// submitChanges({
// labels_list: issueDetail?.labels_list.filter((l) => l !== label.id),
// })
// }
>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }}
></span>
{label.name}
</span>
))}
<Controller
control={control}
name="labels_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple
onChange={(value: any) => submitChanges({ labels_list: value })}
className="flex-shrink-0"
>
{({ open }) => (
<>
<Listbox.Label className="sr-only">Label</Listbox.Label>
<div className="relative">
<Listbox.Button className="flex items-center gap-2 border rounded-2xl text-xs px-1 py-0.5 hover:bg-gray-100 cursor-pointer">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden sm:block text-left"
)}
>
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 z-10 right-0 mt-1 w-40 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">
{issueLabels ? (
issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
`${
active || selected
? "text-white bg-theme"
: "text-gray-900"
} flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate`
}
value={label.id}
>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }}
></span>
{label.name}
</Listbox.Option>
))
) : (
<div className="text-center">No labels found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium"
onClick={() => setCreateLabelForm((prevData) => !prevData)}
>
{createLabelForm ? (
<>
<XMarkIcon className="h-3 w-3" /> Cancel
</>
) : (
<>
<PlusIcon className="h-3 w-3" /> Create new
</>
)}
</button>
</div>
{createLabelForm && (
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(onSubmit)}>
<div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`bg-white flex items-center gap-1 rounded-md p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
>
{watch("colour") && watch("colour") !== "" && (
<span
className="w-5 h-5 rounded"
style={{
backgroundColor: watch("colour") ?? "green",
}}
></span>
)}
<ChevronDownIcon className="h-3 w-3" />
</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 z-10 transform right-0 mt-1 px-2 max-w-xs sm:px-0">
<Controller
name="colour"
control={controlLabel}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<Input
id="name"
name="name"
placeholder="Title"
register={register}
validations={{
required: "This is required",
}}
autoComplete="off"
/>
<Button type="submit" theme="success" disabled={isSubmitting}>
+
</Button>
</form>
)}
</div>
</div>
</>
);
};
export default IssueDetailSidebar;

View File

@ -0,0 +1,417 @@
import React, { useState } from "react";
// swr
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// react hook form
import { useForm, Controller, UseFormWatch } from "react-hook-form";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// fetching keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
// commons
import { copyTextToClipboard } from "constants/common";
// ui
import { Input, Button, Spinner } from "ui";
import { Popover } from "@headlessui/react";
// icons
import {
TagIcon,
ChevronDownIcon,
ClipboardDocumentIcon,
LinkIcon,
CalendarDaysIcon,
TrashIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import type { Control } from "react-hook-form";
import type { IIssue, IIssueLabels, NestedKeyOf } from "types";
import { TwitterPicker } from "react-color";
import { positionEditorElement } from "components/lexical/helpers/editor";
import SelectState from "./select-state";
import SelectPriority from "./select-priority";
import SelectParent from "./select-parent";
import SelectCycle from "./select-cycle";
import SelectAssignee from "./select-assignee";
import SelectBlocker from "./select-blocker";
import SelectBlocked from "./select-blocked";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
issueDetail: IIssue | undefined;
watch: UseFormWatch<IIssue>;
setDeleteIssueModal: React.Dispatch<React.SetStateAction<boolean>>;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
colour: "#ff0000",
};
const IssueDetailSidebar: React.FC<Props> = ({
control,
submitChanges,
issueDetail,
watch: watchIssue,
setDeleteIssueModal,
}) => {
const [createLabelForm, setCreateLabelForm] = useState(false);
const { activeWorkspace, activeProject, issues } = useUser();
const { setToastAlert } = useToast();
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,
watch,
control: controlLabel,
} = useForm({
defaultValues,
});
const handleNewLabel = (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);
});
};
const handleCycleChange = (cycleId: string) => {
if (activeWorkspace && activeProject && issueDetail)
issuesServices.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
issue: issueDetail.id,
});
};
console.log(issueDetail);
return (
<>
<div className="h-full w-full divide-y-2 divide-gray-100">
<div className="flex justify-between items-center pb-3">
<h4 className="text-sm font-medium">
{activeProject?.identifier}-{issueDetail?.sequence_id}
</h4>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${activeProject?.id}/issues/${issueDetail?.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(`${issueDetail?.id}`)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-red-50 text-red-500 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() => setDeleteIssueModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="divide-y-2 divide-gray-100">
<div className="py-1">
<SelectState control={control} submitChanges={submitChanges} />
<SelectAssignee control={control} submitChanges={submitChanges} />
<SelectPriority control={control} submitChanges={submitChanges} watch={watchIssue} />
</div>
<div className="py-1">
<SelectParent
control={control}
submitChanges={submitChanges}
issuesList={
issues?.results.filter(
(i) =>
i.id !== issueDetail?.id &&
i.id !== issueDetail?.parent &&
i.parent !== issueDetail?.id
) ?? []
}
customDisplay={
issueDetail?.parent_detail ? (
<button
type="button"
className="flex items-center gap-2 bg-gray-100 px-3 py-2 text-xs rounded"
onClick={() => submitChanges({ parent: null })}
>
{issueDetail.parent_detail?.name}
<XMarkIcon className="h-3 w-3" />
</button>
) : (
<div className="inline-block bg-gray-100 px-3 py-2 text-xs rounded">
No parent selected
</div>
)
}
watch={watchIssue}
/>
<SelectBlocker
submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue}
/>
<SelectBlocked
submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue}
/>
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="flex-shrink-0 h-4 w-4" />
<p>Due date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
submitChanges({ target_date: e.target.value });
onChange(e.target.value);
}}
className="hover:bg-gray-100 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 text-xs duration-300 w-full"
/>
)}
/>
</div>
</div>
</div>
<div className="py-1">
<SelectCycle control={control} handleCycleChange={handleCycleChange} />
</div>
</div>
<div className="pt-3 space-y-3">
<div className="flex justify-between items-start">
<div className="flex items-center gap-x-2 text-sm basis-1/2">
<TagIcon className="w-4 h-4" />
<p>Label</p>
</div>
<div className="basis-1/2">
<div className="flex gap-1 flex-wrap">
{issueDetail?.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 border rounded-2xl text-xs px-1 py-0.5 hover:bg-red-50 hover:border-red-500 cursor-pointer"
onClick={() => {
const updatedLabels = issueDetail?.labels.filter((l) => l !== label.id);
submitChanges({
labels_list: updatedLabels,
});
}}
>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }}
></span>
{label.name}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
</span>
))}
<Controller
control={control}
name="labels_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple
onChange={(val: any) => submitChanges({ labels_list: val })}
className="flex-shrink-0"
>
{({ open }) => (
<>
<Listbox.Label className="sr-only">Label</Listbox.Label>
<div className="relative">
<Listbox.Button className="flex items-center gap-2 border rounded-2xl text-xs px-2 py-0.5 hover:bg-gray-100 cursor-pointer">
Select Label
</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 right-0 mt-1 w-40 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="py-1">
{issueLabels ? (
issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex items-center gap-2 text-gray-900 cursor-pointer select-none relative p-2 truncate`
}
value={label.id}
>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }}
></span>
{label.name}
</Listbox.Option>
))
) : (
<div className="text-center">No labels found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
<button
type="button"
className="flex items-center gap-1 border rounded-2xl text-xs px-2 py-0.5 hover:bg-gray-100 cursor-pointer"
onClick={() => setCreateLabelForm((prevData) => !prevData)}
>
{createLabelForm ? (
<>
<XMarkIcon className="h-3 w-3" /> Cancel
</>
) : (
<>
<PlusIcon className="h-3 w-3" /> New
</>
)}
</button>
</div>
</div>
</div>
{createLabelForm && (
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
<div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`bg-white flex items-center gap-1 rounded-md p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
>
{watch("colour") && watch("colour") !== "" && (
<span
className="w-5 h-5 rounded"
style={{
backgroundColor: watch("colour") ?? "green",
}}
></span>
)}
<ChevronDownIcon className="h-3 w-3" />
</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 z-10 transform right-0 mt-1 px-2 max-w-xs sm:px-0">
<Controller
name="colour"
control={controlLabel}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<Input
id="name"
name="name"
placeholder="Title"
register={register}
validations={{
required: "This is required",
}}
autoComplete="off"
/>
<Button type="submit" theme="success" disabled={isSubmitting}>
+
</Button>
</form>
)}
</div>
</div>
</>
);
};
export default IssueDetailSidebar;

View File

@ -0,0 +1,181 @@
// react
import React from "react";
// next
import Image from "next/image";
// swr
import useSWR from "swr";
// react-hook-form
import { Control, Controller } from "react-hook-form";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import { ArrowPathIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import { IIssue } from "types";
// constants
import { classNames } from "constants/common";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
};
const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
const { activeWorkspace } = useUser();
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ArrowPathIcon className="flex-shrink-0 h-4 w-4" />
<p>Assignees</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="assignees_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={true}
onChange={(value: any) => {
submitChanges({ assignees_list: value });
}}
className="flex-shrink-0"
>
{({ open }) => (
<div className="relative">
<Listbox.Button className="w-full flex justify-end items-center gap-1 text-xs cursor-pointer">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left"
)}
>
<div className="flex items-center gap-1 text-xs cursor-pointer">
{value && Array.isArray(value) ? (
<>
{value.length > 0 ? (
value.map((assignee, index: number) => {
const person = people?.find(
(p) => p.member.id === assignee
)?.member;
return (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{person && person.avatar && person.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={person.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={person.first_name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
>
{person?.first_name.charAt(0)}
</div>
)}
</div>
);
})
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</>
) : null}
</div>
</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 right-0 mt-1 w-40 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="py-1">
{people ? (
people.length > 0 ? (
people.map((option) => (
<Listbox.Option
key={option.member.id}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex items-center gap-2 text-gray-900 cursor-pointer select-none relative p-2 rounded-md truncate`
}
value={option.member.id}
>
{option.member.avatar && option.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={option.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
) : (
<div className="h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name.charAt(0)
: option.member.email.charAt(0)}
</div>
)}
{option.member.first_name}
</Listbox.Option>
))
) : (
<div className="text-center">No assignees found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
</div>
</div>
);
};
export default SelectAssignee;

View File

@ -0,0 +1,238 @@
// react
import React, { useState } from "react";
// react-hook-form
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import {
FolderIcon,
MagnifyingGlassIcon,
UserGroupIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import { IIssue } from "types";
// constants
import { classNames } from "constants/common";
type FormInput = {
issue_ids: string[];
};
type Props = {
submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
watch: UseFormWatch<IIssue>;
};
const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) => {
const [query, setQuery] = useState("");
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const { activeProject, issues } = useUser();
const { setToastAlert } = useToast();
const { register, handleSubmit, reset, watch: watchIssues } = useForm<FormInput>();
const handleClose = () => {
setIsBlockedModalOpen(false);
reset();
};
const onSubmit: SubmitHandler<FormInput> = (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
const newBlocked = [...watch("blocked_list"), ...data.issue_ids];
submitChanges({ blocked_list: newBlocked });
handleClose();
};
return (
<div className="flex items-start py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="flex-shrink-0 h-4 w-4" />
<p>Blocked issues</p>
</div>
<div className="sm:basis-1/2 space-y-1">
<div className="flex gap-1 flex-wrap">
{watch("blocked_list") && watch("blocked_list").length > 0
? watch("blocked_list").map((issue) => (
<span
key={issue}
className="group flex items-center gap-1 border rounded-2xl text-xs px-1 py-0.5 hover:bg-red-50 hover:border-red-500 cursor-pointer"
onClick={() => {
const updatedBlockers = watch("blocked_list").filter((i) => i !== issue);
submitChanges({
blocked_list: updatedBlockers,
});
}}
>
{`${activeProject?.identifier}-${
issues?.results.find((i) => i.id === issue)?.sequence_id
}`}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
</span>
))
: null}
</div>
<Transition.Root
show={isBlockedModalOpen}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<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-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="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<form>
<Combobox>
<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"
>
{issuesList.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select blocked issues
</h2>
)}
<ul className="text-sm text-gray-700">
{issuesList.map((issue) => {
if (!watch("blocked_list").includes(issue.id)) {
return (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</>
)}
</Combobox.Option>
);
}
})}
</ul>
</li>
</>
)}
</Combobox.Options>
{query !== "" && issuesList.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>
<div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(onSubmit)} size="sm">
Add selected issues
</Button>
<div>
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<button
type="button"
className="flex justify-between items-center gap-1 hover:bg-gray-100 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 text-xs duration-300 w-full"
onClick={() => setIsBlockedModalOpen(true)}
>
Select issues
</button>
</div>
</div>
);
};
export default SelectBlocked;

View File

@ -0,0 +1,238 @@
// react
import React, { useState } from "react";
// react-hook-form
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import {
FolderIcon,
MagnifyingGlassIcon,
UserGroupIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import { IIssue } from "types";
// constants
import { classNames } from "constants/common";
type FormInput = {
issue_ids: string[];
};
type Props = {
submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
watch: UseFormWatch<IIssue>;
};
const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) => {
const [query, setQuery] = useState("");
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const { activeProject, issues } = useUser();
const { setToastAlert } = useToast();
const { register, handleSubmit, reset } = useForm<FormInput>();
const handleClose = () => {
setIsBlockerModalOpen(false);
reset();
};
const onSubmit: SubmitHandler<FormInput> = (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
const newBlockers = [...watch("blockers_list"), ...data.issue_ids];
submitChanges({ blockers_list: newBlockers });
handleClose();
};
return (
<div className="flex items-start py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="flex-shrink-0 h-4 w-4" />
<p>Blocker issues</p>
</div>
<div className="sm:basis-1/2 space-y-1">
<div className="flex gap-1 flex-wrap">
{watch("blockers_list") && watch("blockers_list").length > 0
? watch("blockers_list").map((issue) => (
<span
key={issue}
className="group flex items-center gap-1 border rounded-2xl text-xs px-1 py-0.5 hover:bg-red-50 hover:border-red-500 cursor-pointer"
onClick={() => {
const updatedBlockers = watch("blockers_list").filter((i) => i !== issue);
submitChanges({
blockers_list: updatedBlockers,
});
}}
>
{`${activeProject?.identifier}-${
issues?.results.find((i) => i.id === issue)?.sequence_id
}`}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
</span>
))
: null}
</div>
<Transition.Root
show={isBlockerModalOpen}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<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-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="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<form>
<Combobox>
<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"
>
{issuesList.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select blocker issues
</h2>
)}
<ul className="text-sm text-gray-700">
{issuesList.map((issue) => {
if (!watch("blockers_list").includes(issue.id)) {
return (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</>
)}
</Combobox.Option>
);
}
})}
</ul>
</li>
</>
)}
</Combobox.Options>
{query !== "" && issuesList.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>
<div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(onSubmit)} size="sm">
Add selected issues
</Button>
<div>
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<button
type="button"
className="flex justify-between items-center gap-1 hover:bg-gray-100 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 text-xs duration-300 w-full"
onClick={() => setIsBlockerModalOpen(true)}
>
Select issues
</button>
</div>
</div>
);
};
export default SelectBlocker;

View File

@ -0,0 +1,73 @@
// react-hook-form
import { Control, Controller } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// types
import { IIssue } from "types";
import { classNames } from "constants/common";
import { Spinner } from "ui";
import React from "react";
import { ArrowPathIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import CustomSelect from "ui/custom-select";
type Props = {
control: Control<IIssue, any>;
handleCycleChange: (cycleId: string) => void;
};
const SelectCycle: React.FC<Props> = ({ control, handleCycleChange }) => {
const { cycles } = useUser();
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ArrowPathIcon className="flex-shrink-0 h-4 w-4" />
<p>Cycle</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="cycle"
render={({ field: { value } }) => (
<>
<CustomSelect
label={
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left"
)}
>
{value ? cycles?.find((c) => c.id === value)?.name : "None"}
</span>
}
value={value}
onChange={(value: any) => {
handleCycleChange(value);
}}
>
{cycles ? (
cycles.length > 0 ? (
cycles.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
{option.name}
</CustomSelect.Option>
))
) : (
<div className="text-center">No cycles found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</>
)}
/>
</div>
</div>
);
};
export default SelectCycle;

View File

@ -0,0 +1,74 @@
// react
import React, { useState } from "react";
// react-hook-form
import { Control, Controller, UseFormWatch } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
// components
import IssuesListModal from "components/project/issues/issues-list-modal";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// types
import { IIssue } from "types";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
customDisplay: JSX.Element;
watch: UseFormWatch<IIssue>;
};
const SelectParent: React.FC<Props> = ({
control,
submitChanges,
issuesList,
customDisplay,
watch,
}) => {
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const { activeProject, issues } = useUser();
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="flex-shrink-0 h-4 w-4" />
<p>Parent</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="parent"
render={({ field: { value, onChange } }) => (
<IssuesListModal
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(val) => {
submitChanges({ parent: val });
onChange(val);
}}
issues={issuesList}
title="Select Parent"
value={value}
customDisplay={customDisplay}
/>
)}
/>
<button
type="button"
className="flex justify-between items-center gap-1 hover:bg-gray-100 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 text-xs duration-300 w-full"
onClick={() => setIsParentModalOpen(true)}
>
{watch("parent") && watch("parent") !== ""
? `${activeProject?.identifier}-${
issues?.results.find((i) => i.id === watch("parent"))?.sequence_id
}`
: "Select issue"}
</button>
</div>
</div>
);
};
export default SelectParent;

View File

@ -0,0 +1,58 @@
// react
import React from "react";
// react-hook-form
import { Control, Controller, UseFormWatch } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { ChevronDownIcon, ChartBarIcon } from "@heroicons/react/24/outline";
// types
import { IIssue } from "types";
// constants
import { classNames } from "constants/common";
import { PRIORITIES } from "constants/";
import CustomSelect from "ui/custom-select";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
};
const SelectPriority: React.FC<Props> = ({ control, submitChanges, watch }) => {
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ChartBarIcon className="flex-shrink-0 h-4 w-4" />
<p>Priority</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="state"
render={({ field: { value } }) => (
<CustomSelect
label={
<span className={classNames(value ? "" : "text-gray-900", "text-left capitalize")}>
{watch("priority") && watch("priority") !== "" ? watch("priority") : "None"}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ priority: value });
}}
>
{PRIORITIES.map((option) => (
<CustomSelect.Option key={option} value={option} className="capitalize">
{option}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
);
};
export default SelectPriority;

View File

@ -0,0 +1,91 @@
// react-hook-form
import { Control, Controller } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// types
import { IIssue } from "types";
import { classNames } from "constants/common";
import { CustomMenu, Spinner } from "ui";
import React from "react";
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
import CustomSelect from "ui/custom-select";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
};
const SelectState: React.FC<Props> = ({ control, submitChanges }) => {
const { states } = useUser();
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<Squares2X2Icon className="flex-shrink-0 h-4 w-4" />
<p>State</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="state"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={classNames(
value ? "" : "text-gray-900",
"flex items-center gap-2 text-left"
)}
>
{value ? (
<>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{
backgroundColor: states?.find((option) => option.id === value)?.color,
}}
></span>
{states?.find((option) => option.id === value)?.name}
</>
) : (
"None"
)}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ state: value });
}}
>
{states ? (
states.length > 0 ? (
states.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<>
{option.color && (
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: option.color }}
></span>
)}
{option.name}
</>
</CustomSelect.Option>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
)}
/>
</div>
</div>
);
};
export default SelectState;

View File

@ -0,0 +1,249 @@
// react
import React, { useState } from "react";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types
import { IIssue } from "types";
import { classNames } from "constants/common";
import useUser from "lib/hooks/useUser";
type Props = {
isOpen: boolean;
handleClose: () => void;
value?: any;
onChange: (...event: any[]) => void;
issues: IIssue[];
title?: string;
multiple?: boolean;
customDisplay?: JSX.Element;
};
const IssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
value,
onChange,
issues,
title = "Issues",
multiple = false,
customDisplay,
}) => {
const [query, setQuery] = useState("");
const [values, setValues] = useState<string[]>([]);
const { activeProject } = useUser();
const handleClose = () => {
onClose();
setQuery("");
setValues([]);
};
const filteredIssues: IIssue[] =
query === ""
? issues ?? []
: issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<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-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="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
{multiple ? (
<>
<Combobox
value={value}
onChange={(val) => {
// setValues(val);
console.log(val);
}}
multiple
>
<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={(e) => setQuery(e.target.value)}
displayValue={() => ""}
/>
</div>
<div className="p-3">{customDisplay}</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">
{title}
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.id}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
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>
<div className="flex justify-end items-center gap-2 p-3">
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button type="button" size="sm" onClick={() => onChange(values)}>
Add issues
</Button>
</div>
</>
) : (
<Combobox value={value} onChange={onChange}>
<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={(e) => setQuery(e.target.value)}
displayValue={() => ""}
/>
</div>
<div className="p-3">{customDisplay}</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">
{title}
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
onClick={() => handleClose()}
>
{({ selected }) => (
<>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
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 IssuesListModal;

View File

@ -0,0 +1,245 @@
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";
// ui
import { Button, Input, TextArea, Select } from "ui";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// types
import type { IModule } from "types";
// common
import { renderDateFormat } from "constants/common";
// fetch keys
import { MODULE_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
projectId: string;
data?: IModule;
};
const defaultValues: Partial<IModule> = {
name: "",
description: "",
};
const CreateUpdateModuleModal: 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<IModule>({
defaultValues,
});
const onSubmit = async (formData: IModule) => {
if (!activeWorkspace) return;
const payload = {
...formData,
start_date: formData.start_date ? renderDateFormat(formData.start_date) : null,
target_date: formData.target_date ? renderDateFormat(formData.target_date) : null,
};
if (!data) {
await modulesService
.createModule(activeWorkspace.slug, projectId, payload)
.then((res) => {
mutate<IModule[]>(
MODULE_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 modulesService
.updateModule(activeWorkspace.slug, projectId, data.id, payload)
.then((res) => {
mutate<IModule[]>(
MODULE_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"} Module
</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: "Backlog", value: "backlog" },
{ label: "Planned", value: "planned" },
{ label: "In Progress", value: "in-progress" },
{ label: "Paused", value: "paused" },
{ label: "Completed", value: "completed" },
{ label: "Cancelled", value: "cancelled" },
]}
/>
</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="target_date"
label="Target Date"
name="target_date"
type="date"
placeholder="Enter target date"
error={errors.target_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 Module..."
: "Update Module"
: isSubmitting
? "Creating Module..."
: "Create Module"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default CreateUpdateModuleModal;

View File

@ -82,13 +82,11 @@ const WorkspaceOptions: React.FC<Props> = ({ sidebarCollapse }) => {
return ( return (
<div className="px-2"> <div className="px-2">
<div <div className="relative">
className={`relative ${sidebarCollapse ? "flex" : "grid grid-cols-5 gap-2 items-center"}`}
>
<Menu as="div" className="col-span-4 inline-block text-left w-full"> <Menu as="div" className="col-span-4 inline-block text-left w-full">
<div className="w-full"> <div className="w-full">
<Menu.Button <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 ${ className={`inline-flex justify-between items-center w-full rounded-md px-1 py-2 text-sm font-semibold text-gray-700 focus:outline-none ${
!sidebarCollapse !sidebarCollapse
? "hover:bg-gray-50 focus:bg-gray-50 border border-gray-300 shadow-sm" ? "hover:bg-gray-50 focus:bg-gray-50 border border-gray-300 shadow-sm"
: "" : ""
@ -102,20 +100,25 @@ const WorkspaceOptions: React.FC<Props> = ({ sidebarCollapse }) => {
alt="Workspace Logo" alt="Workspace Logo"
layout="fill" layout="fill"
objectFit="cover" objectFit="cover"
className="rounded"
/> />
) : ( ) : (
activeWorkspace?.name?.charAt(0) ?? "N" activeWorkspace?.name?.charAt(0) ?? "N"
)} )}
</div> </div>
{!sidebarCollapse && ( {!sidebarCollapse && (
<p className="truncate w-20 text-left ml-1"> <p className="text-left ml-1">
{activeWorkspace?.name ?? "Loading..."} {activeWorkspace?.name
? activeWorkspace.name.length > 17
? `${activeWorkspace.name.substring(0, 17)}...`
: activeWorkspace.name
: "Loading..."}
</p> </p>
)} )}
</div> </div>
{!sidebarCollapse && ( {!sidebarCollapse && (
<div className="flex-grow flex justify-end"> <div className="flex-grow flex justify-end">
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" /> <ChevronDownIcon className="ml-2 h-3 w-3" aria-hidden="true" />
</div> </div>
)} )}
</Menu.Button> </Menu.Button>
@ -131,74 +134,124 @@ const WorkspaceOptions: React.FC<Props> = ({ sidebarCollapse }) => {
leaveTo="transform opacity-0 scale-95" 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-20"> <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-20">
<div className="p-1"> <div className="px-1 py-2 divide-y">
{workspaces ? ( <div>
<> <Menu.Item as="div" className="text-xs px-2 pb-2">
{workspaces.length > 0 ? ( {user?.email}
workspaces.map((workspace: any) => ( </Menu.Item>
<Menu.Item key={workspace.id}> </div>
{({ active }) => ( <div className="py-2">
<button {workspaces ? (
type="button" <>
onClick={() => { {workspaces.length > 0 ? (
mutateUser( workspaces.map((workspace: any) => (
(prevData) => ({ <Menu.Item key={workspace.id}>
...(prevData as IUser), {({ active }) => (
last_workspace_id: workspace.id, <button
}), type="button"
false onClick={() => {
); mutateUser(
userService (prevData) => ({
.updateUser({ ...(prevData as IUser),
last_workspace_id: workspace?.id, last_workspace_id: workspace.id,
}) }),
.then((res) => { false
const isInProject = router.pathname.includes("/[projectId]/"); );
if (isInProject) router.push("/workspace"); userService
}) .updateUser({
.catch((err) => console.error(err)); last_workspace_id: workspace?.id,
}} })
className={`${ .then((res) => {
active ? "bg-theme text-white" : "text-gray-900" const isInProject = router.pathname.includes("/[projectId]/");
} group flex w-full items-center rounded-md p-2 text-sm`} if (isInProject) router.push("/workspace");
> })
{workspace.name} .catch((err) => console.error(err));
</button> }}
)} className={`${
</Menu.Item> active ? "bg-indigo-50" : ""
)) } w-full flex items-center gap-2 text-gray-900 rounded-md p-2 text-sm`}
) : ( >
<p>No workspace found!</p> <div className="h-5 w-5 p-4 flex items-center justify-center bg-gray-700 text-white rounded uppercase relative">
)} {workspace?.logo && workspace.logo !== "" ? (
<Menu.Item <Image
as="button" src={workspace.logo}
onClick={() => { alt="Workspace Logo"
router.push("/create-workspace"); layout="fill"
}} objectFit="cover"
className="w-full" className="rounded"
> />
{({ active }) => ( ) : (
<a activeWorkspace?.name?.charAt(0) ?? "N"
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 ${ )}
active ? "bg-theme text-white" : "text-gray-900" </div>
}`} <div className="text-left">
> <h5 className="text-sm">{workspace.name}</h5>
<PlusIcon className="w-5 h-5" /> <div className="text-xs text-gray-500">1 members</div>
<span>Create Workspace</span> </div>
</a> </button>
)}
</Menu.Item>
))
) : (
<p>No workspace found!</p>
)} )}
<Menu.Item
as="button"
type="button"
onClick={() => {
router.push("/create-workspace");
}}
className="w-full text-xs flex items-center gap-2 px-2 py-1 text-left rounded hover:bg-gray-100"
>
<PlusIcon className="h-3 w-3" />
Create Workspace
</Menu.Item>
</>
) : (
<div className="w-full flex justify-center">
<Spinner />
</div>
)}
</div>
<div className="text-xs pt-2 space-y-1">
{userLinks.map((link, index) => (
<Menu.Item key={index} as="div">
<Link href={link.href}>
<a className="block px-2 py-1 text-left rounded hover:bg-gray-100">
{link.name}
</a>
</Link>
</Menu.Item> </Menu.Item>
</> ))}
) : ( <Menu.Item
<div className="w-full flex justify-center"> as="button"
<Spinner /> type="button"
</div> className="w-full px-2 py-1 text-left rounded hover:bg-gray-100"
)} 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
</Menu.Item>
</div>
</div> </div>
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
{!sidebarCollapse && ( {/* {!sidebarCollapse && (
<Menu as="div" className="inline-block text-left flex-shrink-0 w-full"> <Menu as="div" className="inline-block text-left flex-shrink-0 w-full">
<div className="h-10 w-10"> <div className="h-10 w-10">
<Menu.Button className="h-full w-full grid relative place-items-center rounded-md shadow-sm bg-white text-gray-700 hover:bg-gray-50 focus:outline-none"> <Menu.Button className="h-full w-full grid relative place-items-center rounded-md shadow-sm bg-white text-gray-700 hover:bg-gray-50 focus:outline-none">
@ -261,7 +314,7 @@ const WorkspaceOptions: React.FC<Props> = ({ sidebarCollapse }) => {
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
)} )} */}
</div> </div>
<div className="mt-3 flex-1 space-y-1 bg-white"> <div className="mt-3 flex-1 space-y-1 bg-white">
{workspaceLinks.map((link, index) => ( {workspaceLinks.map((link, index) => (

View File

@ -132,3 +132,18 @@ export const REMOVE_ISSUE_FROM_CYCLE = (
bridgeId: string bridgeId: string
) => ) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/${bridgeId}/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/${bridgeId}/`;
// modules
export const MODULES_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/`;
export const MODULE_DETAIL = (workspaceSlug: string, projectId: string, moduleId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/`;
export const MODULE_ISSUES = (workspaceSlug: string, projectId: string, moduleId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`;
export const MODULE_ISSUE_DETAIL = (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${issueId}/`;

View File

@ -24,7 +24,7 @@ export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_S
export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`; export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`;
export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`; export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`;
export const CYCLE_ISSUES = (sprintId: string) => `CYCLE_ISSUES_${sprintId}`; export const CYCLE_ISSUES = (cycleId: string) => `CYCLE_ISSUES_${cycleId}`;
export const CYCLE_DETAIL = "CYCLE_DETAIL"; export const CYCLE_DETAIL = "CYCLE_DETAIL";
export const STATE_LIST = (projectId: string) => `STATE_LIST_${projectId}`; export const STATE_LIST = (projectId: string) => `STATE_LIST_${projectId}`;
@ -32,3 +32,7 @@ export const STATE_DETAIL = "STATE_DETAIL";
export const USER_ISSUE = (workspaceSlug: string) => `USER_ISSUE_${workspaceSlug}`; export const USER_ISSUE = (workspaceSlug: string) => `USER_ISSUE_${workspaceSlug}`;
export const USER_PROJECT_VIEW = (projectId: string) => `USER_PROJECT_VIEW_${projectId}`; export const USER_PROJECT_VIEW = (projectId: string) => `USER_PROJECT_VIEW_${projectId}`;
export const MODULE_LIST = (projectId: string) => `MODULE_LIST_${projectId}`;
export const MODULE_ISSUES = (moduleId: string) => `MODULE_ISSUES_${moduleId}`;
export const MODULE_DETAIL = "MODULE_DETAIL";

View File

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

View File

@ -1,629 +0,0 @@
import React, { useState } from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
import Image from "next/image";
// services
import userService from "lib/services/user.service";
import authenticationService from "lib/services/authentication.service";
// hooks
import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
import useToast from "lib/hooks/useToast";
// headless ui
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
// icons
import {
ArrowPathIcon,
Bars3Icon,
ChevronDownIcon,
Cog6ToothIcon,
HomeIcon,
ClipboardDocumentListIcon,
PlusIcon,
RectangleStackIcon,
UserGroupIcon,
UserIcon,
XMarkIcon,
ArrowLongLeftIcon,
QuestionMarkCircleIcon,
EllipsisHorizontalIcon,
ClipboardDocumentIcon,
} from "@heroicons/react/24/outline";
// constants
import { classNames, copyTextToClipboard } from "constants/common";
// ui
import { CustomListbox, Spinner, Tooltip } from "ui";
// types
import type { IUser } from "types";
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 workspaceLinks = [
{
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 router = useRouter();
const { projects, user } = useUser();
const { projectId } = router.query;
const { workspaces, activeWorkspace, mutateUser } = useUser();
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
const { setToastAlert } = useToast();
return (
<nav className="h-full">
<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="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-2 items-center"
}`}
>
<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 focus:bg-gray-50 border border-gray-300 shadow-sm"
: ""
}`}
>
<div className="flex gap-x-1 items-center">
<div className="h-5 w-5 p-4 flex items-center justify-center bg-gray-700 text-white rounded uppercase relative">
{activeWorkspace?.logo && activeWorkspace.logo !== "" ? (
<Image
src={activeWorkspace.logo}
alt="Workspace Logo"
layout="fill"
objectFit="cover"
/>
) : (
activeWorkspace?.name?.charAt(0) ?? "N"
)}
</div>
{!sidebarCollapse && (
<p className="truncate w-20 text-left ml-1">
{activeWorkspace?.name ?? "Loading..."}
</p>
)}
</div>
{!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-20">
<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) => {
const isInProject =
router.pathname.includes("/[projectId]/");
if (isInProject) router.push("/workspace");
})
.catch((err) => console.error(err));
}}
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
as="button"
onClick={() => {
router.push("/create-workspace");
}}
className="w-full"
>
{({ active }) => (
<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 ${
active ? "bg-theme text-white" : "text-gray-900"
}`}
>
<PlusIcon className="w-5 h-5" />
<span>Create Workspace</span>
</a>
)}
</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 flex-shrink-0 w-full">
<div className="h-10 w-10">
<Menu.Button className="grid relative place-items-center h-full w-full rounded-md shadow-sm bg-white text-gray-700 hover:bg-gray-50 focus:outline-none">
{user?.avatar && user.avatar !== "" ? (
<Image
src={user.avatar}
alt="User Avatar"
layout="fill"
className="rounded-md"
/>
) : (
<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-20">
<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">
{workspaceLinks.map((link, index) => (
<Link key={index} href={link.href}>
<a
className={`${
link.href === router.asPath
? "bg-theme text-white"
: "hover:bg-indigo-100 focus:bg-indigo-100"
} flex items-center gap-3 p-2 text-xs font-medium rounded-md outline-none ${
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}>
{({ open }) => (
<>
<div className="flex items-center">
<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 flex-shrink-0">
{project?.name.charAt(0)}
</span>
{!sidebarCollapse && (
<span className="flex items-center justify-between w-full">
{project?.name}
<span>
<ChevronDownIcon
className={`h-4 w-4 duration-300 ${
open ? "rotate-180" : ""
}`}
/>
</span>
</span>
)}
</Disclosure.Button>
{!sidebarCollapse && (
<Menu as="div" className="relative inline-block">
<Menu.Button className="grid relative place-items-center focus:outline-none">
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<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 right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-20">
<div className="p-1">
<Menu.Item as="div">
{(active) => (
<button
className="flex items-center gap-2 p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${project?.id}/issues/`
).then(() => {
setToastAlert({
title: "Link Copied",
message: "Link copied to clipboard",
type: "success",
});
})
}
>
<ClipboardDocumentIcon className="h-3 w-3" />
Copy Link
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
<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 focus:bg-gray-100 focus:text-gray-900",
"group flex items-center px-2 py-2 text-xs font-medium rounded-md outline-none",
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">
{!sidebarCollapse && (
<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={() => {
const e = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "p",
});
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-5 w-5" />
{!sidebarCollapse && "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 flex items-center ${
sidebarCollapse ? "flex-col-reverse" : ""
}`}
>
<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 outline-none ${
sidebarCollapse ? "justify-center w-full" : ""
}`}
onClick={() => toggleCollapsed()}
>
<ArrowLongLeftIcon
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
sidebarCollapse ? "rotate-180" : ""
}`}
/>
</button>
<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 outline-none ${
sidebarCollapse ? "justify-center w-full" : ""
}`}
onClick={() => {
const e = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "h",
});
document.dispatchEvent(e);
}}
title="Help"
>
<QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" />
</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;

View File

@ -1,8 +1,8 @@
import React, { useState } from "react"; import React, { useState } from "react";
// layouts // layouts
import Container from "layouts/Container"; import Container from "layouts/container";
import Sidebar from "layouts/Navbar/main-sidebar"; import Sidebar from "layouts/navbar/main-siderbar";
import Header from "layouts/Navbar/Header"; import Header from "layouts/navbar/header";
// components // components
import CreateProjectModal from "components/project/create-project-modal"; import CreateProjectModal from "components/project/create-project-modal";
// types // types
@ -13,7 +13,9 @@ const AppLayout: React.FC<Props> = ({
children, children,
noPadding = false, noPadding = false,
bg = "primary", bg = "primary",
noHeader = false,
breadcrumbs, breadcrumbs,
left,
right, right,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -24,7 +26,7 @@ const AppLayout: React.FC<Props> = ({
<div className="h-screen w-full flex overflow-x-hidden"> <div className="h-screen w-full flex overflow-x-hidden">
<Sidebar /> <Sidebar />
<main className="h-screen w-full flex flex-col overflow-y-auto min-w-0"> <main className="h-screen w-full flex flex-col overflow-y-auto min-w-0">
<Header breadcrumbs={breadcrumbs} right={right} /> {noHeader ? null : <Header breadcrumbs={breadcrumbs} left={left} right={right} />}
<div <div
className={`w-full flex-grow ${noPadding ? "" : "p-5"} ${ className={`w-full flex-grow ${noPadding ? "" : "p-5"} ${
bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary" bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary"

View File

@ -19,6 +19,7 @@ import {
XMarkIcon, XMarkIcon,
ArrowLongLeftIcon, ArrowLongLeftIcon,
QuestionMarkCircleIcon, QuestionMarkCircleIcon,
RectangleGroupIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// constants // constants
import { classNames } from "constants/common"; import { classNames } from "constants/common";
@ -34,6 +35,11 @@ const navigation = (projectId: string) => [
href: `/projects/${projectId}/cycles`, href: `/projects/${projectId}/cycles`,
icon: ArrowPathIcon, icon: ArrowPathIcon,
}, },
{
name: "Modules",
href: `/projects/${projectId}/modules`,
icon: RectangleGroupIcon,
},
{ {
name: "Members", name: "Members",
href: `/projects/${projectId}/members`, href: `/projects/${projectId}/members`,
@ -138,7 +144,7 @@ const Sidebar: React.FC = () => {
</Transition.Root> </Transition.Root>
<div <div
className={`${ className={`${
sidebarCollapse ? "" : "w-auto md:w-64" sidebarCollapse ? "" : "w-auto md:w-60"
} h-full hidden md:inset-y-0 md:flex md:flex-col`} } h-full hidden md:inset-y-0 md:flex md:flex-col`}
> >
<div className="h-full flex flex-1 flex-col border-r border-gray-200"> <div className="h-full flex flex-1 flex-col border-r border-gray-200">

View File

@ -5,10 +5,10 @@ import { useRouter } from "next/router";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// layouts // layouts
import Container from "layouts/Container"; import Container from "layouts/container";
import Sidebar from "layouts/Navbar/main-sidebar"; import Sidebar from "layouts/navbar/main-siderbar";
import SettingsSidebar from "layouts/Navbar/settings-sidebar"; import SettingsSidebar from "layouts/navbar/settings-sidebar";
import Header from "layouts/Navbar/Header"; import Header from "layouts/navbar/header";
// components // components
import CreateProjectModal from "components/project/create-project-modal"; import CreateProjectModal from "components/project/create-project-modal";
// types // types

View File

@ -10,6 +10,8 @@ export type Props = {
children: React.ReactNode; children: React.ReactNode;
noPadding?: boolean; noPadding?: boolean;
bg?: "primary" | "secondary"; bg?: "primary" | "secondary";
noHeader?: boolean;
breadcrumbs?: JSX.Element; breadcrumbs?: JSX.Element;
left?: JSX.Element;
right?: JSX.Element; right?: JSX.Element;
}; };

View File

@ -0,0 +1,119 @@
// api routes
import {
MODULES_ENDPOINT,
MODULE_DETAIL,
MODULE_ISSUES,
MODULE_ISSUE_DETAIL,
} from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class ProjectIssuesServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async getModules(workspaceSlug: string, projectId: string): Promise<any> {
return this.get(MODULES_ENDPOINT(workspaceSlug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async createModule(workspaceSlug: string, projectId: string, data: any): Promise<any> {
return this.post(MODULES_ENDPOINT(workspaceSlug, projectId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateModule(
workspaceSlug: string,
projectId: string,
moduleId: string,
data: any
): Promise<any> {
return this.put(MODULE_DETAIL(workspaceSlug, projectId, moduleId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async patchModule(
workspaceSlug: string,
projectId: string,
moduleId: string,
data: any
): Promise<any> {
return this.patch(MODULE_DETAIL(workspaceSlug, projectId, moduleId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteModule(workspaceSlug: string, projectId: string, moduleId: string): Promise<any> {
return this.delete(MODULE_DETAIL(workspaceSlug, projectId, moduleId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getModuleIssues(workspaceSlug: string, projectId: string, moduleId: string): Promise<any> {
return this.get(MODULE_ISSUES(workspaceSlug, projectId, moduleId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async addIssueToModule(
workspaceSlug: string,
projectId: string,
moduleId: string,
data: any
): Promise<any> {
return this.post(MODULE_ISSUES(workspaceSlug, projectId, moduleId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async removeIssueFromModule(
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
): Promise<any> {
return this.delete(MODULE_ISSUE_DETAIL(workspaceSlug, projectId, moduleId, issueId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new ProjectIssuesServices();

View File

@ -3,56 +3,71 @@ import React from "react";
// next // next
import Link from "next/link"; import Link from "next/link";
import type { NextPage } from "next"; import type { NextPage } from "next";
import Image from "next/image";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// headless ui // headless ui
import { Transition, Popover } from "@headlessui/react"; import { Disclosure, Listbox, Menu, Popover, Transition } from "@headlessui/react";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// headless ui
// ui // ui
import { Spinner, Breadcrumbs, BreadcrumbItem, EmptySpace, EmptySpaceItem, HeaderButton } from "ui";
// icons
import { import {
Spinner, CalendarDaysIcon,
HeaderButton, ChevronDownIcon,
EmptySpace, EllipsisHorizontalIcon,
EmptySpaceItem, PlusIcon,
Breadcrumbs, RectangleStackIcon,
BreadcrumbItem, } from "@heroicons/react/24/outline";
CustomMenu, import User from "public/user.png";
} from "ui";
// constants
import { USER_ISSUE } from "constants/fetch-keys";
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// services // services
import userService from "lib/services/user.service"; import userService from "lib/services/user.service";
import issuesServices from "lib/services/issues.service"; import issuesServices from "lib/services/issues.service";
import workspaceService from "lib/services/workspace.service";
// hooks
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// hoc // hoc
import withAuth from "lib/hoc/withAuthWrapper"; import withAuth from "lib/hoc/withAuthWrapper";
import useMyIssuesProperties from "lib/hooks/useMyIssueFilter"; import useMyIssuesProperties from "lib/hooks/useMyIssueFilter";
// components // components
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown"; import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
// icons
import { ChevronDownIcon, PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, NestedKeyOf, Properties } from "types"; import { IIssue, IWorkspaceMember, Properties } from "types";
// constants
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [ import { USER_ISSUE, WORKSPACE_MEMBERS } from "constants/fetch-keys";
{ name: "State", key: "state_detail.name" }, import {
{ name: "Priority", key: "priority" }, addSpaceIfCamelCase,
{ name: "Cycle", key: "issue_cycle.cycle_detail.name" }, classNames,
{ name: "Created By", key: "created_by" }, findHowManyDaysLeft,
{ name: "None", key: null }, renderShortNumericDateFormat,
]; replaceUnderscoreIfSnakeCase,
} from "constants/common";
import { PRIORITIES } from "constants/";
const MyIssues: NextPage = () => { const MyIssues: NextPage = () => {
const { user, activeWorkspace } = useUser(); const { activeWorkspace, user, states } = useUser();
console.log(states);
const { data: myIssues, mutate: mutateMyIssues } = useSWR<IIssue[]>( const { data: myIssues, mutate: mutateMyIssues } = useSWR<IIssue[]>(
user && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null, user && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null,
user && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null user && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null
); );
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
const [properties, setProperties] = useIssuesProperties(
activeWorkspace?.slug,
"21b5fab2-cb0c-4875-9496-619134bf1f32"
);
const updateMyIssues = ( const updateMyIssues = (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -84,13 +99,8 @@ const MyIssues: NextPage = () => {
}); });
}; };
const { const { filteredIssues, setMyIssueGroupByProperty, setMyIssueProperty, groupByProperty } =
filteredIssues, useMyIssuesProperties(myIssues);
properties,
setMyIssueGroupByProperty,
setMyIssueProperty,
groupByProperty,
} = useMyIssuesProperties(myIssues);
return ( return (
<AppLayout <AppLayout
@ -100,7 +110,7 @@ const MyIssues: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
right={ right={
<> <div className="flex items-center gap-2">
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (
<> <>
@ -123,27 +133,8 @@ const MyIssues: NextPage = () => {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-3 bg-white rounded-lg shadow-lg"> <Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-3 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4"> <div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"Select"
}
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setMyIssueGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative flex flex-col gap-1"> <div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4> <h4 className="text-base text-gray-600">Properties</h4>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@ -156,7 +147,7 @@ const MyIssues: NextPage = () => {
? "border-theme bg-theme text-white" ? "border-theme bg-theme text-white"
: "" : ""
}`} }`}
onClick={() => setMyIssueProperty(key as keyof Properties)} onClick={() => setProperties(key as keyof Properties)}
> >
{replaceUnderscoreIfSnakeCase(key)} {replaceUnderscoreIfSnakeCase(key)}
</button> </button>
@ -180,88 +171,454 @@ const MyIssues: NextPage = () => {
document.dispatchEvent(e); document.dispatchEvent(e);
}} }}
/> />
</> </div>
} }
> >
<div className="w-full h-full flex flex-col space-y-5"> <div className="w-full h-full flex flex-col space-y-5">
{myIssues ? ( {myIssues ? (
<> <>
{myIssues.length > 0 ? ( {myIssues.length > 0 ? (
<div className="flex flex-col"> <div className="flex flex-col space-y-5">
<div className="overflow-x-auto "> <Disclosure as="div" defaultOpen>
<div className="inline-block min-w-full align-middle px-0.5 py-2"> {({ open }) => (
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg"> <div className="bg-white rounded-lg">
<table className="min-w-full"> <div className="bg-gray-100 px-4 py-3 rounded-t-lg">
<thead className="bg-gray-100"> <Disclosure.Button>
<tr className="text-left"> <div className="flex items-center gap-x-2">
<th <span>
scope="col" <ChevronDownIcon
className="px-3 py-3.5 text-sm font-semibold text-gray-900" className={`h-4 w-4 text-gray-500 ${
> !open ? "transform -rotate-90" : ""
NAME }`}
</th> />
<th </span>
scope="col" <h2 className="font-medium leading-5">My Issues</h2>
className="px-3 py-3.5 text-sm font-semibold text-gray-900" <p className="text-gray-500 text-sm">{myIssues.length}</p>
> </div>
DESCRIPTION </Disclosure.Button>
</th> </div>
<th <Transition
scope="col" show={open}
className="px-3 py-3.5 text-sm font-semibold text-gray-900" enter="transition duration-100 ease-out"
> enterFrom="transform opacity-0"
PROJECT enterTo="transform opacity-100"
</th> leave="transition duration-75 ease-out"
<th leaveFrom="transform opacity-100"
scope="col" leaveTo="transform opacity-0"
className="px-3 py-3.5 text-sm font-semibold text-gray-900" >
> <Disclosure.Panel>
PRIORITY <div className="divide-y-2">
</th> {myIssues.map((issue: IIssue) => {
<th const assignees = [
scope="col" ...(issue?.assignees_list ?? []),
className="px-3 py-3.5 text-sm font-semibold text-gray-900" ...(issue?.assignees ?? []),
> ]?.map((assignee) => {
STATUS const tempPerson = people?.find(
</th> (p) => p.member.id === assignee
</tr> )?.member;
</thead>
<tbody className="bg-white"> return {
{myIssues.map((myIssue, index) => ( avatar: tempPerson?.avatar,
<tr first_name: tempPerson?.first_name,
key={myIssue.id} email: tempPerson?.email,
className={classNames( };
index === 0 ? "border-gray-300" : "border-gray-200", });
"border-t text-sm text-gray-900"
)} return (
> <div
<td className="px-3 py-4 text-sm font-medium text-gray-900 hover:text-theme max-w-[15rem] duration-300"> key={issue.id}
<Link href={`/projects/${myIssue.project}/issues/${myIssue.id}`}> className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
<a>{myIssue.name}</a> >
</Link> <div className="flex items-center gap-2">
</td> <span
<td className="px-3 py-4 max-w-[15rem] truncate"> className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
{/* {myIssue.description} */} style={{
</td> backgroundColor: issue.state_detail.color,
<td className="px-3 py-4"> }}
{myIssue.project_detail?.name} />
<br /> <Link href={`/projects/${issue.project}/issues/${issue.id}`}>
<span className="text-xs">{`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`}</span> <a className="group relative flex items-center gap-2">
</td> {/* {properties.key && (
<td className="px-3 py-4 capitalize">{myIssue.priority}</td> <span className="flex-shrink-0 text-xs text-gray-500">
<td className="relative px-3 py-4"> {issue.project_detail.identifier}-{issue.sequence_id}
<ChangeStateDropdown </span>
issue={myIssue} )} */}
updateIssues={updateMyIssues} <span className="">{issue.name}</span>
/> <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
</td> <h5 className="font-medium mb-1">Name</h5>
</tr> <div>{issue.name}</div>
))} </div>
</tbody> </a>
</table> </Link>
</div>
<div className="flex-shrink-0 flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
// partialUpdateIssue({ priority: data }, issue.id);
}}
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{issue.priority ?? "None"}
</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>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Priority
</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</>
)}
</Listbox>
)}
{properties.state && (
<Listbox
as="div"
value={issue.state}
onChange={(data: string) => {
// partialUpdateIssue({ state: data }, issue.id);
}}
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
></span>
{addSpaceIfCamelCase(issue.state_detail.name)}
</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>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue.state_detail.name}</div>
</div>
</>
)}
</Listbox>
)}
{properties.start_date && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date
? renderShortNumericDateFormat(issue.start_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(issue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 &&
"text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Target date
</h5>
<div>
{renderShortNumericDateFormat(issue.target_date ?? "")}
</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
{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="group relative flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button>
<div className="flex items-center gap-1 text-xs cursor-pointer">
{assignees.length > 0 ? (
assignees.map((assignee, 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?.first_name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
>
{assignee.first_name?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
</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 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 p-2"
)
}
value={person.member.id}
>
<div
className={`flex items-center gap-x-1 ${
assignees.includes({
avatar: person.member.avatar,
first_name: person.member.first_name,
email: person.member.email,
})
? "font-medium"
: "font-normal"
}`}
>
{person.member.avatar &&
person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={person.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
) : (
<div className="h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
{person.member.first_name &&
person.member.first_name !== ""
? person.member.first_name.charAt(0)
: person.member.email.charAt(0)}
</div>
)}
<p>
{person.member.first_name &&
person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details
.map((assignee) => assignee.first_name)
.join(", ")
: "No one"}
</div>
</div>
</>
)}
</Listbox>
)}
<Menu as="div" className="relative">
<Menu.Button
as="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none`}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<Menu.Item>
<button
type="button"
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
onClick={() => {
// setSelectedIssue({
// ...issue,
// actionType: "edit",
// });
}}
>
Edit
</button>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
type="button"
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
onClick={() => {
// handleDeleteIssue(issue.id);
}}
>
Delete permanently
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
);
})}
</div>
</Disclosure.Panel>
</Transition>
</div> </div>
</div> )}
</div> </Disclosure>
</div> </div>
) : ( ) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4"> <div className="w-full h-full flex flex-col justify-center items-center px-4">

View File

@ -1,6 +1,7 @@
// react // react
import React from "react"; import React, { useState } from "react";
// next // next
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr // swr
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
@ -9,8 +10,8 @@ import { DropResult } from "react-beautiful-dnd";
// layouots // layouots
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// components // components
import CyclesListView from "components/project/cycles/ListView"; import CyclesListView from "components/project/cycles/list-view";
import CyclesBoardView from "components/project/cycles/BoardView"; import CyclesBoardView from "components/project/cycles/board-view";
// services // services
import issuesServices from "lib/services/issues.service"; import issuesServices from "lib/services/issues.service";
import cycleServices from "lib/services/cycles.service"; import cycleServices from "lib/services/cycles.service";
@ -20,24 +21,28 @@ import useUser from "lib/hooks/useUser";
import useIssuesFilter from "lib/hooks/useIssuesFilter"; import useIssuesFilter from "lib/hooks/useIssuesFilter";
import useIssuesProperties from "lib/hooks/useIssuesProperties"; import useIssuesProperties from "lib/hooks/useIssuesProperties";
// headless ui // headless ui
import { Menu, Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
// ui // ui
import { BreadcrumbItem, Breadcrumbs, CustomMenu } from "ui"; import { BreadcrumbItem, Breadcrumbs, CustomMenu } from "ui";
// icons // icons
import { Squares2X2Icon } from "@heroicons/react/20/solid"; import { Squares2X2Icon } from "@heroicons/react/20/solid";
import { import { ArrowPathIcon, ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline";
ArrowPathIcon,
ChevronDownIcon,
EllipsisHorizontalIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
// types // types
import { CycleIssueResponse, IIssue, NestedKeyOf, Properties } from "types"; import {
CycleIssueResponse,
IIssue,
NestedKeyOf,
Properties,
SelectIssue,
SelectSprintType,
} from "types";
// fetch-keys // fetch-keys
import { CYCLE_ISSUES, PROJECT_MEMBERS } from "constants/fetch-keys"; import { CYCLE_ISSUES, PROJECT_MEMBERS } from "constants/fetch-keys";
// constants // constants
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common"; import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
import Link from "next/link"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CycleIssuesListModal from "components/project/cycles/cycle-issues-list-modal";
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [ const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" }, { name: "State", key: "state_detail.name" },
@ -73,7 +78,12 @@ const filterIssueOptions: Array<{
type Props = {}; type Props = {};
const SingleCycle: React.FC<Props> = () => { const SingleCycle: React.FC<Props> = () => {
const { activeWorkspace, activeProject, cycles } = useUser(); const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [selectedCycle, setSelectedCycle] = useState<SelectSprintType>();
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const { activeWorkspace, activeProject, cycles, issues } = useUser();
const router = useRouter(); const router = useRouter();
@ -91,9 +101,8 @@ const SingleCycle: React.FC<Props> = () => {
cycleServices.getCycleIssues(activeWorkspace?.slug, activeProject?.id, cycleId as string) cycleServices.getCycleIssues(activeWorkspace?.slug, activeProject?.id, cycleId as string)
: null : null
); );
const cycleIssuesArray = cycleIssues?.map((issue) => { const cycleIssuesArray = cycleIssues?.map((issue) => {
return issue.issue_details; return { bridge: issue.id, ...issue.issue_details };
}); });
const { data: members } = useSWR( const { data: members } = useSWR(
@ -121,6 +130,25 @@ const SingleCycle: React.FC<Props> = () => {
filterIssue, filterIssue,
} = useIssuesFilter(cycleIssuesArray ?? []); } = useIssuesFilter(cycleIssuesArray ?? []);
const openCreateIssueModal = (
issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create"
) => {
const cycle = cycles?.find((cycle) => cycle.id === cycleId);
if (cycle) {
setSelectedCycle({
...cycle,
actionType: "create-issue",
});
if (issue) setSelectedIssues({ ...issue, actionType });
setIsIssueModalOpen(true);
}
};
const openIssuesListModal = () => {
setCycleIssuesListModal(true);
};
const addIssueToCycle = (cycleId: string, issueId: string) => { const addIssueToCycle = (cycleId: string, issueId: string) => {
if (!activeWorkspace || !activeProject?.id) return; if (!activeWorkspace || !activeProject?.id) return;
@ -180,16 +208,16 @@ const SingleCycle: React.FC<Props> = () => {
// console.log(result); // console.log(result);
}; };
const removeIssueFromCycle = (cycleId: string, bridgeId: string) => { const removeIssueFromCycle = (bridgeId: string) => {
if (activeWorkspace && activeProject) { if (activeWorkspace && activeProject) {
mutate<CycleIssueResponse[]>( mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId), CYCLE_ISSUES(cycleId as string),
(prevData) => prevData?.filter((p) => p.id !== bridgeId), (prevData) => prevData?.filter((p) => p.id !== bridgeId),
false false
); );
issuesServices issuesServices
.removeIssueFromCycle(activeWorkspace.slug, activeProject.id, cycleId, bridgeId) .removeIssueFromCycle(activeWorkspace.slug, activeProject.id, cycleId as string, bridgeId)
.then((res) => { .then((res) => {
console.log(res); console.log(res);
}) })
@ -200,216 +228,218 @@ const SingleCycle: React.FC<Props> = () => {
}; };
return ( return (
<AppLayout <>
breadcrumbs={ <CreateUpdateIssuesModal
<Breadcrumbs> isOpen={
<BreadcrumbItem isIssueModalOpen &&
title={`${activeProject?.name ?? "Project"} Cycles`} selectedCycle?.actionType === "create-issue" &&
link={`/projects/${activeProject?.id}/cycles`} selectedIssues?.actionType !== "delete"
/> }
{/* <BreadcrumbItem title={`${cycles?.find((c) => c.id === cycleId)?.name ?? "Cycle"} `} /> */} data={selectedIssues}
<Menu as="div" className="relative inline-block"> prePopulateData={{ sprints: selectedCycle?.id }}
<Menu.Button className="flex items-center gap-1 border ml-3 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium"> setIsOpen={setIsIssueModalOpen}
<ArrowPathIcon className="h-3 w-3" /> projectId={activeProject?.id}
Cycle />
</Menu.Button> <CycleIssuesListModal
isOpen={cycleIssuesListModal}
<Transition handleClose={() => setCycleIssuesListModal(false)}
as={React.Fragment} issues={issues}
enter="transition ease-out duration-100" cycleId={cycleId as string}
enterFrom="transform opacity-0 scale-95" />
enterTo="transform opacity-100 scale-100" <AppLayout
leave="transition ease-in duration-75" breadcrumbs={
leaveFrom="transform opacity-100 scale-100" <Breadcrumbs>
leaveTo="transform opacity-0 scale-95" <BreadcrumbItem
> title={`${activeProject?.name ?? "Project"} Cycles`}
<Menu.Items className="absolute left-3 mt-2 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10"> link={`/projects/${activeProject?.id}/cycles`}
{cycles?.map((cycle) => ( />
<Menu.Item key={cycle.id}> </Breadcrumbs>
<Link href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}> }
<a left={
className={`block text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full ${ <CustomMenu
cycle.id === cycleId ? "bg-theme text-white" : "" label={
}`}
>
{cycle.name}
</a>
</Link>
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<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>
<Popover className="relative">
{({ open }) => (
<> <>
<Popover.Button <ArrowPathIcon className="h-3 w-3" />
className={classNames( {cycles?.find((c) => c.id === cycleId)?.name}
open ? "bg-gray-100 text-gray-900" : "text-gray-500", </>
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2" }
)} className="ml-1.5"
> >
<span>View</span> {cycles?.map((cycle) => (
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" /> <CustomMenu.MenuItem
</Popover.Button> key={cycle.id}
renderAs="a"
href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}
>
{cycle.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
}
right={
<div className="flex items-center gap-2">
<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>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2"
)}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition <Transition
as={React.Fragment} as={React.Fragment}
enter="transition ease-out duration-200" enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1" enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150" leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-3 bg-white rounded-lg shadow-lg overflow-hidden"> <Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform p-3 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4"> <div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Group by</h4> <h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu <CustomMenu
label={ label={
groupByOptions.find((option) => option.key === groupByProperty)?.name ?? groupByOptions.find((option) => option.key === groupByProperty)
"Select" ?.name ?? "Select"
} }
> width="auto"
{groupByOptions.map((option) => ( >
<CustomMenu.MenuItem {groupByOptions.map((option) => (
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={option.key} key={option.key}
onClick={() => setOrderBy(option.key)} onClick={() => setGroupByProperty(option.key)}
> >
{option.name} {option.name}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
) ))}
)} </CustomMenu>
</CustomMenu> </div>
</div> <div className="flex justify-between items-center">
<div className="flex justify-between items-center"> <h4 className="text-sm text-gray-600">Order by</h4>
<h4 className="text-sm text-gray-600">Issue type</h4> <CustomMenu
<CustomMenu label={
label={ orderByOptions.find((option) => option.key === orderBy)?.name ??
filterIssueOptions.find((option) => option.key === filterIssue)?.name ?? "Select"
"Select" }
} width="auto"
> >
{filterIssueOptions.map((option) => ( {orderByOptions.map((option) =>
<CustomMenu.MenuItem groupByProperty === "priority" && option.key === "priority" ? null : (
key={option.key} <CustomMenu.MenuItem
onClick={() => setFilterIssue(option.key)} key={option.key}
> onClick={() => setOrderBy(option.key)}
{option.name} >
</CustomMenu.MenuItem> {option.name}
))} </CustomMenu.MenuItem>
</CustomMenu> )
</div> )}
<div className="border-b-2"></div> </CustomMenu>
<div className="relative flex flex-col gap-1"> </div>
<h4 className="text-base text-gray-600">Properties</h4> <div className="flex justify-between items-center">
<div className="flex items-center gap-2 flex-wrap"> <h4 className="text-sm text-gray-600">Issue type</h4>
{Object.keys(properties).map((key) => ( <CustomMenu
<button label={
key={key} filterIssueOptions.find((option) => option.key === filterIssue)
type="button" ?.name ?? "Select"
className={`px-2 py-1 capitalize rounded border border-theme text-xs ${ }
properties[key as keyof Properties] width="auto"
? "border-theme bg-theme text-white" >
: "" {filterIssueOptions.map((option) => (
}`} <CustomMenu.MenuItem
onClick={() => setProperties(key as keyof Properties)} key={option.key}
> onClick={() => setFilterIssue(option.key)}
{replaceUnderscoreIfSnakeCase(key)} >
</button> {option.name}
))} </CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div className="flex items-center gap-2 flex-wrap">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 capitalize rounded border border-theme text-xs ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div> </div>
</div> </div>
</div> </Popover.Panel>
</Popover.Panel> </Transition>
</Transition> </>
</> )}
)} </Popover>
</Popover> </div>
</div> }
} >
> {issueView === "list" ? (
{issueView === "list" ? ( <CyclesListView
<CyclesListView
groupedByIssues={groupedByIssues}
selectedGroup={groupByProperty}
properties={properties}
openCreateIssueModal={() => {
return;
}}
openIssuesListModal={() => {
return;
}}
removeIssueFromCycle={removeIssueFromCycle}
/>
) : (
<div className="h-screen">
<CyclesBoardView
groupedByIssues={groupedByIssues} groupedByIssues={groupedByIssues}
properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
selectedGroup={groupByProperty} selectedGroup={groupByProperty}
members={members} properties={properties}
openCreateIssueModal={() => { openCreateIssueModal={openCreateIssueModal}
return; openIssuesListModal={openIssuesListModal}
}} removeIssueFromCycle={removeIssueFromCycle}
openIssuesListModal={() => {
return;
}}
/> />
</div> ) : (
)} <div className="h-screen">
</AppLayout> <CyclesBoardView
groupedByIssues={groupedByIssues}
properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
selectedGroup={groupByProperty}
members={members}
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
/>
</div>
)}
</AppLayout>
</>
); );
}; };

View File

@ -3,49 +3,33 @@ import React, { useEffect, useState } from "react";
// next // next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import type { NextPage } from "next"; import type { NextPage } from "next";
import Link from "next/link";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// services // services
import issuesServices from "lib/services/issues.service";
import sprintService from "lib/services/cycles.service"; import sprintService from "lib/services/cycles.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// fetching keys
import { CYCLE_LIST } from "constants/fetch-keys";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// components // components
import CycleIssuesListModal from "components/project/cycles/CycleIssuesListModal"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; import CycleStatsView from "components/project/cycles/stats-view";
import ConfirmSprintDeletion from "components/project/cycles/ConfirmCycleDeletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui // ui
import { BreadcrumbItem, Breadcrumbs, HeaderButton, Spinner, EmptySpace, EmptySpaceItem } from "ui"; import { BreadcrumbItem, Breadcrumbs, HeaderButton, Spinner, EmptySpace, EmptySpaceItem } from "ui";
// icons // icons
import { ArrowPathIcon, ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline"; import { ArrowPathIcon, PlusIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, ICycle, SelectSprintType, SelectIssue, Properties } from "types"; import { ICycle, SelectSprintType } from "types";
// constants // fetching keys
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common"; import { CYCLE_LIST } from "constants/fetch-keys";
const ProjectSprints: NextPage = () => { const ProjectSprints: NextPage = () => {
const [isOpen, setIsOpen] = useState(false); const [selectedCycle, setSelectedCycle] = useState<SelectSprintType>();
const [selectedSprint, setSelectedSprint] = useState<SelectSprintType>(); const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false); const { activeWorkspace, activeProject } = useUser();
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>();
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const [cycleId, setCycleId] = useState("");
const { activeWorkspace, activeProject, issues } = useUser();
const router = useRouter(); const router = useRouter();
@ -58,45 +42,13 @@ const ProjectSprints: NextPage = () => {
: null : null
); );
const [properties, setProperties] = useIssuesProperties(
activeWorkspace?.slug,
projectId as string
);
const openCreateIssueModal = (
cycleId: string,
issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create"
) => {
const cycle = cycles?.find((cycle) => cycle.id === cycleId);
if (cycle) {
setSelectedSprint({
...cycle,
actionType: "create-issue",
});
if (issue) setSelectedIssues({ ...issue, actionType });
setIsIssueModalOpen(true);
}
};
const openIssuesListModal = (cycleId: string) => {
setCycleId(cycleId);
setCycleIssuesListModal(true);
};
useEffect(() => { useEffect(() => {
if (isOpen) return; if (createUpdateCycleModal) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
setSelectedSprint(undefined); setSelectedCycle(undefined);
clearTimeout(timer); clearTimeout(timer);
}, 500); }, 500);
}, [isOpen]); }, [createUpdateCycleModal]);
useEffect(() => {
if (selectedIssues?.actionType === "delete") {
setDeleteIssue(selectedIssues.id);
}
}, [selectedIssues]);
return ( return (
<AppLayout <AppLayout
@ -110,105 +62,33 @@ const ProjectSprints: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
right={ right={
<div className="flex items-center gap-2"> <HeaderButton
<Popover className="relative"> Icon={PlusIcon}
{({ open }) => ( label="Add Cycle"
<> onClick={() => {
<Popover.Button const e = new KeyboardEvent("keydown", {
className={classNames( ctrlKey: true,
open ? "bg-gray-100 text-gray-900" : "text-gray-500", key: "q",
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2" });
)} document.dispatchEvent(e);
> }}
<span>View</span> />
<ChevronDownIcon className="h-4 w-4" 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="absolute mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-4 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div>
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 inline capitalize rounded border border-theme text-sm m-1 ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<HeaderButton Icon={PlusIcon} label="Add Cycle" onClick={() => setIsOpen(true)} />
</div>
} }
> >
<CreateUpdateSprintsModal <CreateUpdateCycleModal
isOpen={ isOpen={createUpdateCycleModal}
isOpen && setIsOpen={setCreateUpdateCycleModal}
selectedSprint?.actionType !== "delete" &&
selectedSprint?.actionType !== "create-issue"
}
setIsOpen={setIsOpen}
data={selectedSprint}
projectId={projectId as string} projectId={projectId as string}
/> data={selectedCycle}
<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}
/>
<CycleIssuesListModal
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
issues={issues}
cycleId={cycleId}
/> />
{cycles ? ( {cycles ? (
cycles.length > 0 ? ( cycles.length > 0 ? (
<div className="space-y-5"> <div className="space-y-5">
{cycles.map((cycle) => ( <CycleStatsView
<Link key={cycle.id} href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}> cycles={cycles}
<a className="block bg-white p-3 rounded-md">{cycle.name}</a> setCreateUpdateCycleModal={setCreateUpdateCycleModal}
</Link> setSelectedCycle={setSelectedCycle}
))} />
</div> </div>
) : ( ) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4"> <div className="w-full h-full flex flex-col justify-center items-center px-4">
@ -226,7 +106,7 @@ const ProjectSprints: NextPage = () => {
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}
action={() => setIsOpen(true)} action={() => setCreateUpdateCycleModal(true)}
/> />
</EmptySpace> </EmptySpace>
</div> </div>

View File

@ -1,4 +1,5 @@
// next // next
import Link from "next/link";
import type { NextPage } from "next"; import type { NextPage } from "next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -7,7 +8,7 @@ import React, { useCallback, useEffect, useState } from "react";
// swr // swr
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react hook form // react hook form
import { useForm, Controller } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Disclosure, Menu, Tab, Transition } from "@headlessui/react"; import { Disclosure, Menu, Tab, Transition } from "@headlessui/react";
// services // services
@ -27,10 +28,12 @@ import AppLayout from "layouts/app-layout";
// components // components
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection"; import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection";
import AddAsSubIssue from "components/project/issues/issue-detail/add-as-sub-issue";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
// common // common
import { debounce } from "constants/common"; import { debounce } from "constants/common";
// components // components
import IssueDetailSidebar from "components/project/issues/issue-detail/IssueDetailSidebar"; import IssueDetailSidebar from "components/project/issues/issue-detail/issue-detail-sidebar";
// activites // activites
import IssueActivitySection from "components/project/issues/issue-detail/activity"; import IssueActivitySection from "components/project/issues/issue-detail/activity";
// ui // ui
@ -46,9 +49,6 @@ import {
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
PlusIcon, PlusIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import Link from "next/link";
import AddAsSubIssue from "components/command-palette/addAsSubIssue";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
const RichTextEditor = dynamic(() => import("components/lexical/editor"), { const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false, ssr: false,

View File

@ -220,6 +220,7 @@ const ProjectIssues: NextPage = () => {
groupByOptions.find((option) => option.key === groupByProperty)?.name ?? groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"Select" "Select"
} }
width="auto"
> >
{groupByOptions.map((option) => ( {groupByOptions.map((option) => (
<CustomMenu.MenuItem <CustomMenu.MenuItem
@ -238,6 +239,7 @@ const ProjectIssues: NextPage = () => {
orderByOptions.find((option) => option.key === orderBy)?.name ?? orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select" "Select"
} }
width="auto"
> >
{orderByOptions.map((option) => {orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : ( groupByProperty === "priority" && option.key === "priority" ? null : (
@ -258,6 +260,7 @@ const ProjectIssues: NextPage = () => {
filterIssueOptions.find((option) => option.key === filterIssue)?.name ?? filterIssueOptions.find((option) => option.key === filterIssue)?.name ??
"Select" "Select"
} }
width="auto"
> >
{filterIssueOptions.map((option) => ( {filterIssueOptions.map((option) => (
<CustomMenu.MenuItem <CustomMenu.MenuItem

View File

@ -0,0 +1,104 @@
// next
import type { NextPage } from "next";
import useSWR from "swr";
import { useRouter } from "next/router";
// layouts
import AppLayout from "layouts/app-layout";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { BreadcrumbItem, Breadcrumbs, EmptySpace, EmptySpaceItem, HeaderButton, Spinner } from "ui";
// icons
import { PlusIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
// types
import { IModule } from "types/modules";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
const ProjectModules: NextPage = () => {
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { projectId } = router.query;
const { data: modules } = useSWR<IModule[]>(
activeWorkspace && projectId ? MODULE_LIST(projectId as string) : null,
activeWorkspace && projectId
? () => modulesService.getModules(activeWorkspace.slug, projectId as string)
: null
);
console.log(modules);
return (
<AppLayout
meta={{
title: "Plane - Modules",
}}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Modules`} />
</Breadcrumbs>
}
right={
<HeaderButton
Icon={PlusIcon}
label="Add Module"
onClick={() => {
const e = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "m",
});
document.dispatchEvent(e);
}}
/>
}
>
{modules ? (
modules.length > 0 ? (
<div className="space-y-5">
{modules.map((module) => (
<div key={module.id} className="bg-white p-3 rounded-md">
<h3>{module.name}</h3>
<p className="text-gray-500 text-sm mt-2">{module.description}</p>
</div>
))}
</div>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">
<EmptySpace
title="You don't have any module 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={RectangleGroupIcon}
>
<EmptySpaceItem
title="Create a new module"
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={() => {
return;
}}
/>
</EmptySpace>
</div>
)
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</AppLayout>
);
};
export default withAuth(ProjectModules);

View File

@ -13,6 +13,7 @@ import { Tab } from "@headlessui/react";
import withAuth from "lib/hoc/withAuthWrapper"; import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import SettingsLayout from "layouts/settings-layout"; import SettingsLayout from "layouts/settings-layout";
import AppLayout from "layouts/app-layout";
// service // service
import projectServices from "lib/services/project.service"; import projectServices from "lib/services/project.service";
// hooks // hooks
@ -155,14 +156,14 @@ const ProjectSettings: NextPage = () => {
]; ];
return ( return (
<SettingsLayout <AppLayout
breadcrumbs={ breadcrumbs={
<Breadcrumbs> <Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" /> <BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Settings`} /> <BreadcrumbItem title={`${activeProject?.name ?? "Project"} Settings`} />
</Breadcrumbs> </Breadcrumbs>
} }
links={sidebarLinks} // links={sidebarLinks}
> >
{projectDetails ? ( {projectDetails ? (
<div className="space-y-3"> <div className="space-y-3">
@ -209,7 +210,7 @@ const ProjectSettings: NextPage = () => {
<Spinner /> <Spinner />
</div> </div>
)} )}
</SettingsLayout> </AppLayout>
); );
}; };

View File

@ -57,7 +57,23 @@ const Projects: NextPage = () => {
}; };
return ( return (
<AppLayout> <AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Projects`} />
</Breadcrumbs>
}
right={
<HeaderButton
Icon={PlusIcon}
label="Add Project"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "p", ctrlKey: true });
document.dispatchEvent(e);
}}
/>
}
>
<ConfirmProjectDeletion <ConfirmProjectDeletion
isOpen={!!deleteProject} isOpen={!!deleteProject}
onClose={() => setDeleteProject(null)} onClose={() => setDeleteProject(null)}
@ -91,20 +107,6 @@ const Projects: NextPage = () => {
</div> </div>
) : ( ) : (
<div className="h-full w-full space-y-5"> <div className="h-full w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Projects`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Projects</h2>
<HeaderButton
Icon={PlusIcon}
label="Add Project"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "p", ctrlKey: true });
document.dispatchEvent(e);
}}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((item) => ( {projects.map((item) => (
<ProjectMemberInvitations <ProjectMemberInvitations

View File

@ -18,9 +18,14 @@ import userService from "lib/services/user.service";
// ui // ui
import { Spinner } from "ui"; import { Spinner } from "ui";
// icons // icons
import { ArrowRightIcon } from "@heroicons/react/24/outline"; import { ArrowRightIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
// types // types
import type { IIssue } from "types"; import type { IIssue } from "types";
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
const Workspace: NextPage = () => { const Workspace: NextPage = () => {
const { user, activeWorkspace, projects } = useUser(); const { user, activeWorkspace, projects } = useUser();
@ -46,7 +51,7 @@ const Workspace: NextPage = () => {
const hours = new Date().getHours(); const hours = new Date().getHours();
return ( return (
<AppLayout> <AppLayout noHeader={true}>
<div className="h-full w-full space-y-5"> <div className="h-full w-full space-y-5">
{user ? ( {user ? (
<div className="font-medium text-2xl"> <div className="font-medium text-2xl">
@ -78,49 +83,105 @@ const Workspace: NextPage = () => {
<div className="max-h-[30rem] overflow-y-auto w-full border border-gray-200 bg-white rounded-lg shadow-sm col-span-2"> <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 ? (
myIssues.length > 0 ? ( myIssues.length > 0 ? (
<table className="h-full w-full overflow-y-auto"> <div className="flex flex-col space-y-5">
<thead className="border-b bg-gray-50 text-sm"> <div className="bg-white rounded-lg">
<tr> <div className="bg-gray-100 px-4 py-3 rounded-t-lg">
<th scope="col" className="px-3 py-4 text-left"> <div className="flex items-center gap-x-2">
ISSUE <h2 className="font-medium leading-5">My Issues</h2>
</th> <p className="text-gray-500 text-sm">{myIssues.length}</p>
<th scope="col" className="px-3 py-4 text-left"> </div>
KEY </div>
</th> <div className="divide-y-2">
<th scope="col" className="px-3 py-4 text-left"> {myIssues.map((issue) => (
STATUS <div
</th> key={issue.id}
</tr> className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
</thead> >
<tbody> <div className="flex items-center gap-2">
{myIssues?.map((issue, index) => ( <span
<tr className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
className="border-t transition duration-300 ease-in-out hover:bg-gray-100 text-gray-900 gap-3 text-sm" style={{
key={index} backgroundColor: issue.state_detail.color,
> }}
<td className="px-3 py-4 font-medium"> />
<Link href={`/projects/${issue.project}/issues/${issue.id}`}> <Link href={`/projects/${issue.project}/issues/${issue.id}`}>
<a className="hover:text-theme duration-300">{issue.name}</a> <a className="group relative flex items-center gap-2">
</Link> {/* {properties.key && (
</td> <span className="flex-shrink-0 text-xs text-gray-500">
<td className="px-3 py-4"> {issue.project_detail.identifier}-{issue.sequence_id}
{issue.project_detail?.identifier}-{issue.sequence_id} </span>
</td> )} */}
<td className="px-3 py-4"> <span className="">{issue.name}</span>
<span <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
className="rounded px-2 py-1 text-xs font-medium" <h5 className="font-medium mb-1">Name</h5>
style={{ <div>{issue.name}</div>
border: `2px solid ${issue.state_detail.color}`, </div>
backgroundColor: `${issue.state_detail.color}20`, </a>
}} </Link>
> </div>
{issue.state_detail.name ?? "None"} <div className="flex-shrink-0 flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
</span> <div
</td> className={`rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
</tr> issue.priority === "urgent"
))} ? "bg-red-100 text-red-600"
</tbody> : issue.priority === "high"
</table> ? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{issue.priority ?? "None"}
</div>
<div className="flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
></span>
{addSpaceIfCamelCase(issue.state_detail.name)}
</div>
<div
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 &&
"text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Target date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
) : ( ) : (
<div className="m-10"> <div className="m-10">
<p className="text-gray-500 text-center">No Issues Found</p> <p className="text-gray-500 text-center">No Issues Found</p>

View File

@ -1,4 +1,4 @@
import type { IUser, IIssue } from "./"; import type { IUser, IIssue } from ".";
export interface ICycle { export interface ICycle {
id: string; id: string;

View File

@ -1,10 +1,11 @@
export * from "./users"; export * from "./users";
export * from "./workspace"; export * from "./workspace";
export * from "./sprints"; export * from "./cycles";
export * from "./projects"; export * from "./projects";
export * from "./state"; export * from "./state";
export * from "./invitation"; export * from "./invitation";
export * from "./issues"; export * from "./issues";
export * from "./modules";
export type NestedKeyOf<ObjectType extends object> = { export type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object

View File

@ -30,6 +30,7 @@ export interface IIssue {
label_details: any[]; label_details: any[];
assignee_details: IUser[]; assignee_details: IUser[];
assignees_list: string[]; assignees_list: string[];
bridge?: string;
blocked_by_issue_details: any[]; blocked_by_issue_details: any[];
blocked_issues: BlockeIssue[]; blocked_issues: BlockeIssue[];
blocker_issues: BlockeIssue[]; blocker_issues: BlockeIssue[];
@ -39,6 +40,18 @@ export interface IIssue {
updated_at: Date; updated_at: Date;
name: string; name: string;
// TODO change type of description // TODO change type of description
issue_cycle: {
created_at: Date;
created_by: string;
cycle: string;
cycle_detail: ICycle;
id: string;
issue: string;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;
};
description: any; description: any;
priority: string | null; priority: string | null;
start_date: string | null; start_date: string | null;

28
apps/app/types/modules.d.ts vendored Normal file
View File

@ -0,0 +1,28 @@
import type { IUser, IIssue, IProject } from ".";
export interface IModule {
created_at: Date;
created_by: string;
description: string;
description_text: any;
description_html: any;
id: string;
lead: string | null;
lead_detail: IUserLite;
members_list: string[];
name: string;
project: string;
project_detail: IProject;
start_date: Date | null;
status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled";
target_date: Date | null;
updated_at: Date;
updated_by: string;
workspace: string;
}
export type SelectModuleType =
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type SelectIssue = (IIssue & { actionType: "edit" | "delete" | "create" }) | undefined;

View File

@ -20,7 +20,7 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(
onClick, onClick,
type = "button", type = "button",
size = "sm", size = "sm",
className, className = "",
theme = "primary", theme = "primary",
disabled = false, disabled = false,
}, },
@ -39,7 +39,7 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(
disabled ? "opacity-70" : "" disabled ? "opacity-70" : ""
} text-white shadow-sm bg-theme hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 border border-transparent` } text-white shadow-sm bg-theme hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 border border-transparent`
: theme === "secondary" : theme === "secondary"
? "border bg-white" ? "border bg-white hover:bg-gray-100"
: theme === "success" : theme === "success"
? `${ ? `${
disabled ? "opacity-70" : "" disabled ? "opacity-70" : ""
@ -54,7 +54,7 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(
: size === "lg" : size === "lg"
? "px-4 py-2 text-base" ? "px-4 py-2 text-base"
: "px-2.5 py-2 text-sm", : "px-2.5 py-2 text-sm",
className || "" className
)} )}
> >
{children} {children}

View File

@ -4,28 +4,41 @@ import Link from "next/link";
// headless ui // headless ui
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/20/solid"; import { ChevronDownIcon, EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
// commons
import { classNames } from "constants/common";
// types // types
import type { MenuItemProps, Props } from "./types"; import type { MenuItemProps, Props } from "./types";
// constants
import { classNames } from "constants/common";
const CustomMenu = ({ children, label, textAlignment }: Props) => { const CustomMenu = ({
children,
label,
className = "",
ellipsis = false,
width,
textAlignment,
}: Props) => {
return ( return (
<Menu as="div" className="relative inline-block text-left"> <Menu as="div" className={`relative text-left ${className}`}>
<div> <div>
<Menu.Button {ellipsis ? (
className={`inline-flex w-32 justify-between gap-x-4 rounded-md border border-gray-300 bg-white px-4 py-1 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100 ${ <Menu.Button className="grid relative place-items-center hover:bg-gray-100 rounded p-1 focus:outline-none">
textAlignment === "right" <EllipsisHorizontalIcon className="h-4 w-4" />
? "text-right" </Menu.Button>
: textAlignment === "center" ) : (
? "text-center" <Menu.Button
: "text-left" className={`flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 w-full py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
}`} textAlignment === "right"
> ? "text-right"
<span className="truncate w-20">{label}</span> : textAlignment === "center"
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" /> ? "text-center"
</Menu.Button> : "text-left"
}`}
>
{label}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Menu.Button>
)}
</div> </div>
<Transition <Transition
@ -37,7 +50,11 @@ const CustomMenu = ({ children, label, textAlignment }: Props) => {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> <Menu.Items
className={`absolute right-0 z-10 mt-2 origin-top-right rounded-md bg-white text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
width === "auto" ? "min-w-full whitespace-nowrap" : "w-56"
}`}
>
<div className="py-1">{children}</div> <div className="py-1">{children}</div>
</Menu.Items> </Menu.Items>
</Transition> </Transition>
@ -48,14 +65,12 @@ const CustomMenu = ({ children, label, textAlignment }: Props) => {
const MenuItem: React.FC<MenuItemProps> = ({ children, renderAs, href, onClick }) => { const MenuItem: React.FC<MenuItemProps> = ({ children, renderAs, href, onClick }) => {
return ( return (
<Menu.Item> <Menu.Item>
{({ active }) => {({ active, close }) =>
renderAs === "a" ? ( renderAs === "a" ? (
<Link href={href ?? ""}> <Link href={href ?? ""}>
<a <a
className={classNames( className="block p-2 text-gray-700 hover:bg-indigo-50 hover:text-gray-900"
active ? "bg-gray-100 text-gray-900" : "text-gray-700", onClick={close}
"block px-4 py-2 text-sm"
)}
> >
{children} {children}
</a> </a>
@ -65,8 +80,8 @@ const MenuItem: React.FC<MenuItemProps> = ({ children, renderAs, href, onClick }
type="button" type="button"
onClick={onClick} onClick={onClick}
className={classNames( className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-gray-700", active ? "bg-indigo-50 text-gray-900" : "text-gray-700",
"block w-full px-4 py-2 text-left text-sm" "block w-full p-2 text-left"
)} )}
> >
{children} {children}

View File

@ -1,6 +1,9 @@
export type Props = { export type Props = {
children: React.ReactNode; children: React.ReactNode;
label: string; label?: string | JSX.Element;
className?: string;
ellipsis?: boolean;
width?: "auto";
textAlignment?: "left" | "center" | "right"; textAlignment?: "left" | "center" | "right";
}; };

View File

@ -0,0 +1,78 @@
import React from "react";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { ChevronDownIcon } from "@heroicons/react/20/solid";
type CustomSelectProps = {
value: any;
onChange: (props: any) => void;
children: React.ReactNode;
label: string | JSX.Element;
textAlignment?: "left" | "center" | "right";
};
const CustomSelect = ({ children, label, textAlignment, value, onChange }: CustomSelectProps) => {
return (
<Listbox
as="div"
value={value}
onChange={onChange}
className="relative text-left flex-shrink-0"
>
<div>
<Listbox.Button
className={`flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 w-full py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
textAlignment === "right"
? "text-right"
: textAlignment === "center"
? "text-center"
: "text-left"
}`}
>
{label}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Listbox.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"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 w-56 origin-top-right rounded-md bg-white text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">{children}</div>
</Listbox.Options>
</Transition>
</Listbox>
);
};
type OptionProps = {
children: string | JSX.Element;
value: string;
className?: string;
};
const Option: React.FC<OptionProps> = ({ children, value, className }) => {
return (
<Listbox.Option
value={value}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex items-center gap-2 text-gray-900 cursor-pointer select-none relative p-2 truncate ${className}`
}
>
{children}
</Listbox.Option>
);
};
CustomSelect.Option = Option;
export default CustomSelect;