mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
resolved merge conflict
This commit is contained in:
commit
eb175ba823
3
Procfile
Normal file
3
Procfile
Normal 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
|
@ -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/",
|
||||||
|
@ -10,6 +10,7 @@ from .project import (
|
|||||||
AddMemberToProjectEndpoint,
|
AddMemberToProjectEndpoint,
|
||||||
ProjectJoinEndpoint,
|
ProjectJoinEndpoint,
|
||||||
ProjectUserViewsEndpoint,
|
ProjectUserViewsEndpoint,
|
||||||
|
ProjectMemberUserEndpoint,
|
||||||
)
|
)
|
||||||
from .people import (
|
from .people import (
|
||||||
PeopleEndpoint,
|
PeopleEndpoint,
|
||||||
|
@ -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,
|
||||||
|
)
|
||||||
|
16
app.json
16
app.json
@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
|
@ -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;
|
|
@ -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> = ({
|
371
apps/app/components/project/cycles/board-view/single-board.tsx
Normal file
371
apps/app/components/project/cycles/board-view/single-board.tsx
Normal 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;
|
@ -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>
|
320
apps/app/components/project/cycles/list-view/index.tsx
Normal file
320
apps/app/components/project/cycles/list-view/index.tsx
Normal 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;
|
56
apps/app/components/project/cycles/stats-view/index.tsx
Normal file
56
apps/app/components/project/cycles/stats-view/index.tsx
Normal 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;
|
177
apps/app/components/project/cycles/stats-view/single-stat.tsx
Normal file
177
apps/app/components/project/cycles/stats-view/single-stat.tsx
Normal 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;
|
@ -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={{
|
||||||
|
@ -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
|
||||||
|
@ -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>;
|
||||||
|
@ -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";
|
||||||
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
249
apps/app/components/project/issues/issues-list-modal.tsx
Normal file
249
apps/app/components/project/issues/issues-list-modal.tsx
Normal 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;
|
@ -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;
|
@ -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) => (
|
||||||
|
@ -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}/`;
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
|
@ -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"
|
||||||
|
@ -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">
|
@ -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
|
||||||
|
2
apps/app/layouts/types.d.ts
vendored
2
apps/app/layouts/types.d.ts
vendored
@ -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;
|
||||||
};
|
};
|
||||||
|
119
apps/app/lib/services/modules.service.ts
Normal file
119
apps/app/lib/services/modules.service.ts
Normal 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();
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
104
apps/app/pages/projects/[projectId]/modules/index.tsx
Normal file
104
apps/app/pages/projects/[projectId]/modules/index.tsx
Normal 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);
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { IUser, IIssue } from "./";
|
import type { IUser, IIssue } from ".";
|
||||||
|
|
||||||
export interface ICycle {
|
export interface ICycle {
|
||||||
id: string;
|
id: string;
|
3
apps/app/types/index.d.ts
vendored
3
apps/app/types/index.d.ts
vendored
@ -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
|
||||||
|
13
apps/app/types/issues.d.ts
vendored
13
apps/app/types/issues.d.ts
vendored
@ -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
28
apps/app/types/modules.d.ts
vendored
Normal 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;
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
5
apps/app/ui/CustomMenu/types.d.ts
vendored
5
apps/app/ui/CustomMenu/types.d.ts
vendored
@ -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";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
78
apps/app/ui/custom-select/index.tsx
Normal file
78
apps/app/ui/custom-select/index.tsx
Normal 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;
|
Loading…
Reference in New Issue
Block a user