Merge branch 'stage-release' of https://github.com/makeplane/plane into stage-release

This commit is contained in:
vamsi 2022-12-22 00:14:18 +05:30
commit 2fae7a6200
191 changed files with 9973 additions and 2861 deletions

View File

@ -37,6 +37,7 @@ class IssueFlatSerializer(BaseSerializer):
"priority", "priority",
"start_date", "start_date",
"target_date", "target_date",
"sequence_id",
] ]

View File

@ -52,7 +52,6 @@ from plane.api.views import (
AddMemberToProjectEndpoint, AddMemberToProjectEndpoint,
ProjectJoinEndpoint, ProjectJoinEndpoint,
BulkDeleteIssuesEndpoint, BulkDeleteIssuesEndpoint,
BulkAssignIssuesToCycleEndpoint,
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
ModuleViewSet, ModuleViewSet,
ModuleIssueViewSet, ModuleIssueViewSet,
@ -444,11 +443,6 @@ urlpatterns = [
), ),
name="project-cycle", name="project-cycle",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/bulk-assign-issues/",
BulkAssignIssuesToCycleEndpoint.as_view(),
name="bulk-assign-cycle-issues",
),
## End Cycles ## End Cycles
# Issue # Issue
path( path(

View File

@ -38,7 +38,7 @@ from .workspace import (
from .state import StateViewSet from .state import StateViewSet
from .shortcut import ShortCutViewSet from .shortcut import ShortCutViewSet
from .view import ViewViewSet from .view import ViewViewSet
from .cycle import CycleViewSet, CycleIssueViewSet, BulkAssignIssuesToCycleEndpoint from .cycle import CycleViewSet, CycleIssueViewSet
from .asset import FileAssetEndpoint from .asset import FileAssetEndpoint
from .issue import ( from .issue import (
IssueViewSet, IssueViewSet,

View File

@ -1,9 +1,10 @@
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet
from plane.api.serializers import CycleSerializer, CycleIssueSerializer from plane.api.serializers import CycleSerializer, CycleIssueSerializer
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Cycle, CycleIssue, Issue from plane.db.models import Cycle, CycleIssue, Issue
@ -66,26 +67,27 @@ class CycleIssueViewSet(BaseViewSet):
.distinct() .distinct()
) )
def create(self, request, slug, project_id, cycle_id):
class BulkAssignIssuesToCycleEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id, cycle_id):
try: try:
issue_ids = request.data.get("issue_ids") issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
)
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id workspace__slug=slug, project_id=project_id, pk=cycle_id
) )
issues = Issue.objects.filter( issues = Issue.objects.filter(
pk__in=issue_ids, workspace__slug=slug, project_id=project_id pk__in=issues, workspace__slug=slug, project_id=project_id
) )
# Delete old records in order to maintain the database integrity
CycleIssue.objects.filter(issue_id__in=issues).delete()
CycleIssue.objects.bulk_create( CycleIssue.objects.bulk_create(
[ [
CycleIssue( CycleIssue(
@ -107,3 +109,9 @@ class BulkAssignIssuesToCycleEndpoint(BaseAPIView):
return Response( return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
) )
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

View File

@ -5,6 +5,7 @@ from django.db.models import Prefetch
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet from . import BaseViewSet
@ -14,7 +15,7 @@ from plane.api.serializers import (
ModuleIssueSerializer, ModuleIssueSerializer,
) )
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Module, ModuleIssue, Project from plane.db.models import Module, ModuleIssue, Project, Issue
class ModuleViewSet(BaseViewSet): class ModuleViewSet(BaseViewSet):
@ -71,6 +72,12 @@ class ModuleViewSet(BaseViewSet):
{"name": "The module name is already taken"}, {"name": "The module name is already taken"},
status=status.HTTP_410_GONE, status=status.HTTP_410_GONE,
) )
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class ModuleIssueViewSet(BaseViewSet): class ModuleIssueViewSet(BaseViewSet):
@ -107,3 +114,45 @@ class ModuleIssueViewSet(BaseViewSet):
.select_related("issue") .select_related("issue")
.distinct() .distinct()
) )
def create(self, request, slug, project_id, module_id):
try:
issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=module_id
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
)
ModuleIssue.objects.bulk_create(
[
ModuleIssue(
module=module,
issue=issue,
project_id=project_id,
workspace=module.workspace,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
except Module.DoesNotExist:
return Response(
{"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

View File

@ -52,7 +52,6 @@ class PeopleEndpoint(BaseAPIView):
class UserEndpoint(BaseViewSet): class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer serializer_class = UserSerializer
model = User model = User
serializers = {}
def get_object(self): def get_object(self):
return self.request.user return self.request.user

View File

@ -609,7 +609,13 @@ class ProjectUserViewsEndpoint(BaseAPIView):
{"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN {"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN
) )
project_member.view_props = request.data view_props = project_member.view_props
default_props = project_member.default_props
project_member.view_props = request.data.get("view_props", view_props)
project_member.default_props = request.data.get(
"default_props", default_props
)
project_member.save() project_member.save()
@ -632,7 +638,7 @@ class ProjectMemberUserEndpoint(BaseAPIView):
try: try:
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
project=project_id, workpsace__slug=slug, member=request.user project_id=project_id, workspace__slug=slug, member=request.user
) )
serializer = ProjectMemberSerializer(project_member) serializer = ProjectMemberSerializer(project_member)

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.14 on 2022-12-20 09:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0011_auto_20221216_0259'),
]
operations = [
migrations.AddField(
model_name='user',
name='my_issues_prop',
field=models.JSONField(null=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-12-20 11:29
from django.db import migrations, models
import plane.db.models.project
class Migration(migrations.Migration):
dependencies = [
('db', '0012_user_my_issues_prop'),
]
operations = [
migrations.AddField(
model_name='projectmember',
name='default_props',
field=models.JSONField(default=plane.db.models.project.get_default_props),
),
]

View File

@ -19,6 +19,15 @@ ROLE_CHOICES = (
) )
def get_default_props():
return {
"issueView": "list",
"groupByProperty": None,
"orderBy": None,
"filterIssue": None,
}
class Project(BaseModel): class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
@ -119,6 +128,7 @@ class ProjectMember(ProjectBaseModel):
comment = models.TextField(blank=True, null=True) comment = models.TextField(blank=True, null=True)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
view_props = models.JSONField(null=True) view_props = models.JSONField(null=True)
default_props = models.JSONField(default=get_default_props)
class Meta: class Meta:
unique_together = ["project", "member"] unique_together = ["project", "member"]

View File

@ -66,6 +66,7 @@ class User(AbstractBaseUser, PermissionsMixin):
last_login_uagent = models.TextField(blank=True) last_login_uagent = models.TextField(blank=True)
token_updated_at = models.DateTimeField(null=True) token_updated_at = models.DateTimeField(null=True)
last_workspace_id = models.UUIDField(null=True) last_workspace_id = models.UUIDField(null=True)
my_issues_prop = models.JSONField(null=True)
USERNAME_FIELD = "email" USERNAME_FIELD = "email"

View File

@ -23,7 +23,7 @@ import {
// components // components
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/create-update-issue-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui // ui
import { Button } from "ui"; import { Button } from "ui";
@ -260,7 +260,7 @@ const CommandPalette: React.FC = () => {
<li className="p-2"> <li className="p-2">
{query === "" && ( {query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900"> <h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Issues Select issues
</h2> </h2>
)} )}
<ul className="text-sm text-gray-700"> <ul className="text-sm text-gray-700">
@ -376,9 +376,9 @@ const CommandPalette: React.FC = () => {
)} )}
</Combobox> </Combobox>
<div className="flex justify-between items-center gap-2 p-3"> <div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm"> <Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected Delete selected issues
</Button> </Button>
<div> <div>
<Button type="button" size="sm" onClick={handleCommandPaletteClose}> <Button type="button" size="sm" onClick={handleCommandPaletteClose}>

View File

@ -1,15 +1,53 @@
import React from "react"; import React, { useState } from "react";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// icons // icons
import { XMarkIcon } from "@heroicons/react/20/solid"; import { XMarkIcon } from "@heroicons/react/20/solid";
// ui
import { Input } from "ui";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const shortcuts = [
{
title: "Navigation",
shortcuts: [
{ keys: "ctrl,/", description: "To open navigator" },
{ keys: "↑", description: "Move up" },
{ keys: "↓", description: "Move down" },
{ keys: "←", description: "Move left" },
{ keys: "→", description: "Move right" },
{ keys: "Enter", description: "Select" },
{ keys: "Esc", description: "Close" },
],
},
{
title: "Common",
shortcuts: [
{ keys: "ctrl,p", description: "To create project" },
{ keys: "ctrl,i", description: "To create issue" },
{ keys: "ctrl,q", description: "To create cycle" },
{ keys: "ctrl,h", description: "To open shortcuts guide" },
{
keys: "ctrl,alt,c",
description: "To copy issue url when on issue detail page.",
},
],
},
];
const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => { const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const filteredShortcuts = shortcuts.filter((shortcut) =>
shortcut.shortcuts.some((item) => item.description.includes(query.trim())) || query === ""
? true
: false
);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={setIsOpen}> <Dialog as="div" className="relative z-10" onClose={setIsOpen}>
@ -39,7 +77,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"> <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white p-5"> <div className="bg-white p-5">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="text-center sm:text-left w-full"> <div className="flex flex-col gap-y-4 text-center sm:text-left w-full">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-medium leading-6 text-gray-900 flex justify-between" className="text-lg font-medium leading-6 text-gray-900 flex justify-between"
@ -54,35 +92,18 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</button> </button>
</span> </span>
</Dialog.Title> </Dialog.Title>
<div className="mt-2 pt-5 flex flex-col gap-y-3 w-full"> <div>
{[ <Input
{ id="search"
title: "Navigation", name="search"
shortcuts: [ type="text"
{ keys: "ctrl,/", description: "To open navigator" }, placeholder="Search for shortcuts"
{ keys: "↑", description: "Move up" }, onChange={(e) => setQuery(e.target.value)}
{ keys: "↓", description: "Move down" }, />
{ keys: "←", description: "Move left" }, </div>
{ keys: "→", description: "Move right" }, <div className="flex flex-col gap-y-3 w-full">
{ keys: "Enter", description: "Select" }, {filteredShortcuts.length > 0 ? (
{ keys: "Esc", description: "Close" }, filteredShortcuts.map(({ title, shortcuts }) => (
],
},
{
title: "Common",
shortcuts: [
{ keys: "ctrl,p", description: "To create project" },
{ keys: "ctrl,i", description: "To create issue" },
{ keys: "ctrl,q", description: "To create cycle" },
{ keys: "ctrl,m", description: "To create module" },
{ keys: "ctrl,h", description: "To open shortcuts guide" },
{
keys: "ctrl,alt,c",
description: "To copy issue url when on issue detail page.",
},
],
},
].map(({ title, shortcuts }) => (
<div key={title} className="w-full flex flex-col"> <div key={title} className="w-full flex flex-col">
<p className="font-medium mb-4">{title}</p> <p className="font-medium mb-4">{title}</p>
<div className="flex flex-col gap-y-3"> <div className="flex flex-col gap-y-3">
@ -95,9 +116,6 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<kbd className="bg-gray-200 text-sm px-1 rounded"> <kbd className="bg-gray-200 text-sm px-1 rounded">
{key} {key}
</kbd> </kbd>
{/* {index !== keys.split(",").length - 1 ? (
<span className="text-xs">+</span>
) : null} */}
</span> </span>
))} ))}
</div> </div>
@ -105,7 +123,19 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
))} ))}
</div> </div>
</div> </div>
))} ))
) : (
<div className="flex flex-col gap-y-3">
<p className="text-sm text-gray-500">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
{query}
{`"`}
</span>
</p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,5 @@
const SingleBoard = () => {
return <></>;
};
export default SingleBoard;

View File

@ -0,0 +1,394 @@
// next
import Link from "next/link";
import Image from "next/image";
// react-beautiful-dnd
import { DraggableStateSnapshot } from "react-beautiful-dnd";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import User from "public/user.png";
// types
import { IIssue, IWorkspaceMember, Properties } from "types";
// common
import {
addSpaceIfCamelCase,
classNames,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
// constants
import { PRIORITIES } from "constants/";
import useUser from "lib/hooks/useUser";
import React from "react";
type Props = {
issue: IIssue;
properties: Properties;
snapshot?: DraggableStateSnapshot;
assignees: {
avatar: string | undefined;
first_name: string | undefined;
email: string | undefined;
}[];
people: IWorkspaceMember[] | undefined;
handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void;
};
const SingleIssue: React.FC<Props> = ({
issue,
properties,
snapshot,
assignees,
people,
handleDeleteIssue,
partialUpdateIssue,
}) => {
const { activeProject, states } = useUser();
return (
<div
className={`border rounded bg-white shadow-sm ${
snapshot && snapshot.isDragging ? "border-theme shadow-lg bg-indigo-50" : ""
}`}
>
<div className="group/card relative p-2 select-none">
{handleDeleteIssue && (
<div className="opacity-0 group-hover/card:opacity-100 absolute top-1.5 right-1.5 z-10">
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded text-red-500 bg-white hover:bg-red-50 duration-300 outline-none"
onClick={() => handleDeleteIssue(issue.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
<Link href={`/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="text-xs font-medium text-gray-500 mb-2">
{activeProject?.identifier}-{issue.sequence_id}
</div>
)}
<h5
className="group-hover:text-theme text-sm mb-3"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="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-20 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-20 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",
"flex items-center gap-2 cursor-pointer select-none px-3 py-2"
)
}
value={state.id}
>
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: state.color,
}}
></span>
{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 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="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(issue.start_date ?? "")}</div>
</div>
</div>
)}
{properties.due_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 ${
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="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(issue.target_date ?? "")}</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days`
: "Due 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 capitalize">
{assignee.first_name && assignee.first_name !== ""
? assignee.first_name.charAt(0)
: assignee?.email?.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 left-0 z-20 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 left-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>
)}
</div>
</div>
</div>
);
};
export default SingleIssue;

View File

@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react";
// swr // swr
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
// headless // headless
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
import projectServices from "lib/services/project.service"; import projectServices from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service"; import workspaceService from "lib/services/workspace.service";
// common // common
import { createSimilarString } from "constants/common"; import { createSimilarString, getRandomEmoji } from "constants/common";
// constants // constants
import { NETWORK_CHOICES } from "constants/"; import { NETWORK_CHOICES } from "constants/";
// fetch keys // fetch keys
@ -18,7 +18,7 @@ import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// ui // ui
import { Button, Input, TextArea, Select } from "ui"; import { Button, Input, TextArea, Select, EmojiIconPicker } from "ui";
// types // types
import { IProject } from "types"; import { IProject } from "types";
@ -32,6 +32,7 @@ const defaultValues: Partial<IProject> = {
identifier: "", identifier: "",
description: "", description: "",
network: 0, network: 0,
icon: getRandomEmoji(),
}; };
const IsGuestCondition: React.FC<{ const IsGuestCondition: React.FC<{
@ -83,6 +84,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
reset, reset,
setError, setError,
clearErrors, clearErrors,
control,
watch, watch,
setValue, setValue,
} = useForm<IProject>({ } = useForm<IProject>({
@ -201,6 +203,22 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div>
<label htmlFor="icon" className="text-gray-500 mb-2">
Icon
</label>
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? String.fromCodePoint(parseInt(value)) : "Select Icon"}
value={value}
onChange={onChange}
/>
)}
/>
</div>
<div> <div>
<Input <Input
id="name" id="name"

View File

@ -16,6 +16,16 @@ type Props = {
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void; openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void; removeIssueFromCycle: (bridgeId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
}; };
const CyclesBoardView: React.FC<Props> = ({ const CyclesBoardView: React.FC<Props> = ({
@ -26,6 +36,9 @@ const CyclesBoardView: React.FC<Props> = ({
openCreateIssueModal, openCreateIssueModal,
openIssuesListModal, openIssuesListModal,
removeIssueFromCycle, removeIssueFromCycle,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
}) => { }) => {
const { states } = useUser(); const { states } = useUser();
@ -57,6 +70,14 @@ const CyclesBoardView: React.FC<Props> = ({
removeIssueFromCycle={removeIssueFromCycle} removeIssueFromCycle={removeIssueFromCycle}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal} openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
/> />
))} ))}
</div> </div>

View File

@ -1,44 +1,25 @@
// react // react
import React, { useState } from "react"; import React, { useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// services // services
import cycleServices from "lib/services/cycles.service"; import workspaceService from "lib/services/workspace.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// ui // components
import { Spinner } from "ui"; import SingleIssue from "components/common/board-view/single-issue";
// icons // headless ui
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 { Menu, Transition } from "@headlessui/react";
import workspaceService from "lib/services/workspace.service"; // ui
import { CustomMenu } from "ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// common
import { addSpaceIfCamelCase, classNames } from "constants/common";
type Props = { type Props = {
properties: Properties; properties: Properties;
@ -52,6 +33,17 @@ type Props = {
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void; openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void; removeIssueFromCycle: (bridgeId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
stateId: string | null;
}; };
const SingleCycleBoard: React.FC<Props> = ({ const SingleCycleBoard: React.FC<Props> = ({
@ -64,11 +56,15 @@ const SingleCycleBoard: React.FC<Props> = ({
openCreateIssueModal, openCreateIssueModal,
openIssuesListModal, openIssuesListModal,
removeIssueFromCycle, removeIssueFromCycle,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
stateId,
}) => { }) => {
// Collapse/Expand // Collapse/Expand
const [show, setState] = useState(true); const [show, setState] = useState(true);
const { activeWorkspace, activeProject } = useUser(); const { activeWorkspace } = useUser();
if (selectedGroup === "priority") if (selectedGroup === "priority")
groupTitle === "high" groupTitle === "high"
@ -123,48 +119,25 @@ const SingleCycleBoard: React.FC<Props> = ({
</span> </span>
</div> </div>
<Menu as="div" className="relative inline-block"> <CustomMenu width="auto" ellipsis>
<Menu.Button className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"> <CustomMenu.MenuItem
<EllipsisHorizontalIcon className="h-4 w-4" /> onClick={() => {
</Menu.Button> openCreateIssueModal();
if (selectedGroup !== null) {
<Transition setPreloadedData({
as={React.Fragment} state: stateId !== null ? stateId : undefined,
enter="transition ease-out duration-100" [selectedGroup]: groupTitle,
enterFrom="transform opacity-0 scale-95" actionType: "createIssue",
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 Create new
</button> </CustomMenu.MenuItem>
)} <CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
</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 Add an existing issue
</button> </CustomMenu.MenuItem>
)} </CustomMenu>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div> </div>
</div> </div>
<div <div
@ -187,181 +160,47 @@ const SingleCycleBoard: React.FC<Props> = ({
}); });
return ( return (
<div key={childIssue.id} className={`border rounded bg-white shadow-sm`}> <SingleIssue
<div className="relative p-2 select-none"> key={childIssue.id}
<Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}> issue={childIssue}
<a> properties={properties}
{properties.key && ( assignees={assignees}
<div className="text-xs font-medium text-gray-500 mb-2"> people={people}
{activeProject?.identifier}-{childIssue.sequence_id} partialUpdateIssue={partialUpdateIssue}
</div> handleDeleteIssue={handleDeleteIssue}
)}
<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" <CustomMenu
className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none" label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
withoutBorder
> >
<PlusIcon className="h-3 w-3 mr-1" /> <CustomMenu.MenuItem
Create onClick={() => {
</button> openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div> </div>
</div> </div>
</div> </div>

View File

@ -195,6 +195,9 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
placeholder="Enter start date" placeholder="Enter start date"
error={errors.start_date} error={errors.start_date}
register={register} register={register}
validations={{
required: "Start date is required",
}}
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
@ -206,6 +209,9 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
placeholder="Enter end date" placeholder="Enter end date"
error={errors.end_date} error={errors.end_date}
register={register} register={register}
validations={{
required: "End date is required",
}}
/> />
</div> </div>
</div> </div>

View File

@ -17,6 +17,8 @@ import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/out
import { IIssue, IssueResponse } from "types"; import { IIssue, IssueResponse } from "types";
// constants // constants
import { classNames } from "constants/common"; import { classNames } from "constants/common";
import { mutate } from "swr";
import { CYCLE_ISSUES } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -26,7 +28,7 @@ type Props = {
}; };
type FormInput = { type FormInput = {
issue_ids: string[]; issues: string[];
}; };
const CycleIssuesListModal: React.FC<Props> = ({ const CycleIssuesListModal: React.FC<Props> = ({
@ -54,12 +56,12 @@ const CycleIssuesListModal: React.FC<Props> = ({
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm<FormInput>({ } = useForm<FormInput>({
defaultValues: { defaultValues: {
issue_ids: [], issues: [],
}, },
}); });
const handleAddToCycle: SubmitHandler<FormInput> = (data) => { const handleAddToCycle: SubmitHandler<FormInput> = (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) { if (!data.issues || data.issues.length === 0) {
setToastAlert({ setToastAlert({
title: "Error", title: "Error",
type: "error", type: "error",
@ -70,9 +72,10 @@ const CycleIssuesListModal: React.FC<Props> = ({
if (activeWorkspace && activeProject) { if (activeWorkspace && activeProject) {
issuesServices issuesServices
.bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, cycleId, data) .addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, data)
.then((res) => { .then((res) => {
console.log(res); console.log(res);
mutate(CYCLE_ISSUES(cycleId));
handleClose(); handleClose();
}) })
.catch((e) => { .catch((e) => {
@ -117,7 +120,7 @@ const CycleIssuesListModal: React.FC<Props> = ({
<form> <form>
<Controller <Controller
control={control} control={control}
name="issue_ids" name="issues"
render={({ field }) => ( render={({ field }) => (
<Combobox as="div" {...field} multiple> <Combobox as="div" {...field} multiple>
<div className="relative m-1"> <div className="relative m-1">

View File

@ -6,22 +6,21 @@ import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
// headless ui // headless ui
import { Disclosure, Transition, Menu } from "@headlessui/react"; import { Disclosure, Transition, Menu } from "@headlessui/react";
// services
import cycleServices from "lib/services/cycles.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// ui // ui
import { CustomMenu, Spinner } from "ui"; import { CustomMenu, Spinner } from "ui";
// icons // icons
import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/24/outline"; import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, SelectSprintType } from "types"; import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch keys // fetch keys
import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants // constants
import { import {
addSpaceIfCamelCase, addSpaceIfCamelCase,
classNames,
findHowManyDaysLeft, findHowManyDaysLeft,
renderShortNumericDateFormat, renderShortNumericDateFormat,
} from "constants/common"; } from "constants/common";
@ -29,13 +28,21 @@ import workspaceService from "lib/services/workspace.service";
type Props = { type Props = {
groupedByIssues: { groupedByIssues: {
[key: string]: IIssue[]; [key: string]: (IIssue & { bridge?: string })[];
}; };
properties: Properties; properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null; selectedGroup: NestedKeyOf<IIssue> | null;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: (cycleId: string) => void; openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void; removeIssueFromCycle: (bridgeId: string) => void;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
}; };
const CyclesListView: React.FC<Props> = ({ const CyclesListView: React.FC<Props> = ({
@ -45,8 +52,9 @@ const CyclesListView: React.FC<Props> = ({
openIssuesListModal, openIssuesListModal,
properties, properties,
removeIssueFromCycle, removeIssueFromCycle,
setPreloadedData,
}) => { }) => {
const { activeWorkspace, activeProject } = useUser(); const { activeWorkspace, activeProject, states } = useUser();
const { data: people } = useSWR<IWorkspaceMember[]>( const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null, activeWorkspace ? WORKSPACE_MEMBERS : null,
@ -55,7 +63,13 @@ const CyclesListView: React.FC<Props> = ({
return ( return (
<div className="flex flex-col space-y-5"> <div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => ( {Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
return (
<Disclosure key={singleGroup} as="div" defaultOpen> <Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => ( {({ open }) => (
<div className="bg-white rounded-lg"> <div className="bg-white rounded-lg">
@ -95,7 +109,7 @@ const CyclesListView: React.FC<Props> = ({
<div className="divide-y-2"> <div className="divide-y-2">
{groupedByIssues[singleGroup] ? ( {groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? ( groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue: IIssue) => { groupedByIssues[singleGroup].map((issue) => {
const assignees = [ const assignees = [
...(issue?.assignees_list ?? []), ...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []), ...(issue?.assignees ?? []),
@ -130,11 +144,11 @@ const CyclesListView: React.FC<Props> = ({
{activeProject?.identifier}-{issue.sequence_id} {activeProject?.identifier}-{issue.sequence_id}
</span> </span>
)} )}
<span className="">{issue.name}</span> <span>{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"> {/* <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> <h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div> <div>{issue.name}</div>
</div> </div> */}
</a> </a>
</Link> </Link>
</div> </div>
@ -204,7 +218,7 @@ const CyclesListView: React.FC<Props> = ({
</div> </div>
</div> </div>
)} )}
{properties.target_date && ( {properties.due_date && (
<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 ${ 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 === null
@ -220,23 +234,21 @@ const CyclesListView: React.FC<Props> = ({
? renderShortNumericDateFormat(issue.target_date) ? renderShortNumericDateFormat(issue.target_date)
: "N/A"} : "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"> <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"> <h5 className="font-medium mb-1 text-gray-900">Due date</h5>
Target date
</h5>
<div> <div>
{renderShortNumericDateFormat(issue.target_date ?? "")} {renderShortNumericDateFormat(issue.target_date ?? "")}
</div> </div>
<div> <div>
{issue.target_date && {issue.target_date &&
(issue.target_date < new Date().toISOString() (issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft( ? `Due date has passed by ${findHowManyDaysLeft(
issue.target_date issue.target_date
)} days` )} days`
: findHowManyDaysLeft(issue.target_date) <= 3 : findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft( ? `Due date is in ${findHowManyDaysLeft(
issue.target_date issue.target_date
)} days` )} days`
: "Target date")} : "Due date")}
</div> </div>
</div> </div>
</div> </div>
@ -270,50 +282,41 @@ const CyclesListView: React.FC<Props> = ({
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
<div className="p-3"> <div className="p-3">
<button <CustomMenu
type="button" label={
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium" <span className="flex items-center gap-1">
// 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" /> <PlusIcon className="h-3 w-3" />
Add issue Add issue
</button> </span>
}
optionsPosition="left"
withoutBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: singleGroup,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div> </div>
</div> </div>
)} )}
</Disclosure> </Disclosure>
))} );
})}
</div> </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
); );
}; };

View File

@ -63,20 +63,29 @@ const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle
return ( return (
<> <>
<div className="bg-white p-3"> <div className="border bg-white p-3 rounded-md">
<div className="grid grid-cols-8 gap-2 divide-x"> <div className="grid grid-cols-8 gap-2 divide-x">
<div className="col-span-3 space-y-3"> <div className="col-span-3 space-y-3">
<div className="flex justify-between items-center gap-2">
<Link href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}> <Link href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}>
<a className="flex justify-between items-center"> <a>
<h2 className="font-medium">{cycle.name}</h2> <h2 className="font-medium">{cycle.name}</h2>
</a>
</Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs bg-gray-100 px-2 py-1 rounded-xl"> <div className="flex items-center gap-2">
{today.getDate() < startDate.getDate() <span
? "Not started" className={`text-xs border px-3 py-0.5 rounded-xl ${
: today.getDate() > endDate.getDate() today < startDate
? "Over" ? "text-orange-500 border-orange-500"
: "Active"} : today > endDate
? "text-red-500 border-red-500"
: "text-green-500 border-green-500"
}`}
>
{today < startDate ? "Not started" : today > endDate ? "Over" : "Active"}
</span> </span>
</div>
<CustomMenu width="auto" ellipsis> <CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}> <CustomMenu.MenuItem onClick={handleDeleteCycle}>
@ -84,22 +93,20 @@ const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>
</div> </div>
</a> </div>
</Link> <div className="grid grid-cols-3 gap-x-2 gap-y-3 text-xs">
<div className="grid grid-cols-2 gap-x-2 gap-y-3 text-xs">
<div className="flex items-center gap-2 text-gray-500"> <div className="flex items-center gap-2 text-gray-500">
<CalendarDaysIcon className="h-4 w-4" /> <CalendarDaysIcon className="h-4 w-4" />
Cycle dates Cycle dates
</div> </div>
<div> <div className="col-span-2">
{renderShortNumericDateFormat(startDate)} - {renderShortNumericDateFormat(endDate)} {renderShortNumericDateFormat(startDate)} - {renderShortNumericDateFormat(endDate)}
</div> </div>
<div className="flex items-center gap-2 text-gray-500"> <div className="flex items-center gap-2 text-gray-500">
<UserIcon className="h-4 w-4" /> <UserIcon className="h-4 w-4" />
Created by Created by
</div> </div>
<div className="flex items-center gap-2"> <div className="col-span-2 flex items-center gap-2">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image <Image
src={cycle.owned_by.avatar} src={cycle.owned_by.avatar}
@ -119,7 +126,7 @@ const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle
<CalendarDaysIcon className="h-4 w-4" /> <CalendarDaysIcon className="h-4 w-4" />
Active members Active members
</div> </div>
<div></div> <div className="col-span-2"></div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button theme="secondary" className="flex items-center gap-2" disabled> <Button theme="secondary" className="flex items-center gap-2" disabled>
@ -157,7 +164,9 @@ const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle
<span className="text-gray-500"> <span className="text-gray-500">
-{" "} -{" "}
{cycleIssues && cycleIssues.length > 0 {cycleIssues && cycleIssues.length > 0
? `${(groupedIssues[group].length / cycleIssues.length) * 100}%` ? `${Math.round(
(groupedIssues[group].length / cycleIssues.length) * 100
)}%`
: "0%"} : "0%"}
</span> </span>
</span> </span>

View File

@ -16,7 +16,7 @@ import { STATE_LIST } from "constants/fetch-keys";
// components // components
import SingleBoard from "components/project/issues/BoardView/single-board"; import SingleBoard from "components/project/issues/BoardView/single-board";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
// ui // ui
import { Spinner } from "ui"; import { Spinner } from "ui";
// types // types

View File

@ -1,8 +1,5 @@
// react // react
import React, { useState } from "react"; import React, { useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// react-beautiful-dnd // react-beautiful-dnd
@ -12,30 +9,18 @@ import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import workspaceService from "lib/services/workspace.service"; import workspaceService from "lib/services/workspace.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons // icons
import { import {
ArrowsPointingInIcon, ArrowsPointingInIcon,
ArrowsPointingOutIcon, ArrowsPointingOutIcon,
CalendarDaysIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
PlusIcon, PlusIcon,
TrashIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import User from "public/user.png"; import { addSpaceIfCamelCase } from "constants/common";
// common
import { PRIORITIES } from "constants/";
import {
addSpaceIfCamelCase,
classNames,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
import { getPriorityIcon } from "constants/global";
// types // types
import { IIssue, Properties, NestedKeyOf, IWorkspaceMember } from "types"; import { IIssue, Properties, NestedKeyOf, IWorkspaceMember } from "types";
import SingleIssue from "components/common/board-view/single-issue";
type Props = { type Props = {
selectedGroup: NestedKeyOf<IIssue> | null; selectedGroup: NestedKeyOf<IIssue> | null;
@ -78,7 +63,7 @@ const SingleBoard: React.FC<Props> = ({
// Collapse/Expand // Collapse/Expand
const [show, setShow] = useState(true); const [show, setShow] = useState(true);
const { activeProject, activeWorkspace, states } = useUser(); const { activeWorkspace } = useUser();
if (selectedGroup === "priority") if (selectedGroup === "priority")
groupTitle === "high" groupTitle === "high"
@ -206,368 +191,20 @@ const SingleBoard: React.FC<Props> = ({
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}> <Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
className={`border rounded bg-white shadow-sm ${
snapshot.isDragging ? "border-theme shadow-lg bg-indigo-50" : ""
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
>
<div
className="group/card relative p-2 select-none"
{...provided.dragHandleProps} {...provided.dragHandleProps}
> >
<div className="opacity-0 group-hover/card:opacity-100 absolute top-1 right-1"> <SingleIssue
<button issue={childIssue}
type="button" properties={properties}
className="h-7 w-7 p-1 grid place-items-center rounded text-red-500 hover:bg-red-50 duration-300 outline-none" snapshot={snapshot}
onClick={() => handleDeleteIssue(childIssue.id)} people={people}
> assignees={assignees}
<TrashIcon className="h-4 w-4" /> handleDeleteIssue={handleDeleteIssue}
</button> partialUpdateIssue={partialUpdateIssue}
</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 && (
<Listbox
as="div"
value={childIssue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data }, childIssue.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 ${
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"
}`}
>
{childIssue.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 ${
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>
</>
)}
</Listbox>
)}
{properties.state && (
<Listbox
as="div"
value={childIssue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data }, childIssue.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: childIssue.state_detail.color,
}}
></span>
{addSpaceIfCamelCase(childIssue.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>{childIssue.state_detail.name}</div>
</div>
</>
)}
</Listbox>
)}
{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 && (
<Listbox
as="div"
value={childIssue.assignees}
onChange={(data: any) => {
const newData = childIssue.assignees ?? [];
if (newData.includes(data)) {
newData.splice(newData.indexOf(data), 1);
} else {
newData.push(data);
}
partialUpdateIssue(
{ assignees_list: newData },
childIssue.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>
) : (
<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 left-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 left-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>
{childIssue.assignee_details?.length > 0
? childIssue.assignee_details
.map((assignee) => assignee.first_name)
.join(", ")
: "No one"}
</div>
</div>
</>
)}
</Listbox>
)}
</div>
</div>
</div>
)} )}
</Draggable> </Draggable>
); );

View File

@ -9,6 +9,8 @@ import stateServices from "lib/services/state.service";
import { STATE_LIST } from "constants/fetch-keys"; import { STATE_LIST } from "constants/fetch-keys";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// common
import { groupBy } from "constants/common";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui // ui
@ -18,25 +20,27 @@ import { Button } from "ui";
import type { IState } from "types"; import type { IState } from "types";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; onClose: () => void;
data?: IState; data: IState | null;
}; };
const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => { const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser(); const [issuesWithThisStateExist, setIssuesWithThisStateExist] = useState(true);
const { activeWorkspace, issues } = useUser();
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const handleClose = () => { const handleClose = () => {
setIsOpen(false); onClose();
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const handleDeletion = async () => { const handleDeletion = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
if (!data || !activeWorkspace) return; if (!data || !activeWorkspace || issuesWithThisStateExist) return;
await stateServices await stateServices
.deleteState(activeWorkspace.slug, data.project, data.id) .deleteState(activeWorkspace.slug, data.project, data.id)
.then(() => { .then(() => {
@ -53,9 +57,11 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
}); });
}; };
const groupedIssues = groupBy(issues?.results ?? [], "state");
useEffect(() => { useEffect(() => {
data && setIsOpen(true); if (data) setIssuesWithThisStateExist(!!groupedIssues[data.id]);
}, [data, setIsOpen]); }, [groupedIssues, data]);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
@ -109,6 +115,14 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
This action cannot be undone. This action cannot be undone.
</p> </p>
</div> </div>
<div className="mt-2">
{issuesWithThisStateExist && (
<p className="text-sm text-red-500">
There are issues with this state. Please move them to another state
before deleting this state.
</p>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -117,7 +131,7 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
type="button" type="button"
onClick={handleDeletion} onClick={handleDeletion}
theme="danger" theme="danger"
disabled={isDeleteLoading} disabled={isDeleteLoading || issuesWithThisStateExist}
className="inline-flex sm:ml-3" className="inline-flex sm:ml-3"
> >
{isDeleteLoading ? "Deleting..." : "Delete"} {isDeleteLoading ? "Deleting..." : "Delete"}

View File

@ -0,0 +1,209 @@
import React, { useEffect } from "react";
// swr
import { mutate } from "swr";
// react hook form
import { useForm, Controller } from "react-hook-form";
// react color
import { TwitterPicker } from "react-color";
// headless
import { Popover, Transition } from "@headlessui/react";
// constants
import { GROUP_CHOICES } from "constants/";
import { STATE_LIST } from "constants/fetch-keys";
// services
import stateService from "lib/services/state.service";
// ui
import { Button, Input, Select, Spinner } from "ui";
// types
import type { IState } from "types";
type Props = {
workspaceSlug?: string;
projectId?: string;
data: IState | null;
onClose: () => void;
selectedGroup: StateGroup | null;
};
export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null;
const defaultValues: Partial<IState> = {
name: "",
color: "#000000",
group: "backlog",
};
export const CreateUpdateStateInline: React.FC<Props> = ({
workspaceSlug,
projectId,
data,
onClose,
selectedGroup,
}) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
watch,
reset,
control,
} = useForm<IState>({
defaultValues,
});
const handleClose = () => {
onClose();
reset({ name: "", color: "#000000", group: "backlog" });
};
const onSubmit = async (formData: IState) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const payload: IState = {
...formData,
};
if (!data) {
await stateService
.createState(workspaceSlug, projectId, { ...payload })
.then((res) => {
mutate<IState[]>(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res], false);
handleClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof IState, {
message: err[key].join(", "),
});
});
});
} else {
await stateService
.updateState(workspaceSlug, projectId, data.id, {
...payload,
})
.then((res) => {
mutate<IState[]>(
STATE_LIST(projectId),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
handleClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof IState, {
message: err[key].join(", "),
});
});
});
}
};
useEffect(() => {
if (data === null) return;
reset(data);
}, [data, reset]);
useEffect(() => {
if (!data)
reset({
...defaultValues,
group: selectedGroup ?? "backlog",
});
}, [selectedGroup, data, reset]);
return (
<div className="flex items-center gap-x-2 p-2 bg-gray-50">
<div className="flex-shrink-0 h-8 w-8">
<Popover className="relative w-full h-full flex justify-center items-center bg-gray-200 rounded-xl">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
open ? "text-gray-900" : "text-gray-500"
}`}
>
{watch("color") && watch("color") !== "" && (
<span
className="w-4 h-4 rounded"
style={{
backgroundColor: watch("color") ?? "green",
}}
/>
)}
</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 top-full z-20 left-0 mt-3 px-2 w-screen max-w-xs sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<Input
id="name"
name="name"
register={register}
placeholder="Enter state name"
validations={{
required: true,
}}
error={errors.name}
autoComplete="off"
/>
{data && (
<Select
id="group"
name="group"
error={errors.group}
register={register}
validations={{
required: true,
}}
options={Object.keys(GROUP_CHOICES).map((key) => ({
value: key,
label: GROUP_CHOICES[key as keyof typeof GROUP_CHOICES],
}))}
/>
)}
<Input
id="description"
name="description"
register={register}
placeholder="Enter state description"
error={errors.description}
autoComplete="off"
/>
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button theme="primary" disabled={isSubmitting} onClick={handleSubmit(onSubmit)}>
{isSubmitting ? "Loading..." : data ? "Update" : "Create"}
</Button>
</div>
);
};

View File

@ -11,10 +11,12 @@ import { Dialog, Popover, Transition } from "@headlessui/react";
import stateService from "lib/services/state.service"; import stateService from "lib/services/state.service";
// fetch keys // fetch keys
import { STATE_LIST } from "constants/fetch-keys"; import { STATE_LIST } from "constants/fetch-keys";
// constants
import { GROUP_CHOICES } from "constants/";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// ui // ui
import { Button, Input, TextArea } from "ui"; import { Button, Input, Select, TextArea } from "ui";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon } from "@heroicons/react/24/outline";
@ -31,6 +33,7 @@ const defaultValues: Partial<IState> = {
name: "", name: "",
description: "", description: "",
color: "#000000", color: "#000000",
group: "backlog",
}; };
const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, handleClose }) => { const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, handleClose }) => {
@ -161,6 +164,22 @@ const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, hand
}} }}
/> />
</div> </div>
<div>
<Select
id="group"
label="Group"
name="group"
error={errors.group}
register={register}
validations={{
required: "Group is required",
}}
options={Object.keys(GROUP_CHOICES).map((key) => ({
value: key,
label: GROUP_CHOICES[key as keyof typeof GROUP_CHOICES],
}))}
/>
</div>
<div> <div>
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (

View File

@ -4,7 +4,7 @@ import { mutate } from "swr";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// fetching keys // fetching keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { CYCLE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// services // services
import issueServices from "lib/services/issues.service"; import issueServices from "lib/services/issues.service";
// hooks // hooks
@ -55,6 +55,7 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
}, },
false false
); );
mutate(CYCLE_ISSUES(data.issue_cycle?.id ?? ""));
setToastAlert({ setToastAlert({
title: "Success", title: "Success",
type: "success", type: "success",

View File

@ -1,18 +1,12 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
// next // next
import { useRouter } from "next/router";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/router";
// swr // swr
import { mutate } from "swr"; import { mutate } from "swr";
// react hook form // react hook form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// fetching keys
import {
PROJECT_ISSUES_DETAILS,
PROJECT_ISSUES_LIST,
CYCLE_ISSUES,
USER_ISSUE,
} from "constants/fetch-keys";
// headless // headless
import { Dialog, Menu, Transition } from "@headlessui/react"; import { Dialog, Menu, Transition } from "@headlessui/react";
// services // services
@ -21,22 +15,30 @@ import issuesServices from "lib/services/issues.service";
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// ui // ui
import { Button, Input, TextArea } from "ui"; import { Button, TextArea } from "ui";
// commons // icons
import { renderDateFormat, cosineSimilarity } from "constants/common"; import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
// components // components
import SelectState from "./SelectState"; import SelectState from "components/project/issues/create-update-issue-modal/select-state";
import SelectCycles from "./SelectCycles"; import SelectCycles from "components/project/issues/create-update-issue-modal/select-cycle";
import SelectLabels from "./SelectLabels"; import SelectLabels from "components/project/issues/create-update-issue-modal/select-labels";
import SelectProject from "./SelectProject"; import SelectProject from "components/project/issues/create-update-issue-modal/select-project";
import SelectPriority from "./SelectPriority"; import SelectPriority from "components/project/issues/create-update-issue-modal/select-priority";
import SelectAssignee from "./SelectAssignee"; import SelectAssignee from "components/project/issues/create-update-issue-modal/select-assignee";
import SelectParent from "./SelectParentIssue"; import SelectParent from "components/project/issues/create-update-issue-modal/select-parent-issue";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal"; import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// types // types
import type { IIssue, IssueResponse, CycleIssueResponse } from "types"; import type { IIssue, IssueResponse } from "types";
import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline"; // fetch keys
import {
PROJECT_ISSUES_DETAILS,
PROJECT_ISSUES_LIST,
CYCLE_ISSUES,
USER_ISSUE,
} from "constants/fetch-keys";
// common
import { renderDateFormat, cosineSimilarity } from "constants/common";
const RichTextEditor = dynamic(() => import("components/lexical/editor"), { const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false, ssr: false,
@ -113,36 +115,18 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
}, 500); }, 500);
}; };
const addIssueToSprint = async (issueId: string, sprintId: string, issueDetail: IIssue) => { const addIssueToCycle = async (issueId: string, cycleId: string, issueDetail: IIssue) => {
if (!activeWorkspace || !activeProject) return; if (!activeWorkspace || !activeProject) return;
await issuesServices await issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, sprintId, { .addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
issue: issueId, issues: [issueId],
}) })
.then((res) => { .then((res) => {
mutate<CycleIssueResponse[]>( mutate(CYCLE_ISSUES(cycleId));
CYCLE_ISSUES(sprintId),
(prevData) => {
const targetResponse = prevData?.find((t) => t.cycle === sprintId);
if (targetResponse) {
targetResponse.issue_details = issueDetail;
return prevData;
} else {
return [
...(prevData ?? []),
{
cycle: sprintId,
issue_details: issueDetail,
} as CycleIssueResponse,
];
}
},
false
);
if (isUpdatingSingleIssue) { if (isUpdatingSingleIssue) {
mutate<IIssue>( mutate<IIssue>(
PROJECT_ISSUES_DETAILS, PROJECT_ISSUES_DETAILS,
(prevData) => ({ ...(prevData as IIssue), sprints: sprintId }), (prevData) => ({ ...(prevData as IIssue), sprints: cycleId }),
false false
); );
} else } else
@ -152,7 +136,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
return { return {
...(prevData as IssueResponse), ...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((issue) => { results: (prevData?.results ?? []).map((issue) => {
if (issue.id === res.id) return { ...issue, sprints: sprintId }; if (issue.id === res.id) return { ...issue, sprints: cycleId };
return issue; return issue;
}), }),
}; };
@ -185,7 +169,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
mutate<IssueResponse>(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id)); mutate<IssueResponse>(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id));
if (formData.sprints && formData.sprints !== null) { if (formData.sprints && formData.sprints !== null) {
await addIssueToSprint(res.id, formData.sprints, formData); await addIssueToCycle(res.id, formData.sprints, formData);
} }
handleClose(); handleClose();
resetForm(); resetForm();
@ -225,7 +209,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
false false
); );
if (formData.sprints && formData.sprints !== null) { if (formData.sprints && formData.sprints !== null) {
await addIssueToSprint(res.id, formData.sprints, formData); await addIssueToCycle(res.id, formData.sprints, formData);
} }
handleClose(); handleClose();
resetForm(); resetForm();
@ -261,8 +245,6 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
return () => setMostSimilarIssue(undefined); return () => setMostSimilarIssue(undefined);
}, []); }, []);
// console.log(watch("parent"));
return ( return (
<> <>
{activeProject && ( {activeProject && (
@ -340,27 +322,30 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
{mostSimilarIssue && ( {mostSimilarIssue && (
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Did you mean{" "} <Link
<button href={`/projects/${activeProject?.id}/issues/${mostSimilarIssue}`}
type="button"
onClick={() => {
setMostSimilarIssue(undefined);
router.push(
`/projects/${activeProject?.id}/issues/${mostSimilarIssue}`
);
handleClose();
resetForm();
}}
> >
<a target="_blank" type="button" className="inline text-left">
<span>Did you mean </span>
<span className="italic"> <span className="italic">
{ {
issues?.results.find( issues?.results.find((i) => i.id === mostSimilarIssue)
(issue) => issue.id === mostSimilarIssue ?.project_detail.identifier
)?.name
} }
-
{
issues?.results.find((i) => i.id === mostSimilarIssue)
?.sequence_id
}
:{" "}
{
issues?.results.find((i) => i.id === mostSimilarIssue)
?.name
}{" "}
</span> </span>
</button>
? ?
</a>
</Link>{" "}
</p> </p>
<button <button
type="button" type="button"

View File

@ -39,7 +39,13 @@ const SelectAssignee: React.FC<Props> = ({ control }) => {
title="Assignees" title="Assignees"
optionsFontsize="sm" optionsFontsize="sm"
options={people?.map((person) => { options={people?.map((person) => {
return { value: person.member.id, display: person.member.first_name }; return {
value: person.member.id,
display:
person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email,
};
})} })}
multiple={true} multiple={true}
value={value} value={value}

View File

@ -47,14 +47,14 @@ const SelectSprint: React.FC<Props> = ({ control, setIsOpen }) => {
leaveTo="opacity-0" 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"> <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">
<div className="p-1"> <div className="py-1">
{cycles?.map((cycle) => ( {cycles?.map((cycle) => (
<Listbox.Option <Listbox.Option
key={cycle.id} key={cycle.id}
value={cycle.id} value={cycle.id}
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none p-2 rounded-md ${ `text-gray-900 cursor-pointer select-none p-2 ${
active ? "bg-theme text-white" : "text-gray-900" active ? "bg-indigo-50" : ""
}` }`
} }
> >

View File

@ -99,14 +99,14 @@ const SelectLabels: React.FC<Props> = ({ control }) => {
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none"> <Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1"> <div className="py-1">
{issueLabels?.map((label) => ( {issueLabels?.map((label) => (
<Listbox.Option <Listbox.Option
key={label.id} key={label.id}
className={({ active }) => className={({ active }) =>
`${ `${
active ? "text-white bg-theme" : "text-gray-900" active ? "bg-indigo-50" : ""
} flex items-center gap-2 cursor-pointer select-none w-full p-2 rounded-md` } flex items-center gap-2 text-gray-900 cursor-pointer select-none w-full p-2`
} }
value={label.id} value={label.id}
> >

View File

@ -42,14 +42,14 @@ const SelectPriority: React.FC<Props> = ({ control }) => {
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute z-10 mt-1 w-full w-[5rem] bg-white shadow-lg max-h-28 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-xs"> <Listbox.Options className="absolute z-10 mt-1 w-full w-[5rem] bg-white shadow-lg max-h-28 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-xs">
<div className="p-1"> <div className="py-1">
{PRIORITIES.map((priority) => ( {PRIORITIES.map((priority) => (
<Listbox.Option <Listbox.Option
key={priority} key={priority}
className={({ active }) => className={({ active }) =>
`${ `${
active ? "text-white bg-theme" : "text-gray-900" active ? "bg-indigo-50" : ""
} cursor-pointer select-none relative p-2 rounded-md` } text-gray-900 cursor-pointer select-none p-2`
} }
value={priority} value={priority}
> >

View File

@ -28,7 +28,7 @@ const SelectState: React.FC<Props> = ({ control, setIsOpen }) => {
<CustomListbox <CustomListbox
title="State" title="State"
options={states?.map((state) => { options={states?.map((state) => {
return { value: state.id, display: state.name }; return { value: state.id, display: state.name, color: state.color };
})} })}
value={value} value={value}
optionsFontsize="sm" optionsFontsize="sm"

View File

@ -1,5 +1,16 @@
import React from "react";
// next // next
import { useRouter } from "next/router";
import Image from "next/image"; import Image from "next/image";
// swr
import useSWR from "swr";
// constants
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { addSpaceIfCamelCase, timeAgo } from "constants/common";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui // ui
import { Spinner } from "ui"; import { Spinner } from "ui";
// icons // icons
@ -12,14 +23,6 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import { IssueResponse, IState } from "types"; import { IssueResponse, IState } from "types";
// constants
import { addSpaceIfCamelCase, timeAgo } from "constants/common";
type Props = {
issueActivities: any[] | undefined;
states: IState[] | undefined;
issues: IssueResponse | undefined;
};
const activityIcons: { const activityIcons: {
[key: string]: JSX.Element; [key: string]: JSX.Element;
@ -32,7 +35,25 @@ const activityIcons: {
parent: <UserIcon className="h-3.5 w-3.5" />, parent: <UserIcon className="h-3.5 w-3.5" />,
}; };
const IssueActivitySection: React.FC<Props> = ({ issueActivities, states, issues }) => { const IssueActivitySection: React.FC = () => {
const router = useRouter();
const { issueId, projectId } = router.query;
const { activeWorkspace, states, issues } = useUser();
const { data: issueActivities } = useSWR<any[]>(
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null,
activeWorkspace && projectId && issueId
? () =>
issuesServices.getIssueActivities(
activeWorkspace.slug,
projectId as string,
issueId as string
)
: null
);
return ( return (
<> <>
{issueActivities ? ( {issueActivities ? (

View File

@ -1,16 +1,12 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
// next // next
import Image from "next/image"; import Image from "next/image";
// swr
import { mutate } from "swr";
// headless ui // headless ui
import { Menu } from "@headlessui/react"; import { Menu } from "@headlessui/react";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// fetch keys
import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys";
// common // common
import { timeAgo } from "constants/common"; import { timeAgo } from "constants/common";
// ui // ui
@ -42,16 +38,6 @@ const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion
const onEnter = (formData: IIssueComment) => { const onEnter = (formData: IIssueComment) => {
if (isSubmitting) return; if (isSubmitting) return;
mutate<IIssueComment[]>(
PROJECT_ISSUES_COMMENTS,
(prevData) => {
const newData = prevData ?? [];
const index = newData.findIndex((comment) => comment.id === formData.id);
newData[index] = formData;
return [...newData];
},
false
);
setIsEditing(false); setIsEditing(false);
onSubmit(formData); onSubmit(formData);
}; };
@ -155,11 +141,6 @@ const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={() => { onClick={() => {
mutate<IIssueComment[]>(
PROJECT_ISSUES_COMMENTS,
(prevData) => (prevData ?? []).filter((c) => c.id !== comment.id),
false
);
handleCommentDeletion(comment.id); handleCommentDeletion(comment.id);
}} }}
> >

View File

@ -1,33 +1,28 @@
import React from "react"; import React from "react";
// router
import { useRouter } from "next/router";
// swr // swr
import { mutate } from "swr"; import useSWR from "swr";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// services // services
import issuesServices from "lib/services/issues.service"; import issuesServices from "lib/services/issues.service";
// fetch keys // fetch keys
import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys"; import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// components // components
import CommentCard from "components/project/issues/issue-detail/comment/IssueCommentCard"; import CommentCard from "components/project/issues/issue-detail/comment/IssueCommentCard";
// ui // ui
import { TextArea, Button, Spinner } from "ui"; import { TextArea, Button, Spinner } from "ui";
// types // types
import type { IIssueComment } from "types"; import type { IIssueComment } from "types";
// icons
import UploadingIcon from "public/animated-icons/uploading.json";
type Props = {
comments?: IIssueComment[];
workspaceSlug: string;
projectId: string;
issueId: string;
};
const defaultValues: Partial<IIssueComment> = { const defaultValues: Partial<IIssueComment> = {
comment: "", comment: "",
}; };
const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, workspaceSlug }) => { const IssueCommentSection: React.FC = () => {
const { const {
register, register,
handleSubmit, handleSubmit,
@ -36,16 +31,31 @@ const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, wo
reset, reset,
} = useForm<IIssueComment>({ defaultValues }); } = useForm<IIssueComment>({ defaultValues });
const router = useRouter();
let { issueId, projectId } = router.query;
const { activeWorkspace } = useUser();
const { data: comments, mutate } = useSWR<IIssueComment[]>(
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS(issueId as string) : null,
activeWorkspace && projectId && issueId
? () =>
issuesServices.getIssueComments(
activeWorkspace.slug,
projectId as string,
issueId as string
)
: null
);
const onSubmit = async (formData: IIssueComment) => { const onSubmit = async (formData: IIssueComment) => {
if (!activeWorkspace || !projectId || !issueId || isSubmitting) return;
await issuesServices await issuesServices
.createIssueComment(workspaceSlug, projectId, issueId, formData) .createIssueComment(activeWorkspace.slug, projectId as string, issueId as string, formData)
.then((response) => { .then((response) => {
console.log(response); console.log(response);
mutate<IIssueComment[]>( mutate((prevData) => [response, ...(prevData ?? [])]);
PROJECT_ISSUES_COMMENTS,
(prevData) => [...(prevData ?? []), response],
false
);
reset(defaultValues); reset(defaultValues);
}) })
.catch((error) => { .catch((error) => {
@ -54,17 +64,34 @@ const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, wo
}; };
const onCommentUpdate = async (comment: IIssueComment) => { const onCommentUpdate = async (comment: IIssueComment) => {
if (!activeWorkspace || !projectId || !issueId || isSubmitting) return;
await issuesServices await issuesServices
.patchIssueComment(workspaceSlug, projectId, issueId, comment.id, comment) .patchIssueComment(
activeWorkspace.slug,
projectId as string,
issueId as string,
comment.id,
comment
)
.then((response) => { .then((response) => {
console.log(response); mutate((prevData) => {
const updatedComments = prevData?.map((c) => {
if (c.id === comment.id) {
return comment;
}
return c;
});
return updatedComments;
});
}); });
}; };
const onCommentDelete = async (commentId: string) => { const onCommentDelete = async (commentId: string) => {
if (!activeWorkspace || !projectId || !issueId || isSubmitting) return;
await issuesServices await issuesServices
.deleteIssueComment(workspaceSlug, projectId, issueId, commentId) .deleteIssueComment(activeWorkspace.slug, projectId as string, issueId as string, commentId)
.then((response) => { .then((response) => {
mutate((prevData) => (prevData ?? []).filter((c) => c.id !== commentId));
console.log(response); console.log(response);
}); });
}; };
@ -118,7 +145,6 @@ const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, wo
/> />
<Button type="submit" className="whitespace-nowrap" disabled={isSubmitting}> <Button type="submit" className="whitespace-nowrap" disabled={isSubmitting}>
{isSubmitting ? "Adding comment..." : "Add comment"} {isSubmitting ? "Adding comment..." : "Add comment"}
{/* <UploadingIcon /> */}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
import dynamic from "next/dynamic";
// headless ui // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// react hook form // react hook form
@ -14,6 +15,8 @@ import useToast from "lib/hooks/useToast";
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
// commons // commons
import { copyTextToClipboard } from "constants/common"; import { copyTextToClipboard } from "constants/common";
// components
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
// ui // ui
import { Input, Button, Spinner } from "ui"; import { Input, Button, Spinner } from "ui";
import { Popover } from "@headlessui/react"; import { Popover } from "@headlessui/react";
@ -30,7 +33,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import type { Control } from "react-hook-form"; import type { Control } from "react-hook-form";
import type { IIssue, IIssueLabels, NestedKeyOf } from "types"; import type { ICycle, IIssue, IIssueLabels, NestedKeyOf } from "types";
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
import { positionEditorElement } from "components/lexical/helpers/editor"; import { positionEditorElement } from "components/lexical/helpers/editor";
import SelectState from "./select-state"; import SelectState from "./select-state";
@ -46,7 +49,6 @@ type Props = {
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
issueDetail: IIssue | undefined; issueDetail: IIssue | undefined;
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
setDeleteIssueModal: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const defaultValues: Partial<IIssueLabels> = { const defaultValues: Partial<IIssueLabels> = {
@ -59,7 +61,6 @@ const IssueDetailSidebar: React.FC<Props> = ({
submitChanges, submitChanges,
issueDetail, issueDetail,
watch: watchIssue, watch: watchIssue,
setDeleteIssueModal,
}) => { }) => {
const [createLabelForm, setCreateLabelForm] = useState(false); const [createLabelForm, setCreateLabelForm] = useState(false);
@ -74,6 +75,8 @@ const IssueDetailSidebar: React.FC<Props> = ({
: null : null
); );
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const { const {
register, register,
handleSubmit, handleSubmit,
@ -93,18 +96,23 @@ const IssueDetailSidebar: React.FC<Props> = ({
console.log(res); console.log(res);
reset(defaultValues); reset(defaultValues);
issueLabelMutate((prevData) => [...(prevData ?? []), res], false); issueLabelMutate((prevData) => [...(prevData ?? []), res], false);
submitChanges({ labels_list: [res.id] });
}); });
}; };
const handleCycleChange = (cycleId: string) => { const handleCycleChange = (cycleDetail: ICycle) => {
if (activeWorkspace && activeProject && issueDetail) if (activeWorkspace && activeProject && issueDetail) {
issuesServices.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, { submitChanges({ cycle: cycleDetail.id, cycle_detail: cycleDetail });
issue: issueDetail.id, issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleDetail.id, {
issues: [issueDetail.id],
})
.then(() => {
submitChanges({});
}); });
}
}; };
console.log(issueDetail);
return ( return (
<> <>
<div className="h-full w-full divide-y-2 divide-gray-100"> <div className="h-full w-full divide-y-2 divide-gray-100">
@ -140,7 +148,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
type="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" 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={() => onClick={() =>
copyTextToClipboard(`${issueDetail?.id}`) copyTextToClipboard(issueDetail?.id ?? "")
.then(() => { .then(() => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -208,7 +216,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
watch={watchIssue} watch={watchIssue}
/> />
<SelectBlocked <SelectBlocked
submitChanges={submitChanges} issueDetail={issueDetail}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []} issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue} watch={watchIssue}
/> />
@ -224,6 +232,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<input <input
type="date" type="date"
id="issueDate"
value={value ?? ""} value={value ?? ""}
onChange={(e: any) => { onChange={(e: any) => {
submitChanges({ target_date: e.target.value }); submitChanges({ target_date: e.target.value });
@ -248,12 +257,17 @@ const IssueDetailSidebar: React.FC<Props> = ({
</div> </div>
<div className="basis-1/2"> <div className="basis-1/2">
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{issueDetail?.label_details.map((label) => ( {watchIssue("labels_list")?.map((label) => {
const singleLabel = issueLabels?.find((l) => l.id === label);
if (!singleLabel) return null;
return (
<span <span
key={label.id} key={singleLabel.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" 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={() => { onClick={() => {
const updatedLabels = issueDetail?.labels.filter((l) => l !== label.id); const updatedLabels = watchIssue("labels_list")?.filter((l) => l !== label);
submitChanges({ submitChanges({
labels_list: updatedLabels, labels_list: updatedLabels,
}); });
@ -261,12 +275,13 @@ const IssueDetailSidebar: React.FC<Props> = ({
> >
<span <span
className="h-2 w-2 rounded-full flex-shrink-0" className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }} style={{ backgroundColor: singleLabel.colour ?? "green" }}
></span> ></span>
{label.name} {singleLabel.name}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" /> <XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
</span> </span>
))} );
})}
<Controller <Controller
control={control} control={control}
name="labels_list" name="labels_list"
@ -274,9 +289,9 @@ const IssueDetailSidebar: React.FC<Props> = ({
<Listbox <Listbox
as="div" as="div"
value={value} value={value}
multiple
onChange={(val: any) => submitChanges({ labels_list: val })} onChange={(val: any) => submitChanges({ labels_list: val })}
className="flex-shrink-0" className="flex-shrink-0"
multiple
> >
{({ open }) => ( {({ open }) => (
<> <>
@ -410,6 +425,11 @@ const IssueDetailSidebar: React.FC<Props> = ({
)} )}
</div> </div>
</div> </div>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueDetail}
/>
</> </>
); );
}; };

View File

@ -58,7 +58,7 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
> >
{({ open }) => ( {({ open }) => (
<div className="relative"> <div className="relative">
<Listbox.Button className="w-full flex justify-end items-center gap-1 text-xs cursor-pointer"> <Listbox.Button className="w-full flex items-center gap-1 text-xs cursor-pointer">
<span <span
className={classNames( className={classNames(
value ? "" : "text-gray-900", value ? "" : "text-gray-900",
@ -121,11 +121,14 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
<Transition <Transition
show={open} show={open}
as={React.Fragment} as={React.Fragment}
leave="transition ease-in duration-100" enter="transition ease-out duration-100"
leaveFrom="opacity-100" enterFrom="transform opacity-0 scale-95"
leaveTo="opacity-0" 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 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"> <Listbox.Options className="absolute z-10 right-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="py-1"> <div className="py-1">
{people ? ( {people ? (
people.length > 0 ? ( people.length > 0 ? (
@ -135,7 +138,7 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
className={({ active, selected }) => className={({ active, selected }) =>
`${ `${
active || selected ? "bg-indigo-50" : "" active || selected ? "bg-indigo-50" : ""
} flex items-center gap-2 text-gray-900 cursor-pointer select-none relative p-2 rounded-md truncate` } flex items-center gap-2 text-gray-900 cursor-pointer select-none p-2 truncate`
} }
value={option.member.id} value={option.member.id}
> >
@ -150,13 +153,15 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
/> />
</div> </div>
) : ( ) : (
<div className="h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full"> <div className="flex-shrink-0 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 && option.member.first_name !== ""
? option.member.first_name.charAt(0) ? option.member.first_name.charAt(0)
: option.member.email.charAt(0)} : option.member.email.charAt(0)}
</div> </div>
)} )}
{option.member.first_name} {option.member.first_name && option.member.first_name !== ""
? option.member.first_name
: option.member.email}
</Listbox.Option> </Listbox.Option>
)) ))
) : ( ) : (

View File

@ -10,32 +10,28 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui // ui
import { Button } from "ui"; import { Button } from "ui";
// icons // icons
import { import { FolderIcon, MagnifyingGlassIcon, FlagIcon, XMarkIcon } from "@heroicons/react/24/outline";
FolderIcon,
MagnifyingGlassIcon,
UserGroupIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types // types
import { IIssue } from "types"; import { IIssue, IssueResponse } from "types";
// constants // constants
import { classNames } from "constants/common"; import { classNames } from "constants/common";
import issuesService from "lib/services/issues.service";
type FormInput = { type FormInput = {
issue_ids: string[]; issue_ids: string[];
}; };
type Props = { type Props = {
submitChanges: (formData: Partial<IIssue>) => void; issueDetail: IIssue | undefined;
issuesList: IIssue[]; issuesList: IIssue[];
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
}; };
const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) => { const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const { activeProject, issues } = useUser(); const { activeWorkspace, activeProject, issues, mutateIssues } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { register, handleSubmit, reset, watch: watchIssues } = useForm<FormInput>(); const { register, handleSubmit, reset, watch: watchIssues } = useForm<FormInput>();
@ -54,16 +50,73 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
}); });
return; return;
} }
const newBlocked = [...watch("blocked_list"), ...data.issue_ids];
submitChanges({ blocked_list: newBlocked }); data.issue_ids.map((issue) => {
handleClose(); if (!activeWorkspace || !activeProject || !issueDetail) return;
const currentBlockers =
issues?.results
.find((i) => i.id === issue)
?.blocker_issues.map((b) => b.blocker_issue_detail?.id ?? "") ?? [];
issuesService
.patchIssue(activeWorkspace.slug, activeProject.id, issue, {
blockers_list: [...currentBlockers, issueDetail.id],
})
.then((response) => {
mutateIssues((prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((issue) => {
if (issue.id === issueDetail.id) {
return { ...issue, ...response };
}
return issue;
}),
}));
})
.catch((error) => {
console.log(error);
});
});
// handleClose();
};
const removeBlocked = (issueId: string) => {
if (!activeWorkspace || !activeProject || !issueDetail) return;
const currentBlockers =
issues?.results
.find((i) => i.id === issueId)
?.blocker_issues.map((b) => b.blocker_issue_detail?.id ?? "") ?? [];
const updatedBlockers = currentBlockers.filter((b) => b !== issueDetail.id);
issuesService
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, {
blockers_list: updatedBlockers,
})
.then((response) => {
mutateIssues((prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((issue) => {
if (issue.id === issueDetail.id) {
return { ...issue, ...response };
}
return issue;
}),
}));
})
.catch((error) => {
console.log(error);
});
}; };
return ( return (
<div className="flex items-start py-2 flex-wrap"> <div className="flex items-start py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2"> <div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="flex-shrink-0 h-4 w-4" /> <FlagIcon className="flex-shrink-0 h-4 w-4" />
<p>Blocked issues</p> <p>Blocked by</p>
</div> </div>
<div className="sm:basis-1/2 space-y-1"> <div className="sm:basis-1/2 space-y-1">
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
@ -71,13 +124,8 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
? watch("blocked_list").map((issue) => ( ? watch("blocked_list").map((issue) => (
<span <span
key={issue} 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" className="group flex items-center gap-1 border rounded-2xl text-xs px-1.5 py-0.5 text-red-500 hover:bg-red-50 border-red-500 cursor-pointer"
onClick={() => { onClick={() => removeBlocked(issue)}
const updatedBlockers = watch("blocked_list").filter((i) => i !== issue);
submitChanges({
blocked_list: updatedBlockers,
});
}}
> >
{`${activeProject?.identifier}-${ {`${activeProject?.identifier}-${
issues?.results.find((i) => i.id === issue)?.sequence_id issues?.results.find((i) => i.id === issue)?.sequence_id
@ -145,7 +193,10 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
)} )}
<ul className="text-sm text-gray-700"> <ul className="text-sm text-gray-700">
{issuesList.map((issue) => { {issuesList.map((issue) => {
if (!watch("blocked_list").includes(issue.id)) { if (
!watch("blocked_list").includes(issue.id) &&
!watch("blockers_list").includes(issue.id)
) {
return ( return (
<Combobox.Option <Combobox.Option
key={issue.id} key={issue.id}

View File

@ -10,12 +10,7 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui // ui
import { Button } from "ui"; import { Button } from "ui";
// icons // icons
import { import { FlagIcon, FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
FolderIcon,
MagnifyingGlassIcon,
UserGroupIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
// constants // constants
@ -62,8 +57,8 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
return ( return (
<div className="flex items-start py-2 flex-wrap"> <div className="flex items-start py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2"> <div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="flex-shrink-0 h-4 w-4" /> <FlagIcon className="flex-shrink-0 h-4 w-4" />
<p>Blocker issues</p> <p>Blocking</p>
</div> </div>
<div className="sm:basis-1/2 space-y-1"> <div className="sm:basis-1/2 space-y-1">
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
@ -71,7 +66,7 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
? watch("blockers_list").map((issue) => ( ? watch("blockers_list").map((issue) => (
<span <span
key={issue} 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" className="group flex items-center gap-1 border rounded-2xl text-xs px-1.5 py-0.5 text-yellow-500 hover:bg-yellow-50 border-yellow-500 cursor-pointer"
onClick={() => { onClick={() => {
const updatedBlockers = watch("blockers_list").filter((i) => i !== issue); const updatedBlockers = watch("blockers_list").filter((i) => i !== issue);
submitChanges({ submitChanges({
@ -145,7 +140,10 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
)} )}
<ul className="text-sm text-gray-700"> <ul className="text-sm text-gray-700">
{issuesList.map((issue) => { {issuesList.map((issue) => {
if (!watch("blockers_list").includes(issue.id)) { if (
!watch("blockers_list").includes(issue.id) &&
!watch("blocked_list").includes(issue.id)
) {
return ( return (
<Combobox.Option <Combobox.Option
key={issue.id} key={issue.id}

View File

@ -1,20 +1,21 @@
// react
import React from "react";
// react-hook-form // react-hook-form
import { Control, Controller } from "react-hook-form"; import { Control, Controller } from "react-hook-form";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// headless ui // ui
import { Listbox, Transition } from "@headlessui/react"; import { Spinner, CustomSelect } from "ui";
// icons
import { ArrowPathIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue } from "types"; import { ICycle, IIssue } from "types";
// common
import { classNames } from "constants/common"; 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 = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
handleCycleChange: (cycleId: string) => void; handleCycleChange: (cycle: ICycle) => void;
}; };
const SelectCycle: React.FC<Props> = ({ control, handleCycleChange }) => { const SelectCycle: React.FC<Props> = ({ control, handleCycleChange }) => {
@ -29,7 +30,7 @@ const SelectCycle: React.FC<Props> = ({ control, handleCycleChange }) => {
<div className="sm:basis-1/2"> <div className="sm:basis-1/2">
<Controller <Controller
control={control} control={control}
name="cycle" name="issue_cycle"
render={({ field: { value } }) => ( render={({ field: { value } }) => (
<> <>
<CustomSelect <CustomSelect
@ -40,12 +41,12 @@ const SelectCycle: React.FC<Props> = ({ control, handleCycleChange }) => {
"hidden truncate sm:block text-left" "hidden truncate sm:block text-left"
)} )}
> >
{value ? cycles?.find((c) => c.id === value)?.name : "None"} {value ? cycles?.find((c) => c.id === value.cycle_detail.id)?.name : "None"}
</span> </span>
} }
value={value} value={value}
onChange={(value: any) => { onChange={(value: any) => {
handleCycleChange(value); handleCycleChange(cycles?.find((c) => c.id === value) as any);
}} }}
> >
{cycles ? ( {cycles ? (

View File

@ -12,6 +12,7 @@ import { IIssue } from "types";
import { classNames } from "constants/common"; import { classNames } from "constants/common";
import { PRIORITIES } from "constants/"; import { PRIORITIES } from "constants/";
import CustomSelect from "ui/custom-select"; import CustomSelect from "ui/custom-select";
import { getPriorityIcon } from "constants/global";
type Props = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
@ -33,7 +34,18 @@ const SelectPriority: React.FC<Props> = ({ control, submitChanges, watch }) => {
render={({ field: { value } }) => ( render={({ field: { value } }) => (
<CustomSelect <CustomSelect
label={ label={
<span className={classNames(value ? "" : "text-gray-900", "text-left capitalize")}> <span
className={classNames(
value ? "" : "text-gray-900",
"text-left capitalize flex items-center gap-2"
)}
>
{getPriorityIcon(
watch("priority") && watch("priority") !== ""
? watch("priority") ?? ""
: "None",
"text-sm"
)}
{watch("priority") && watch("priority") !== "" ? watch("priority") : "None"} {watch("priority") && watch("priority") !== "" ? watch("priority") : "None"}
</span> </span>
} }
@ -44,7 +56,10 @@ const SelectPriority: React.FC<Props> = ({ control, submitChanges, watch }) => {
> >
{PRIORITIES.map((option) => ( {PRIORITIES.map((option) => (
<CustomSelect.Option key={option} value={option} className="capitalize"> <CustomSelect.Option key={option} value={option} className="capitalize">
<>
{getPriorityIcon(option, "text-sm")}
{option} {option}
</>
</CustomSelect.Option> </CustomSelect.Option>
))} ))}
</CustomSelect> </CustomSelect>

View File

@ -8,7 +8,7 @@ import useSWR from "swr";
// headless ui // headless ui
import { Disclosure, Listbox, Menu, Transition } from "@headlessui/react"; import { Disclosure, Listbox, Menu, Transition } from "@headlessui/react";
// ui // ui
import { Spinner } from "ui"; import { CustomMenu, Spinner } from "ui";
// icons // icons
import { import {
ChevronDownIcon, ChevronDownIcon,
@ -18,7 +18,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import User from "public/user.png"; import User from "public/user.png";
// components // components
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
// types // types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// services // services
@ -93,6 +93,9 @@ const ListView: React.FC<Props> = ({
<h2 className="font-medium leading-5 capitalize"> <h2 className="font-medium leading-5 capitalize">
{singleGroup === null || singleGroup === "null" {singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority" ? selectedGroup === "priority" && "No priority"
: selectedGroup === "created_by"
? people?.find((p) => p.member.id === singleGroup)?.member
?.first_name ?? "Loading..."
: addSpaceIfCamelCase(singleGroup)} : addSpaceIfCamelCase(singleGroup)}
</h2> </h2>
) : ( ) : (
@ -152,11 +155,11 @@ const ListView: React.FC<Props> = ({
{activeProject?.identifier}-{issue.sequence_id} {activeProject?.identifier}-{issue.sequence_id}
</span> </span>
)} )}
<span className="">{issue.name}</span> <span>{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"> {/* <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> <h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div> <div>{issue.name}</div>
</div> </div> */}
</a> </a>
</Link> </Link>
</div> </div>
@ -307,7 +310,7 @@ const ListView: React.FC<Props> = ({
</div> </div>
</div> </div>
)} )}
{properties.target_date && ( {properties.due_date && (
<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 ${ 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 === null
@ -332,14 +335,14 @@ const ListView: React.FC<Props> = ({
<div> <div>
{issue.target_date && {issue.target_date &&
(issue.target_date < new Date().toISOString() (issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft( ? `Due date has passed by ${findHowManyDaysLeft(
issue.target_date issue.target_date
)} days` )} days`
: findHowManyDaysLeft(issue.target_date) <= 3 : findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft( ? `Due date is in ${findHowManyDaysLeft(
issue.target_date issue.target_date
)} days` )} days`
: "Target date")} : "Due date")}
</div> </div>
</div> </div>
</div> </div>
@ -480,18 +483,8 @@ const ListView: React.FC<Props> = ({
)} )}
</Listbox> </Listbox>
)} )}
<Menu as="div" className="relative"> <CustomMenu ellipsis>
<Menu.Button <CustomMenu.MenuItem
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={() => { onClick={() => {
setSelectedIssue({ setSelectedIssue({
...issue, ...issue,
@ -500,23 +493,15 @@ const ListView: React.FC<Props> = ({
}} }}
> >
Edit Edit
</button> </CustomMenu.MenuItem>
</Menu.Item> <CustomMenu.MenuItem
<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={() => { onClick={() => {
handleDeleteIssue(issue.id); handleDeleteIssue(issue.id);
}} }}
> >
Delete permanently Delete permanently
</button> </CustomMenu.MenuItem>
</div> </CustomMenu>
</Menu.Item>
</Menu.Items>
</Menu>
</div> </div>
</div> </div>
); );
@ -548,6 +533,10 @@ const ListView: React.FC<Props> = ({
[selectedGroup]: singleGroup, [selectedGroup]: singleGroup,
actionType: "createIssue", actionType: "createIssue",
}); });
} else {
setPreloadedData({
actionType: "createIssue",
});
} }
}} }}
> >

View File

@ -3,14 +3,13 @@ import React, { useState } from "react";
// next // next
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
import { useRouter } from "next/router";
// services
import projectService from "lib/services/project.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// Services // ui
import projectService from "lib/services/project.service"; import { Button } from "ui";
// fetch keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
// commons
import { renderShortNumericDateFormat } from "constants/common";
// icons // icons
import { import {
CalendarDaysIcon, CalendarDaysIcon,
@ -20,9 +19,15 @@ import {
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
TrashIcon, TrashIcon,
ClipboardDocumentListIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import type { IProject } from "types"; import type { IProject } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
// common
import { renderShortNumericDateFormat } from "constants/common";
type Props = { type Props = {
project: IProject; project: IProject;
slug: string; slug: string;
@ -40,6 +45,8 @@ const ProjectMemberInvitations: React.FC<Props> = ({
}) => { }) => {
const { user } = useUser(); const { user } = useUser();
const router = useRouter();
const { data: members } = useSWR<any[]>(PROJECT_MEMBERS(project.id), () => const { data: members } = useSWR<any[]>(PROJECT_MEMBERS(project.id), () =>
projectService.projectMembers(slug, project.id) projectService.projectMembers(slug, project.id)
); );
@ -59,7 +66,7 @@ const ProjectMemberInvitations: React.FC<Props> = ({
return ( return (
<> <>
<div <div
className={`w-full h-full flex flex-col px-4 py-3 rounded-md bg-white ${ className={`w-full h-full flex flex-col px-4 py-3 rounded-md border bg-white ${
selected ? "ring-2 ring-indigo-400" : "" selected ? "ring-2 ring-indigo-400" : ""
}`} }`}
> >
@ -93,13 +100,13 @@ const ProjectMemberInvitations: React.FC<Props> = ({
{isMember ? ( {isMember ? (
<div className="flex"> <div className="flex">
<Link href={`/projects/${project.id}/settings`}> <Link href={`/projects/${project.id}/settings`}>
<a className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 cursor-pointer"> <a className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 cursor-pointer">
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
</a> </a>
</Link> </Link>
<button <button
type="button" type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none" className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none"
onClick={() => setDeleteProject(project.id)} onClick={() => setDeleteProject(project.id)}
> >
<TrashIcon className="h-4 w-4 text-red-500" /> <TrashIcon className="h-4 w-4 text-red-500" />
@ -115,7 +122,7 @@ const ProjectMemberInvitations: React.FC<Props> = ({
{!isMember ? ( {!isMember ? (
<label <label
htmlFor={project.id} htmlFor={project.id}
className="flex items-center gap-1 text-xs font-medium bg-blue-200 hover:bg-blue-300 p-2 rounded duration-300 cursor-pointer" className="flex items-center gap-1 text-xs font-medium border hover:bg-gray-100 p-2 rounded duration-300 cursor-pointer"
> >
{selected ? ( {selected ? (
<> <>
@ -130,17 +137,19 @@ const ProjectMemberInvitations: React.FC<Props> = ({
)} )}
</label> </label>
) : ( ) : (
<span className="flex items-center gap-1 text-xs bg-green-200 p-2 rounded"> <Button theme="secondary" className="flex items-center gap-1" disabled>
<CheckIcon className="h-3 w-3" /> <CheckIcon className="h-3 w-3" />
Member Member
</span> </Button>
)} )}
<Link href={`/projects/${project.id}/issues`}> <Button
<a className="flex items-center gap-1 text-xs font-medium bg-blue-200 hover:bg-blue-300 p-2 rounded duration-300"> theme="secondary"
<EyeIcon className="h-3 w-3" /> className="flex items-center gap-1"
View onClick={() => router.push(`/projects/${project.id}/issues`)}
</a> >
</Link> <ClipboardDocumentListIcon className="h-3 w-3" />
Open Project
</Button>
</div> </div>
<div className="flex items-center gap-1 text-xs mb-1"> <div className="flex items-center gap-1 text-xs mb-1">
<CalendarDaysIcon className="h-4 w-4" /> <CalendarDaysIcon className="h-4 w-4" />

View File

@ -1,197 +0,0 @@
// react
import React from "react";
// 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 { Button } from "ui";
// icons
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { IProject } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IProject, any>;
isSubmitting: boolean;
};
const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => {
const { activeWorkspace } = useUser();
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Control</h3>
<p className="mt-1 text-sm text-gray-500">Set the control for the project.</p>
</div>
<div className="flex justify-between gap-3">
<div className="w-full md:w-1/2">
<Controller
control={control}
name="project_lead"
render={({ field: { onChange, value } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Project Lead</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((person) => person.member.id === value)?.member
.first_name ?? "Select Lead"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
<div className="w-full md:w-1/2">
<Controller
control={control}
name="default_assignee"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Default Assignee</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((p) => p.member.id === value)?.member.first_name ??
"Select Default Assignee"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section>
</>
);
};
export default ControlSettings;

View File

@ -1,125 +0,0 @@
// react
import { useCallback } from "react";
// react-hook-form
import { UseFormRegister, UseFormSetError } from "react-hook-form";
// services
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Button, Input, Select, TextArea } from "ui";
// types
import { IProject } from "types";
// constants
import { debounce } from "constants/common";
type Props = {
register: UseFormRegister<IProject>;
errors: any;
setError: UseFormSetError<IProject>;
isSubmitting: boolean;
};
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const GeneralSettings: React.FC<Props> = ({ register, errors, setError, isSubmitting }) => {
const { activeWorkspace } = useUser();
const checkIdentifier = (slug: string, value: string) => {
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
console.log(response);
if (response.exists) setError("identifier", { message: "Identifier already exists" });
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
return (
<>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">General</h3>
<p className="mt-1 text-sm text-gray-500">
This information will be displayed to every member of the project.
</p>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-2">
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
label="Name"
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
label="Network"
register={register}
validations={{
required: "Network is required",
}}
/>
</div>
<div>
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
label="Identifier"
onChange={(e: any) => {
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{
required: "Identifier is required",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 9,
message: "Identifier must at most be of 9 characters",
},
}}
/>
</div>
</div>
<div>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
label="Description"
placeholder="Enter project description"
validations={{
required: "Description is required",
}}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section>
</>
);
};
export default GeneralSettings;

View File

@ -1,285 +0,0 @@
// react
import React, { useState } from "react";
// swr
import useSWR from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Popover, Transition, Menu } from "@headlessui/react";
// ui
import { Button, Input, Spinner } from "ui";
// icons
import {
ChevronDownIcon,
EllipsisHorizontalIcon,
PencilIcon,
PlusIcon,
RectangleGroupIcon,
} from "@heroicons/react/24/outline";
// types
import { IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
const defaultValues: Partial<IIssueLabels> = {
name: "",
colour: "#ff0000",
};
const LabelsSettings: React.FC = () => {
const [newLabelForm, setNewLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [labelIdForUpdate, setLabelidForUpdate] = useState<string | null>(null);
const { activeWorkspace, activeProject } = useUser();
const {
register,
handleSubmit,
reset,
control,
setValue,
formState: { errors, isSubmitting },
watch,
} = useForm<IIssueLabels>({ defaultValues });
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
activeProject && activeWorkspace
? () => issuesServices.getIssueLabels(activeWorkspace.slug, activeProject.id)
: null
);
const handleNewLabel: SubmitHandler<IIssueLabels> = (formData) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
issuesServices
.createIssueLabel(activeWorkspace.slug, activeProject.id, formData)
.then((res) => {
console.log(res);
reset(defaultValues);
mutate((prevData) => [...(prevData ?? []), res], false);
setNewLabelForm(false);
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = (formData) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
issuesServices
.patchIssueLabel(activeWorkspace.slug, activeProject.id, labelIdForUpdate ?? "", formData)
.then((res) => {
console.log(res);
reset(defaultValues);
mutate(
(prevData) =>
prevData?.map((p) => (p.id === labelIdForUpdate ? { ...p, ...formData } : p)),
false
);
setNewLabelForm(false);
});
};
const handleLabelDelete = (labelId: string) => {
if (activeWorkspace && activeProject) {
mutate((prevData) => prevData?.filter((p) => p.id !== labelId), false);
issuesServices
.deleteIssueLabel(activeWorkspace.slug, activeProject.id, labelId)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
}
};
const getLabelChildren = (labelId: string) => {
return issueLabels?.filter((l) => l.parent === labelId);
};
return (
<>
<section className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Labels</h3>
<p className="mt-1 text-sm text-gray-500">Manage the labels of this project.</p>
</div>
<Button className="flex items-center gap-x-1" onClick={() => setNewLabelForm(true)}>
<PlusIcon className="h-4 w-4" />
New label
</Button>
</div>
<div className="space-y-5">
<div
className={`bg-white px-4 py-2 flex items-center gap-2 ${newLabelForm ? "" : "hidden"}`}
>
<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-6 h-6 rounded"
style={{
backgroundColor: watch("colour") ?? "green",
}}
></span>
)}
<ChevronDownIcon className="h-4 w-4" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute z-20 transform left-0 mt-1 px-2 max-w-xs sm:px-0">
<Controller
name="colour"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="w-full flex flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Lable title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<Button type="button" theme="secondary" onClick={() => setNewLabelForm(false)}>
Cancel
</Button>
{isUpdating ? (
<Button
type="button"
onClick={handleSubmit(handleLabelUpdate)}
disabled={isSubmitting}
>
{isSubmitting ? "Updating" : "Update"}
</Button>
) : (
<Button type="button" onClick={handleSubmit(handleNewLabel)} disabled={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</Button>
)}
</div>
{issueLabels ? (
issueLabels.map((label) => {
const children = getLabelChildren(label.id);
return (
<React.Fragment key={label.id}>
{children && children.length === 0 ? (
<div className="bg-white p-2 flex items-center justify-between text-gray-900 rounded-md">
<div className="flex items-center gap-2">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label.colour,
}}
/>
<p className="text-sm">{label.name}</p>
</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
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={() => {
setNewLabelForm(true);
setValue("colour", label.colour);
setValue("name", label.name);
setIsUpdating(true);
setLabelidForUpdate(label.id);
}}
>
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={() => handleLabelDelete(label.id)}
>
Delete
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
) : (
<div className="bg-white p-4 text-gray-900 rounded-md">
<h3 className="font-medium leading-5 flex items-center gap-2">
<RectangleGroupIcon className="h-5 w-5" />
This is the label group title
</h3>
<div className="pl-5 mt-4">
<div className="group text-sm flex justify-between items-center p-2 hover:bg-gray-100 rounded">
<h5 className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-600 rounded-full"></div>
This is the label title
</h5>
<button type="button" className="hidden group-hover:block">
<PencilIcon className="h-3 w-3" />
</button>
</div>
</div>
</div>
)}
</React.Fragment>
);
})
) : (
<div className="flex justify-center py-4">
<Spinner />
</div>
)}
</div>
</section>
</>
);
};
export default LabelsSettings;

View File

@ -1,78 +0,0 @@
// react
import { useState } from "react";
// hooks
import useUser from "lib/hooks/useUser";
// components
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
// ui
import { Button } from "ui";
// icons
import { PencilSquareIcon, PlusIcon } from "@heroicons/react/24/outline";
// constants
import { addSpaceIfCamelCase } from "constants/common";
type Props = {
projectId: string | string[] | undefined;
};
const StatesSettings: React.FC<Props> = ({ projectId }) => {
const [isCreateStateModal, setIsCreateStateModal] = useState(false);
const [selectedState, setSelectedState] = useState<string | undefined>();
const { states } = useUser();
return (
<>
<CreateUpdateStateModal
isOpen={isCreateStateModal || Boolean(selectedState)}
handleClose={() => {
setSelectedState(undefined);
setIsCreateStateModal(false);
}}
projectId={projectId as string}
data={selectedState ? states?.find((state) => state.id === selectedState) : undefined}
/>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">State</h3>
<p className="mt-1 text-sm text-gray-500">Manage the state of this project.</p>
</div>
<div className="flex justify-between gap-3">
<div className="w-full space-y-5">
{states?.map((state) => (
<div
key={state.id}
className="bg-white px-4 py-2 rounded flex justify-between items-center"
>
<div className="flex items-center gap-x-2">
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: state.color,
}}
></div>
<h4>{addSpaceIfCamelCase(state.name)}</h4>
</div>
<div>
<button type="button" onClick={() => setSelectedState(state.id)}>
<PencilSquareIcon className="h-5 w-5 text-gray-400" />
</button>
</div>
</div>
))}
<Button
type="button"
className="flex items-center gap-x-1"
onClick={() => setIsCreateStateModal(true)}
>
<PlusIcon className="h-4 w-4" />
<span>Add State</span>
</Button>
</div>
</div>
</section>
</>
);
};
export default StatesSettings;

View File

@ -0,0 +1,160 @@
// react
import React, { useState } from "react";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { Button, CustomMenu, Input } from "ui";
// icons
import { PencilIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
// types
import { IIssueLabels } from "types";
import { TwitterPicker } from "react-color";
type Props = {
label: IIssueLabels;
issueLabels: IIssueLabels[];
editLabel: (label: IIssueLabels) => void;
handleLabelDelete: (labelId: string) => void;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
colour: "#ff0000",
};
const SingleLabel: React.FC<Props> = ({ label, issueLabels, editLabel, handleLabelDelete }) => {
const [newLabelForm, setNewLabelForm] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
watch,
reset,
control,
} = useForm<IIssueLabels>({ defaultValues });
const children = issueLabels?.filter((l) => l.parent === label.id);
return (
<>
{children && children.length === 0 ? (
<div className="md:w-2/3 gap-2 border p-3 rounded-md divide-y space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span
className="flex-shrink-0 h-3 w-3 rounded-full"
style={{
backgroundColor: label.colour,
}}
/>
<h6 className="text-sm">{label.name}</h6>
</div>
<CustomMenu ellipsis>
{/* <CustomMenu.MenuItem>Convert to group</CustomMenu.MenuItem> */}
<CustomMenu.MenuItem
onClick={() => {
editLabel(label);
}}
>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className={`flex items-center gap-2 ${newLabelForm ? "" : "hidden"}`}>
<div className="flex-shrink-0 h-8 w-8">
<Popover className="relative w-full h-full flex justify-center items-center bg-gray-200 rounded-xl">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
open ? "text-gray-900" : "text-gray-500"
}`}
>
{watch("colour") && watch("colour") !== "" && (
<span
className="w-4 h-4 rounded"
style={{
backgroundColor: watch("colour") ?? "green",
}}
></span>
)}
</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 top-full z-20 left-0 mt-3 px-2 w-screen max-w-xs sm:px-0">
<Controller
name="colour"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="w-full flex flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Lable title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<Button type="button" theme="secondary" onClick={() => setNewLabelForm(false)}>
Cancel
</Button>
<Button type="button" disabled={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</Button>
</div>
</div>
) : (
<div className="bg-white p-4 text-gray-900 rounded-md">
<h3 className="font-medium leading-5 flex items-center gap-2">
<RectangleGroupIcon className="h-5 w-5" />
This is the label group title
</h3>
<div className="pl-5 mt-4">
<div className="group text-sm flex justify-between items-center p-2 hover:bg-gray-100 rounded">
<h5 className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-600 rounded-full"></div>
This is the label title
</h5>
<button type="button" className="hidden group-hover:block">
<PencilIcon className="h-3 w-3" />
</button>
</div>
</div>
</div>
)}
</>
);
};
export default SingleLabel;

View File

@ -58,13 +58,20 @@ const ProjectsList: React.FC<Props> = ({ navigation, sidebarCollapse }) => {
<> <>
<div className="flex items-center"> <div className="flex items-center">
<Disclosure.Button <Disclosure.Button
className={`w-full flex items-center gap-2 font-medium rounded-md p-2 text-sm ${ className={`w-full flex items-center text-left gap-2 font-medium rounded-md p-2 text-sm ${
sidebarCollapse ? "justify-center" : "" sidebarCollapse ? "justify-center" : ""
}`} }`}
> >
{project.icon ? (
<span className="text-white rounded h-7 w-7 grid place-items-center uppercase flex-shrink-0">
{String.fromCodePoint(parseInt(project.icon))}
</span>
) : (
<span className="bg-gray-700 text-white rounded h-7 w-7 grid place-items-center uppercase flex-shrink-0"> <span className="bg-gray-700 text-white rounded h-7 w-7 grid place-items-center uppercase flex-shrink-0">
{project?.name.charAt(0)} {project?.name.charAt(0)}
</span> </span>
)}
{!sidebarCollapse && ( {!sidebarCollapse && (
<span className="flex items-center justify-between w-full"> <span className="flex items-center justify-between w-full">
{project?.name} {project?.name}

View File

@ -1,4 +1,4 @@
import { FC, CSSProperties } from "react"; import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
// next // next
import Script from "next/script"; import Script from "next/script";
@ -10,19 +10,17 @@ export interface IGoogleLoginButton {
} }
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => { export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
return ( const googleSignInButton = useRef<HTMLDivElement>(null);
<> const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
<Script
src="https://accounts.google.com/gsi/client" const loadScript = useCallback(() => {
async if (!googleSignInButton.current || gsiScriptLoaded) return;
defer
onLoad={() => {
window?.google?.accounts.id.initialize({ window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: props.onSuccess as any, callback: props.onSuccess as any,
}); });
window?.google?.accounts.id.renderButton( window?.google?.accounts.id.renderButton(
document.getElementById("googleSignInButton") as HTMLElement, googleSignInButton.current,
{ {
type: "standard", type: "standard",
theme: "outline", theme: "outline",
@ -33,9 +31,22 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
} as GsiButtonConfiguration // customization attributes } as GsiButtonConfiguration // customization attributes
); );
window?.google?.accounts.id.prompt(); // also display the One Tap dialog window?.google?.accounts.id.prompt(); // also display the One Tap dialog
}} setGsiScriptLoaded(true);
/> }, [props.onSuccess, gsiScriptLoaded]);
<div className="w-full" id="googleSignInButton"></div>
useEffect(() => {
if (window?.google?.accounts?.id) {
loadScript();
}
return () => {
window?.google?.accounts.id.cancel();
};
}, [loadScript]);
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div className="w-full" id="googleSignInButton" ref={googleSignInButton}></div>
</> </>
); );
}; };

View File

@ -64,6 +64,8 @@ export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`;
export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) => export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`;
export const PROJECT_MEMBER_ME = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`;
export const PROJECT_VIEW_ENDPOINT = (workspaceSlug: string, projectId: string) => export const PROJECT_VIEW_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-views/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/project-views/`;
@ -108,11 +110,6 @@ export const FILTER_STATE_ISSUES = (workspaceSlug: string, projectId: string, st
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/?state=${state}`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/?state=${state}`;
export const BULK_DELETE_ISSUES = (workspaceSlug: string, projectId: string) => export const BULK_DELETE_ISSUES = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`;
export const BULK_ADD_ISSUES_TO_CYCLE = (
workspaceSlug: string,
projectId: string,
cycleId: string
) => `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/bulk-assign-issues/`;
// states // states
export const STATES_ENDPOINT = (workspaceSlug: string, projectId: string) => export const STATES_ENDPOINT = (workspaceSlug: string, projectId: string) =>

View File

@ -26,7 +26,7 @@ export const renderShortNumericDateFormat = (date: string | Date) => {
export const groupBy = (array: any[], key: string) => { export const groupBy = (array: any[], key: string) => {
const innerKey = key.split("."); // split the key by dot const innerKey = key.split("."); // split the key by dot
return array.reduce((result, currentValue) => { return array.reduce((result, currentValue) => {
const key = innerKey.reduce((obj, i) => obj[i], currentValue); // get the value of the inner key const key = innerKey.reduce((obj, i) => obj?.[i], currentValue) ?? "None"; // get the value of the inner key
(result[key] = result[key] || []).push(currentValue); (result[key] = result[key] || []).push(currentValue);
return result; return result;
}, {}); }, {});
@ -216,3 +216,23 @@ export const createSimilarString = (str: string) => {
return shuffled; return shuffled;
}; };
export const getRandomEmoji = () => {
const emojis = [
"8986",
"9200",
"128204",
"127773",
"127891",
"127947",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
return emojis[Math.floor(Math.random() * emojis.length)];
};

View File

@ -5,6 +5,7 @@ export const USER_WORKSPACES = "USER_WORKSPACES";
export const WORKSPACE_MEMBERS = (workspaceSlug: string) => `WORKSPACE_MEMBERS_${workspaceSlug}`; export const WORKSPACE_MEMBERS = (workspaceSlug: string) => `WORKSPACE_MEMBERS_${workspaceSlug}`;
export const WORKSPACE_INVITATIONS = "WORKSPACE_INVITATIONS"; export const WORKSPACE_INVITATIONS = "WORKSPACE_INVITATIONS";
export const WORKSPACE_INVITATION = "WORKSPACE_INVITATION"; export const WORKSPACE_INVITATION = "WORKSPACE_INVITATION";
export const LAST_ACTIVE_WORKSPACE_AND_PROJECTS = "LAST_ACTIVE_WORKSPACE_AND_PROJECTS";
export const PROJECTS_LIST = (workspaceSlug: string) => `PROJECTS_LIST_${workspaceSlug}`; export const PROJECTS_LIST = (workspaceSlug: string) => `PROJECTS_LIST_${workspaceSlug}`;
export const PROJECT_DETAILS = (projectId: string) => `PROJECT_DETAILS_${projectId}`; export const PROJECT_DETAILS = (projectId: string) => `PROJECT_DETAILS_${projectId}`;
@ -17,7 +18,7 @@ export const PROJECT_ISSUES_LIST = (workspaceSlug: string, projectId: string) =>
export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId}`; export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId}`;
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>
`PROJECT_ISSUES_PROPERTIES_${projectId}`; `PROJECT_ISSUES_PROPERTIES_${projectId}`;
export const PROJECT_ISSUES_COMMENTS = "PROJECT_ISSUES_COMMENTS"; export const PROJECT_ISSUES_COMMENTS = (issueId: string) => `PROJECT_ISSUES_COMMENTS_${issueId}`;
export const PROJECT_ISSUES_ACTIVITY = "PROJECT_ISSUES_ACTIVITY"; export const PROJECT_ISSUES_ACTIVITY = "PROJECT_ISSUES_ACTIVITY";
export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId}`; export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId}`;
export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`; export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`;
@ -30,6 +31,7 @@ export const STATE_LIST = (projectId: string) => `STATE_LIST_${projectId}`;
export const STATE_DETAIL = "STATE_DETAIL"; 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 MODULE_LIST = (projectId: string) => `MODULE_LIST_${projectId}`; export const MODULE_LIST = (projectId: string) => `MODULE_LIST_${projectId}`;
export const MODULE_ISSUES = (moduleId: string) => `MODULE_ISSUES_${moduleId}`; export const MODULE_ISSUES = (moduleId: string) => `MODULE_ISSUES_${moduleId}`;

View File

@ -1,12 +1,18 @@
export const getPriorityIcon = (priority: string) => { export const getPriorityIcon = (priority: string, className: string) => {
switch (priority) { switch (priority) {
case "urgent": case "urgent":
return <span className="material-symbols-rounded">signal_cellular_alt</span>; return <span className={`material-symbols-rounded ${className}`}>error</span>;
case "high": case "high":
return <span className="material-symbols-rounded">signal_cellular_alt_2_bar</span>; return <span className={`material-symbols-rounded ${className}`}>signal_cellular_alt</span>;
case "medium": case "medium":
return <span className="material-symbols-rounded">signal_cellular_alt_1_bar</span>; return (
<span className={`material-symbols-rounded ${className}`}>signal_cellular_alt_2_bar</span>
);
case "low":
return (
<span className={`material-symbols-rounded ${className}`}>signal_cellular_alt_1_bar</span>
);
default: default:
return <span>N/A</span>; return null;
} }
}; };

View File

@ -1,3 +1,5 @@
import type { IIssue, NestedKeyOf } from "types";
export const PRIORITIES = ["urgent", "high", "medium", "low"]; export const PRIORITIES = ["urgent", "high", "medium", "low"];
export const ROLE = { export const ROLE = {
@ -8,3 +10,44 @@ export const ROLE = {
}; };
export const NETWORK_CHOICES = { "0": "Secret", "2": "Public" }; export const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
export const GROUP_CHOICES = {
backlog: "Backlog",
unstarted: "Unstarted",
started: "Started",
completed: "Completed",
cancelled: "Cancelled",
};
export const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
// { name: "Cycle", key: "issue_cycle.cycle_detail.name" },
{ name: "Created By", key: "created_by" },
{ name: "None", key: null },
];
export const orderByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "Last created", key: "created_at" },
{ name: "Last updated", key: "updated_at" },
{ name: "Priority", key: "priority" },
{ name: "None", key: null },
];
export const filterIssueOptions: Array<{
name: string;
key: "activeIssue" | "backlogIssue" | null;
}> = [
{
name: "All",
key: null,
},
{
name: "Active Issues",
key: "activeIssue",
},
{
name: "Backlog Issues",
key: "backlogIssue",
},
];

View File

@ -4,3 +4,4 @@ export const SET_ISSUE_VIEW = "SET_ISSUE_VIEW";
export const SET_GROUP_BY_PROPERTY = "SET_GROUP_BY_PROPERTY"; export const SET_GROUP_BY_PROPERTY = "SET_GROUP_BY_PROPERTY";
export const SET_ORDER_BY_PROPERTY = "SET_ORDER_BY_PROPERTY"; export const SET_ORDER_BY_PROPERTY = "SET_ORDER_BY_PROPERTY";
export const SET_FILTER_ISSUES = "SET_FILTER_ISSUES"; export const SET_FILTER_ISSUES = "SET_FILTER_ISSUES";
export const RESET_TO_DEFAULT = "RESET_TO_DEFAULT";

View File

@ -1,4 +1,6 @@
import React, { createContext, useCallback, useReducer, useEffect } from "react"; import React, { createContext, useCallback, useReducer, useEffect } from "react";
// swr
import useSWR from "swr";
// constants // constants
import { import {
TOGGLE_SIDEBAR, TOGGLE_SIDEBAR,
@ -7,9 +9,16 @@ import {
SET_GROUP_BY_PROPERTY, SET_GROUP_BY_PROPERTY,
SET_ORDER_BY_PROPERTY, SET_ORDER_BY_PROPERTY,
SET_FILTER_ISSUES, SET_FILTER_ISSUES,
RESET_TO_DEFAULT,
} from "constants/theme.context.constants"; } from "constants/theme.context.constants";
// components // components
import ToastAlert from "components/toast-alert"; import ToastAlert from "components/toast-alert";
// hooks
import useUser from "lib/hooks/useUser";
// constants
import { USER_PROJECT_VIEW } from "constants/fetch-keys";
// services
import projectService from "lib/services/project.service";
export const themeContext = createContext<ContextType>({} as ContextType); export const themeContext = createContext<ContextType>({} as ContextType);
@ -23,7 +32,8 @@ type ReducerActionType = {
| typeof SET_ISSUE_VIEW | typeof SET_ISSUE_VIEW
| typeof SET_ORDER_BY_PROPERTY | typeof SET_ORDER_BY_PROPERTY
| typeof SET_FILTER_ISSUES | typeof SET_FILTER_ISSUES
| typeof SET_GROUP_BY_PROPERTY; | typeof SET_GROUP_BY_PROPERTY
| typeof RESET_TO_DEFAULT;
payload?: Partial<Theme>; payload?: Partial<Theme>;
}; };
@ -34,10 +44,13 @@ type ContextType = {
groupByProperty: NestedKeyOf<IIssue> | null; groupByProperty: NestedKeyOf<IIssue> | null;
filterIssue: "activeIssue" | "backlogIssue" | null; filterIssue: "activeIssue" | "backlogIssue" | null;
toggleCollapsed: () => void; toggleCollapsed: () => void;
setIssueView: (display: "list" | "kanban") => void;
setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void; setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void;
setOrderBy: (property: NestedKeyOf<IIssue> | null) => void; setOrderBy: (property: NestedKeyOf<IIssue> | null) => void;
setFilterIssue: (property: "activeIssue" | "backlogIssue" | null) => void; setFilterIssue: (property: "activeIssue" | "backlogIssue" | null) => void;
resetFilterToDefault: () => void;
setNewFilterDefaultView: () => void;
setIssueViewToKanban: () => void;
setIssueViewToList: () => void;
}; };
type StateType = Theme; type StateType = Theme;
@ -60,21 +73,18 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
collapsed: !state.collapsed, collapsed: !state.collapsed,
}; };
localStorage.setItem("theme", JSON.stringify(newState)); localStorage.setItem("collapsed", JSON.stringify(newState.collapsed));
return newState; return newState;
case REHYDRATE_THEME: { case REHYDRATE_THEME: {
let newState: any = localStorage.getItem("theme"); let collapsed: any = localStorage.getItem("collapsed");
if (newState !== null) { collapsed = collapsed ? JSON.parse(collapsed) : false;
newState = JSON.parse(newState); return { ...initialState, ...payload, collapsed };
}
return { ...initialState, ...newState };
} }
case SET_ISSUE_VIEW: { case SET_ISSUE_VIEW: {
const newState = { const newState = {
...state, ...state,
issueView: payload?.issueView || "list", issueView: payload?.issueView || "list",
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
@ -85,7 +95,6 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
groupByProperty: payload?.groupByProperty || null, groupByProperty: payload?.groupByProperty || null,
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
@ -96,7 +105,6 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
orderBy: payload?.orderBy || null, orderBy: payload?.orderBy || null,
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
@ -107,68 +115,169 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
filterIssue: payload?.filterIssue || null, filterIssue: payload?.filterIssue || null,
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
}; };
} }
case RESET_TO_DEFAULT: {
return {
...initialState,
...payload,
};
}
default: { default: {
return state; return state;
} }
} }
}; };
const saveDataToServer = async (workspaceSlug: string, projectID: string, state: any) => {
await projectService.setProjectView(workspaceSlug, projectID, {
view_props: state,
});
};
const setNewDefault = async (workspaceSlug: string, projectID: string, state: any) => {
await projectService.setProjectView(workspaceSlug, projectID, {
view_props: state,
default_props: state,
});
};
export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const { activeProject, activeWorkspace } = useUser();
const { data: myViewProps, mutate: mutateMyViewProps } = useSWR(
activeWorkspace && activeProject ? USER_PROJECT_VIEW(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.projectMemberMe(activeWorkspace.slug, activeProject.id)
: null
);
const toggleCollapsed = useCallback(() => { const toggleCollapsed = useCallback(() => {
dispatch({ dispatch({
type: TOGGLE_SIDEBAR, type: TOGGLE_SIDEBAR,
}); });
}, []); }, []);
const setIssueView = useCallback((display: "list" | "kanban") => { const setIssueViewToKanban = useCallback(() => {
dispatch({ dispatch({
type: SET_ISSUE_VIEW, type: SET_ISSUE_VIEW,
payload: { payload: {
issueView: display, issueView: "kanban",
}, },
}); });
}, []); dispatch({
type: SET_GROUP_BY_PROPERTY,
payload: {
groupByProperty: "state_detail.name",
},
});
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, {
...state,
issueView: "kanban",
groupByProperty: "state_detail.name",
});
}, [activeWorkspace, activeProject, state]);
const setGroupByProperty = useCallback((property: NestedKeyOf<IIssue> | null) => { const setIssueViewToList = useCallback(() => {
dispatch({
type: SET_ISSUE_VIEW,
payload: {
issueView: "list",
},
});
dispatch({
type: SET_GROUP_BY_PROPERTY,
payload: {
groupByProperty: null,
},
});
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, {
...state,
issueView: "list",
groupByProperty: null,
});
}, [activeWorkspace, activeProject, state]);
const setGroupByProperty = useCallback(
(property: NestedKeyOf<IIssue> | null) => {
dispatch({ dispatch({
type: SET_GROUP_BY_PROPERTY, type: SET_GROUP_BY_PROPERTY,
payload: { payload: {
groupByProperty: property, groupByProperty: property,
}, },
}); });
}, []);
const setOrderBy = useCallback((property: NestedKeyOf<IIssue> | null) => { if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, {
...state,
groupByProperty: property,
});
},
[activeProject, activeWorkspace, state]
);
const setOrderBy = useCallback(
(property: NestedKeyOf<IIssue> | null) => {
dispatch({ dispatch({
type: SET_ORDER_BY_PROPERTY, type: SET_ORDER_BY_PROPERTY,
payload: { payload: {
orderBy: property, orderBy: property,
}, },
}); });
}, []);
const setFilterIssue = useCallback((property: "activeIssue" | "backlogIssue" | null) => { if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, state);
},
[activeProject, activeWorkspace, state]
);
const setFilterIssue = useCallback(
(property: "activeIssue" | "backlogIssue" | null) => {
dispatch({ dispatch({
type: SET_FILTER_ISSUES, type: SET_FILTER_ISSUES,
payload: { payload: {
filterIssue: property, filterIssue: property,
}, },
}); });
}, []);
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, {
...state,
filterIssue: property,
});
},
[activeProject, activeWorkspace, state]
);
const setNewDefaultView = useCallback(() => {
if (!activeWorkspace || !activeProject) return;
setNewDefault(activeWorkspace.slug, activeProject.id, state).then(() => {
mutateMyViewProps();
});
}, [activeProject, activeWorkspace, state, mutateMyViewProps]);
const resetToDefault = useCallback(() => {
dispatch({
type: RESET_TO_DEFAULT,
payload: myViewProps?.default_props,
});
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, myViewProps?.default_props);
}, [activeProject, activeWorkspace, myViewProps]);
useEffect(() => { useEffect(() => {
dispatch({ dispatch({
type: REHYDRATE_THEME, type: REHYDRATE_THEME,
payload: myViewProps?.view_props,
}); });
}, []); }, [myViewProps]);
return ( return (
<themeContext.Provider <themeContext.Provider
@ -176,13 +285,16 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
collapsed: state.collapsed, collapsed: state.collapsed,
toggleCollapsed, toggleCollapsed,
issueView: state.issueView, issueView: state.issueView,
setIssueView,
groupByProperty: state.groupByProperty, groupByProperty: state.groupByProperty,
setGroupByProperty, setGroupByProperty,
orderBy: state.orderBy, orderBy: state.orderBy,
setOrderBy, setOrderBy,
filterIssue: state.filterIssue, filterIssue: state.filterIssue,
setFilterIssue, setFilterIssue,
resetFilterToDefault: resetToDefault,
setNewFilterDefaultView: setNewDefaultView,
setIssueViewToKanban,
setIssueViewToList,
}} }}
> >
<ToastAlert /> <ToastAlert />

View File

@ -1,9 +1,4 @@
// react import React, { useState } from "react";
import React, { useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
// hooks
import useUser from "lib/hooks/useUser";
// layouts // layouts
import Container from "layouts/container"; import Container from "layouts/container";
import Sidebar from "layouts/navbar/main-siderbar"; import Sidebar from "layouts/navbar/main-siderbar";
@ -25,14 +20,6 @@ const AppLayout: React.FC<Props> = ({
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { user, isUserLoading } = useUser();
useEffect(() => {
if (!isUserLoading && (!user || user === null)) router.push("/signin");
}, [isUserLoading, user, router]);
return ( return (
<Container meta={meta}> <Container meta={meta}>
<CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} /> <CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} />

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
// next // next
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -15,15 +15,16 @@ import {
Bars3Icon, Bars3Icon,
Cog6ToothIcon, Cog6ToothIcon,
RectangleStackIcon, RectangleStackIcon,
UserGroupIcon,
XMarkIcon, XMarkIcon,
ArrowLongLeftIcon, ArrowLongLeftIcon,
QuestionMarkCircleIcon, QuestionMarkCircleIcon,
RectangleGroupIcon, RectangleGroupIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// constants // common
import { classNames } from "constants/common"; import { classNames } from "constants/common";
type Props = { collapse?: boolean };
const navigation = (projectId: string) => [ const navigation = (projectId: string) => [
{ {
name: "Issues", name: "Issues",
@ -35,16 +36,11 @@ const navigation = (projectId: string) => [
href: `/projects/${projectId}/cycles`, href: `/projects/${projectId}/cycles`,
icon: ArrowPathIcon, icon: ArrowPathIcon,
}, },
{ // {
name: "Modules", // name: "Modules",
href: `/projects/${projectId}/modules`, // href: `/projects/${projectId}/modules`,
icon: RectangleGroupIcon, // icon: RectangleGroupIcon,
}, // },
{
name: "Members",
href: `/projects/${projectId}/members`,
icon: UserGroupIcon,
},
{ {
name: "Settings", name: "Settings",
href: `/projects/${projectId}/settings`, href: `/projects/${projectId}/settings`,
@ -52,7 +48,7 @@ const navigation = (projectId: string) => [
}, },
]; ];
const Sidebar: React.FC = () => { const Sidebar: React.FC<Props> = ({ collapse = false }) => {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const router = useRouter(); const router = useRouter();
@ -144,35 +140,35 @@ const Sidebar: React.FC = () => {
</Transition.Root> </Transition.Root>
<div <div
className={`${ className={`${
sidebarCollapse ? "" : "w-auto md:w-60" sidebarCollapse || collapse ? "" : "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">
<div className="h-full flex flex-1 flex-col pt-2"> <div className="h-full flex flex-1 flex-col pt-2">
<WorkspaceOptions sidebarCollapse={sidebarCollapse} /> <WorkspaceOptions sidebarCollapse={sidebarCollapse || collapse} />
<ProjectsList navigation={navigation} sidebarCollapse={sidebarCollapse} /> <ProjectsList navigation={navigation} sidebarCollapse={sidebarCollapse || collapse} />
<div <div
className={`px-2 py-2 w-full self-baseline flex items-center bg-primary ${ className={`px-2 py-2 w-full self-baseline flex items-center bg-primary ${
sidebarCollapse ? "flex-col-reverse" : "" sidebarCollapse || collapse ? "flex-col-reverse" : ""
}`} }`}
> >
<button <button
type="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 ${ 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" : "" sidebarCollapse || collapse ? "justify-center w-full" : ""
}`} }`}
onClick={() => toggleCollapsed()} onClick={() => toggleCollapsed()}
> >
<ArrowLongLeftIcon <ArrowLongLeftIcon
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${ className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
sidebarCollapse ? "rotate-180" : "" sidebarCollapse || collapse ? "rotate-180" : ""
}`} }`}
/> />
</button> </button>
<button <button
type="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 ${ 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" : "" sidebarCollapse || collapse ? "justify-center w-full" : ""
}`} }`}
onClick={() => { onClick={() => {
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {

View File

@ -1,12 +1,13 @@
// next // next
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
type Props = { type Props = {
links: Array<{ links: {
label: string; label: string;
href: string; href: string;
}>; }[];
}; };
const SettingsSidebar: React.FC<Props> = ({ links }) => { const SettingsSidebar: React.FC<Props> = ({ links }) => {
@ -14,9 +15,9 @@ const SettingsSidebar: React.FC<Props> = ({ links }) => {
return ( return (
<nav className="h-screen w-72 border-r border-gray-200"> <nav className="h-screen w-72 border-r border-gray-200">
<div className="h-full p-2 pt-4"> <div className="p-2 pl-6 mt-16">
<h2 className="text-lg font-medium leading-5">Settings</h2> <h2 className="flex items-center gap-2 text-lg font-medium leading-5">Settings</h2>
<div className="mt-3"> <div className="mt-6 space-y-1">
{links.map((link, index) => ( {links.map((link, index) => (
<h4 key={index}> <h4 key={index}>
<Link href={link.href}> <Link href={link.href}>

View File

@ -19,12 +19,11 @@ 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;
links: Array<{ type: "workspace" | "project";
label: string;
href: string;
}>;
}; };
const SettingsLayout: React.FC<Props> = ({ const SettingsLayout: React.FC<Props> = ({
@ -32,30 +31,80 @@ const SettingsLayout: React.FC<Props> = ({
children, children,
noPadding = false, noPadding = false,
bg = "primary", bg = "primary",
noHeader = false,
breadcrumbs, breadcrumbs,
left,
right, right,
links, type,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { user, isUserLoading } = useUser(); const { activeWorkspace, activeProject, user, isUserLoading } = useUser();
useEffect(() => { useEffect(() => {
if (!isUserLoading && (!user || user === null)) router.push("/signin"); if (!isUserLoading && (!user || user === null)) router.push("/signin");
}, [isUserLoading, user, router]); }, [isUserLoading, user, router]);
const workspaceLinks: {
label: string;
href: string;
}[] = [
{
label: "General",
href: "#",
},
{
label: "Control",
href: "#",
},
{
label: "States",
href: "#",
},
{
label: "Labels",
href: "#",
},
];
const sidebarLinks: {
label: string;
href: string;
}[] = [
{
label: "General",
href: `/projects/${activeProject?.id}/settings`,
},
{
label: "Control",
href: `/projects/${activeProject?.id}/settings/control`,
},
{
label: "Members",
href: `/projects/${activeProject?.id}/settings/members`,
},
{
label: "States",
href: `/projects/${activeProject?.id}/settings/states`,
},
{
label: "Labels",
href: `/projects/${activeProject?.id}/settings/labels`,
},
];
return ( return (
<Container meta={meta}> <Container meta={meta}>
<CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} /> <CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} />
<div className="h-screen w-full flex overflow-x-hidden"> <div className="h-screen w-full flex overflow-x-hidden">
<Sidebar /> <Sidebar collapse />
<SettingsSidebar links={links} /> <SettingsSidebar links={type === "workspace" ? workspaceLinks : sidebarLinks} />
<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 px-16"} ${
bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary" bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary"
}`} }`}
> >

View File

@ -1,4 +1,3 @@
// hooks
import useTheme from "./useTheme"; import useTheme from "./useTheme";
import useUser from "./useUser"; import useUser from "./useUser";
// commons // commons
@ -11,13 +10,16 @@ import type { IIssue } from "types";
const useIssuesFilter = (projectIssues: IIssue[]) => { const useIssuesFilter = (projectIssues: IIssue[]) => {
const { const {
issueView, issueView,
setIssueView,
groupByProperty, groupByProperty,
setGroupByProperty, setGroupByProperty,
orderBy, orderBy,
setOrderBy, setOrderBy,
filterIssue, filterIssue,
setFilterIssue, setFilterIssue,
resetFilterToDefault,
setNewFilterDefaultView,
setIssueViewToKanban,
setIssueViewToList,
} = useTheme(); } = useTheme();
const { states } = useUser(); const { states } = useUser();
@ -89,13 +91,16 @@ const useIssuesFilter = (projectIssues: IIssue[]) => {
return { return {
groupedByIssues, groupedByIssues,
issueView, issueView,
setIssueView,
groupByProperty, groupByProperty,
setGroupByProperty, setGroupByProperty,
orderBy, orderBy,
setOrderBy, setOrderBy,
filterIssue, filterIssue,
setFilterIssue, setFilterIssue,
resetFilterToDefault,
setNewFilterDefaultView,
setIssueViewToKanban,
setIssueViewToList,
} as const; } as const;
}; };

View File

@ -16,8 +16,9 @@ const initialValues: Properties = {
assignee: true, assignee: true,
priority: false, priority: false,
start_date: false, start_date: false,
target_date: false, due_date: false,
cycle: false, cycle: false,
children_count: false,
}; };
const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => { const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
@ -25,7 +26,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
const { user } = useUser(); const { user } = useUser();
const { data: issueProperties } = useSWR<IssuePriorities>( const { data: issueProperties, mutate: mutateIssueProperties } = useSWR<IssuePriorities>(
workspaceSlug && projectId ? ISSUE_PROPERTIES_ENDPOINT(workspaceSlug, projectId) : null, workspaceSlug && projectId ? ISSUE_PROPERTIES_ENDPOINT(workspaceSlug, projectId) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => issueServices.getIssueProperties(workspaceSlug, projectId) ? () => issueServices.getIssueProperties(workspaceSlug, projectId)
@ -55,6 +56,14 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
(key: keyof Properties) => { (key: keyof Properties) => {
if (!workspaceSlug || !projectId || !issueProperties || !user) return; if (!workspaceSlug || !projectId || !issueProperties || !user) return;
setProperties((prev) => ({ ...prev, [key]: !prev[key] })); setProperties((prev) => ({ ...prev, [key]: !prev[key] }));
mutateIssueProperties(
(prev) =>
({
...prev,
properties: { ...prev?.properties, [key]: !prev?.properties?.[key] },
} as IssuePriorities),
false
);
if (Object.keys(issueProperties).length > 0) { if (Object.keys(issueProperties).length > 0) {
issueServices.patchIssueProperties(workspaceSlug, projectId, issueProperties.id, { issueServices.patchIssueProperties(workspaceSlug, projectId, issueProperties.id, {
properties: { properties: {
@ -70,23 +79,19 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
}); });
} }
}, },
[workspaceSlug, projectId, issueProperties, user] [workspaceSlug, projectId, issueProperties, user, mutateIssueProperties]
); );
const newProperties = Object.keys(properties).reduce((obj: any, key) => { const newProperties: Properties = {
if ( key: properties.key,
key !== "children" && state: properties.state,
key !== "name" && assignee: properties.assignee,
key !== "parent" && priority: properties.priority,
key !== "project" && start_date: properties.start_date,
key !== "description" && due_date: properties.due_date,
key !== "attachments" && cycle: properties.cycle,
key !== "sequence_id" children_count: properties.children_count,
) { };
obj[key] = properties[key as keyof Properties];
}
return obj;
}, {});
return [newProperties, updateIssueProperties] as const; return [newProperties, updateIssueProperties] as const;
}; };

View File

@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
// hooks
import useUser from "./useUser";
// types
import { Properties, NestedKeyOf, IIssue } from "types";
// services
import userService from "lib/services/user.service";
// common
import { groupBy } from "constants/common";
import { PRIORITIES } from "constants/";
const initialValues: Properties = {
key: true,
state: true,
assignee: true,
priority: false,
start_date: false,
due_date: false,
cycle: false,
children_count: false,
};
const useMyIssuesProperties = (issues?: IIssue[]) => {
const [properties, setProperties] = useState<Properties>(initialValues);
const [groupByProperty, setGroupByProperty] = useState<NestedKeyOf<IIssue> | null>(null);
const { states, user } = useUser();
useEffect(() => {
if (!user) return;
setProperties({ ...initialValues, ...user.my_issues_prop?.properties });
setGroupByProperty(user.my_issues_prop?.groupBy ?? null);
}, [user]);
let groupedByIssues: {
[key: string]: IIssue[];
} = {
...(groupByProperty === "state_detail.name"
? Object.fromEntries(
states
?.sort((a, b) => a.sequence - b.sequence)
?.map((state) => [
state.name,
issues?.filter((issue) => issue.state === state.name) ?? [],
]) ?? []
)
: groupByProperty === "priority"
? Object.fromEntries(
PRIORITIES.map((priority) => [
priority,
issues?.filter((issue) => issue.priority === priority) ?? [],
])
)
: {}),
...groupBy(issues ?? [], groupByProperty ?? ""),
};
const setMyIssueProperty = (key: keyof Properties) => {
if (!user) return;
userService.updateUser({ my_issues_prop: { properties, groupBy: groupByProperty } });
setProperties((prevData) => ({
...prevData,
[key]: !prevData[key],
}));
localStorage.setItem(
"my_issues_prop",
JSON.stringify({
properties: {
...properties,
[key]: !properties[key],
},
groupBy: groupByProperty,
})
);
};
const setMyIssueGroupByProperty = (groupByProperty: NestedKeyOf<IIssue> | null) => {
if (!user) return;
userService.updateUser({ my_issues_prop: { properties, groupBy: groupByProperty } });
setGroupByProperty(groupByProperty);
localStorage.setItem(
"my_issues_prop",
JSON.stringify({ properties, groupBy: groupByProperty })
);
};
useEffect(() => {
const viewProps = localStorage.getItem("my_issues_prop");
if (viewProps) {
const { properties, groupBy } = JSON.parse(viewProps);
setProperties(properties);
setGroupByProperty(groupBy);
}
}, []);
return {
filteredIssues: groupedByIssues,
groupByProperty,
properties,
setMyIssueProperty,
setMyIssueGroupByProperty,
} as const;
};
export default useMyIssuesProperties;

View File

@ -0,0 +1,19 @@
import React, { useEffect } from "react";
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
};
export default useOutsideClickDetector;

View File

@ -9,7 +9,6 @@ import {
CYCLE_DETAIL, CYCLE_DETAIL,
ISSUE_LABELS, ISSUE_LABELS,
BULK_DELETE_ISSUES, BULK_DELETE_ISSUES,
BULK_ADD_ISSUES_TO_CYCLE,
REMOVE_ISSUE_FROM_CYCLE, REMOVE_ISSUE_FROM_CYCLE,
ISSUE_LABEL_DETAILS, ISSUE_LABEL_DETAILS,
} from "constants/api-routes"; } from "constants/api-routes";
@ -93,7 +92,7 @@ class ProjectIssuesServices extends APIService {
projectId: string, projectId: string,
cycleId: string, cycleId: string,
data: { data: {
issue: string; issues: string[];
} }
) { ) {
return this.post(CYCLE_DETAIL(workspaceSlug, projectId, cycleId), data) return this.post(CYCLE_DETAIL(workspaceSlug, projectId, cycleId), data)
@ -289,21 +288,6 @@ class ProjectIssuesServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async bulkAddIssuesToCycle(
workspaceSlug: string,
projectId: string,
cycleId: string,
data: any
): Promise<any> {
return this.post(BULK_ADD_ISSUES_TO_CYCLE(workspaceSlug, projectId, cycleId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
} }
export default new ProjectIssuesServices(); export default new ProjectIssuesServices();

View File

@ -11,6 +11,7 @@ import {
PROJECT_MEMBER_DETAIL, PROJECT_MEMBER_DETAIL,
USER_PROJECT_INVITATIONS, USER_PROJECT_INVITATIONS,
PROJECT_VIEW_ENDPOINT, PROJECT_VIEW_ENDPOINT,
PROJECT_MEMBER_ME,
} from "constants/api-routes"; } from "constants/api-routes";
// services // services
import APIService from "lib/services/api.service"; import APIService from "lib/services/api.service";
@ -132,6 +133,30 @@ class ProjectServices extends APIService {
}); });
} }
async projectMemberMe(workspacSlug: string, projectId: string): Promise<IProjectMember> {
return this.get(PROJECT_MEMBER_ME(workspacSlug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getProjectMember(
workspacSlug: string,
projectId: string,
memberId: string
): Promise<IProjectMember> {
return this.get(PROJECT_MEMBER_DETAIL(workspacSlug, projectId, memberId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateProjectMember( async updateProjectMember(
workspacSlug: string, workspacSlug: string,
projectId: string, projectId: string,
@ -205,9 +230,12 @@ class ProjectServices extends APIService {
async setProjectView( async setProjectView(
workspacSlug: string, workspacSlug: string,
projectId: string, projectId: string,
data: ProjectViewTheme data: {
view_props?: ProjectViewTheme;
default_props?: ProjectViewTheme;
}
): Promise<any> { ): Promise<any> {
await this.patch(PROJECT_VIEW_ENDPOINT(workspacSlug, projectId), data) await this.post(PROJECT_VIEW_ENDPOINT(workspacSlug, projectId), data)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })

View File

@ -1,6 +1,7 @@
// services // services
import { USER_ENDPOINT, USER_ISSUES_ENDPOINT, USER_ONBOARD_ENDPOINT } from "constants/api-routes"; import { USER_ENDPOINT, USER_ISSUES_ENDPOINT, USER_ONBOARD_ENDPOINT } from "constants/api-routes";
import APIService from "lib/services/api.service"; import APIService from "lib/services/api.service";
import type { IUser } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;
@ -38,7 +39,7 @@ class UserService extends APIService {
}); });
} }
async updateUser(data = {}): Promise<any> { async updateUser(data: Partial<IUser>): Promise<any> {
return this.patch(USER_ENDPOINT, data) return this.patch(USER_ENDPOINT, data)
.then((response) => { .then((response) => {
return response?.data; return response?.data;

View File

@ -19,7 +19,12 @@ import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;
// types // types
import { IWorkspace, IWorkspaceMember, IWorkspaceMemberInvitation } from "types"; import {
IWorkspace,
IWorkspaceMember,
IWorkspaceMemberInvitation,
ILastActiveWorkspaceDetails,
} from "types";
class WorkspaceService extends APIService { class WorkspaceService extends APIService {
constructor() { constructor() {
@ -98,6 +103,16 @@ class WorkspaceService extends APIService {
}); });
} }
async getLastActiveWorkspaceAndProjects(): Promise<ILastActiveWorkspaceDetails> {
return this.get(LAST_ACTIVE_WORKSPACE_AND_PROJECTS)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async userWorkspaceInvitations(): Promise<IWorkspaceMemberInvitation[]> { async userWorkspaceInvitations(): Promise<IWorkspaceMemberInvitation[]> {
return this.get(USER_WORKSPACE_INVITATIONS) return this.get(USER_WORKSPACE_INVITATIONS)
.then((response) => { .then((response) => {

View File

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --port 3000",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"

View File

@ -90,47 +90,7 @@ const OnBoard: NextPage = () => {
<div className="w-full md:w-2/3 lg:w-1/3 p-8 rounded-lg"> <div className="w-full md:w-2/3 lg:w-1/3 p-8 rounded-lg">
{invitations && workspaces ? ( {invitations && workspaces ? (
invitations.length > 0 ? ( invitations.length > 0 ? (
<div className="mt-3 sm:mt-5"> <div>
<div className="mt-2">
<h2 className="text-2xl font-medium mb-4">Join your workspaces</h2>
<div className="space-y-2 mb-12">
{invitations.map((item) => (
<div
className="relative flex items-center border px-4 py-2 rounded"
key={item.id}
>
<div className="ml-3 text-sm flex flex-col items-start w-full">
<h3 className="font-medium text-xl text-gray-700">
{item.workspace.name}
</h3>
<p className="text-sm">invited by {item.workspace.owner.first_name}</p>
</div>
<div className="flex gap-x-2 h-5 items-center">
<div className="h-full flex items-center gap-x-1">
<input
id={`${item.id}`}
aria-describedby="workspaces"
name={`${item.id}`}
checked={invitationsRespond.includes(item.id)}
value={item.workspace.name}
onChange={() => {
handleInvitation(
item,
invitationsRespond.includes(item.id) ? "withdraw" : "accepted"
);
}}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-theme focus:ring-indigo-500"
/>
<label htmlFor={item.id} className="text-sm">
Accept
</label>
</div>
</div>
</div>
))}
</div>
</div>
<h2 className="text-lg font-medium text-gray-900">Workspace Invitations</h2> <h2 className="text-lg font-medium text-gray-900">Workspace Invitations</h2>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Select invites that you want to accept. Select invites that you want to accept.

View File

@ -1,17 +1,18 @@
// react // react
import React from "react"; import React from "react";
// next // next
import type { NextPage } from "next";
import Link from "next/link"; import Link from "next/link";
import type { NextPage } from "next";
import Image from "next/image"; import Image from "next/image";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// headless ui
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 // headless ui
import { Disclosure, Listbox, Menu, Popover, Transition } from "@headlessui/react";
// ui // ui
import { Spinner, Breadcrumbs, BreadcrumbItem, EmptySpace, EmptySpaceItem, HeaderButton } from "ui"; import { Spinner, Breadcrumbs, BreadcrumbItem, EmptySpace, EmptySpaceItem, HeaderButton } from "ui";
// icons // icons
@ -31,6 +32,7 @@ import workspaceService from "lib/services/workspace.service";
import useIssuesProperties from "lib/hooks/useIssuesProperties"; 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";
// components // components
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown"; import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
// types // types
@ -97,6 +99,9 @@ const MyIssues: NextPage = () => {
}); });
}; };
const { filteredIssues, setMyIssueGroupByProperty, setMyIssueProperty, groupByProperty } =
useMyIssuesProperties(myIssues);
return ( return (
<AppLayout <AppLayout
breadcrumbs={ breadcrumbs={
@ -393,7 +398,7 @@ const MyIssues: NextPage = () => {
</div> </div>
</div> </div>
)} )}
{properties.target_date && ( {properties.due_date && (
<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 ${ 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 === null
@ -410,7 +415,7 @@ const MyIssues: NextPage = () => {
: "N/A"} : "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"> <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"> <h5 className="font-medium mb-1 text-gray-900">
Target date Due date
</h5> </h5>
<div> <div>
{renderShortNumericDateFormat(issue.target_date ?? "")} {renderShortNumericDateFormat(issue.target_date ?? "")}
@ -418,14 +423,14 @@ const MyIssues: NextPage = () => {
<div> <div>
{issue.target_date && {issue.target_date &&
(issue.target_date < new Date().toISOString() (issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft( ? `Due date has passed by ${findHowManyDaysLeft(
issue.target_date issue.target_date
)} days` )} days`
: findHowManyDaysLeft(issue.target_date) <= 3 : findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft( ? `Due date is in ${findHowManyDaysLeft(
issue.target_date issue.target_date
)} days` )} days`
: "Target date")} : "Due date")}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,6 @@
// react // react
import React, { useState } 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";
@ -12,6 +11,11 @@ import AppLayout from "layouts/app-layout";
// components // components
import CyclesListView from "components/project/cycles/list-view"; import CyclesListView from "components/project/cycles/list-view";
import CyclesBoardView from "components/project/cycles/board-view"; import CyclesBoardView from "components/project/cycles/board-view";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CycleIssuesListModal from "components/project/cycles/cycle-issues-list-modal";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
// 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";
@ -28,60 +32,21 @@ import { BreadcrumbItem, Breadcrumbs, CustomMenu } from "ui";
import { Squares2X2Icon } from "@heroicons/react/20/solid"; import { Squares2X2Icon } from "@heroicons/react/20/solid";
import { ArrowPathIcon, ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline"; import { ArrowPathIcon, ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline";
// types // types
import { import { CycleIssueResponse, IIssue, NestedKeyOf, Properties, SelectIssue } from "types";
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 // common
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common"; import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
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 SingleCycle: React.FC = () => {
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
{ name: "Created By", key: "created_by" },
{ name: "None", key: null },
];
const orderByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
{ name: "Last created", key: "created_at" },
{ name: "Last updated", key: "updated_at" },
{ name: "Priority", key: "priority" },
];
const filterIssueOptions: Array<{
name: string;
key: "activeIssue" | "backlogIssue" | null;
}> = [
{
name: "All",
key: null,
},
{
name: "Active Issues",
key: "activeIssue",
},
{
name: "Backlog Issues",
key: "backlogIssue",
},
];
type Props = {};
const SingleCycle: React.FC<Props> = () => {
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false); const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [selectedCycle, setSelectedCycle] = useState<SelectSprintType>();
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>(); const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
const { activeWorkspace, activeProject, cycles, issues } = useUser(); const { activeWorkspace, activeProject, cycles, issues } = useUser();
@ -118,9 +83,20 @@ const SingleCycle: React.FC<Props> = () => {
} }
); );
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!activeWorkspace || !activeProject) return;
issuesServices
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, formData)
.then((response) => {
mutate(CYCLE_ISSUES(cycleId as string));
})
.catch((error) => {
console.log(error);
});
};
const { const {
issueView, issueView,
setIssueView,
groupByProperty, groupByProperty,
setGroupByProperty, setGroupByProperty,
groupedByIssues, groupedByIssues,
@ -128,43 +104,22 @@ const SingleCycle: React.FC<Props> = () => {
setFilterIssue, setFilterIssue,
orderBy, orderBy,
filterIssue, filterIssue,
setIssueViewToKanban,
setIssueViewToList,
} = useIssuesFilter(cycleIssuesArray ?? []); } = useIssuesFilter(cycleIssuesArray ?? []);
const openCreateIssueModal = ( const openCreateIssueModal = (
issue?: IIssue, issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create" 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 }); if (issue) setSelectedIssues({ ...issue, actionType });
setIsIssueModalOpen(true); setIsIssueModalOpen(true);
}
}; };
const openIssuesListModal = () => { const openIssuesListModal = () => {
setCycleIssuesListModal(true); setCycleIssuesListModal(true);
}; };
const addIssueToCycle = (cycleId: string, issueId: string) => {
if (!activeWorkspace || !activeProject?.id) return;
issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
issue: issueId,
})
.then((response) => {
console.log(response);
mutate(CYCLE_ISSUES(cycleId));
})
.catch((error) => {
console.log(error);
});
};
const handleDragEnd = (result: DropResult) => { const handleDragEnd = (result: DropResult) => {
if (!result.destination) return; if (!result.destination) return;
const { source, destination } = result; const { source, destination } = result;
@ -192,7 +147,7 @@ const SingleCycle: React.FC<Props> = () => {
.then((res) => { .then((res) => {
issuesServices issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, destination.droppableId, { .addIssueToCycle(activeWorkspace.slug, activeProject.id, destination.droppableId, {
issue: result.draggableId.split(",")[1], issues: [result.draggableId.split(",")[1]],
}) })
.then((res) => { .then((res) => {
console.log(res); console.log(res);
@ -230,13 +185,9 @@ const SingleCycle: React.FC<Props> = () => {
return ( return (
<> <>
<CreateUpdateIssuesModal <CreateUpdateIssuesModal
isOpen={ isOpen={isIssueModalOpen && selectedIssues?.actionType !== "delete"}
isIssueModalOpen &&
selectedCycle?.actionType === "create-issue" &&
selectedIssues?.actionType !== "delete"
}
data={selectedIssues} data={selectedIssues}
prePopulateData={{ sprints: selectedCycle?.id }} prePopulateData={{ sprints: cycleId as string, ...preloadedData }}
setIsOpen={setIsIssueModalOpen} setIsOpen={setIsIssueModalOpen}
projectId={activeProject?.id} projectId={activeProject?.id}
/> />
@ -246,6 +197,11 @@ const SingleCycle: React.FC<Props> = () => {
issues={issues} issues={issues}
cycleId={cycleId as string} cycleId={cycleId as string}
/> />
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={issues?.results.find((issue) => issue.id === deleteIssue)}
/>
<AppLayout <AppLayout
breadcrumbs={ breadcrumbs={
<Breadcrumbs> <Breadcrumbs>
@ -264,6 +220,7 @@ const SingleCycle: React.FC<Props> = () => {
</> </>
} }
className="ml-1.5" className="ml-1.5"
width="auto"
> >
{cycles?.map((cycle) => ( {cycles?.map((cycle) => (
<CustomMenu.MenuItem <CustomMenu.MenuItem
@ -284,10 +241,7 @@ const SingleCycle: React.FC<Props> = () => {
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${ 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" : "" issueView === "list" ? "bg-gray-200" : ""
}`} }`}
onClick={() => { onClick={() => setIssueViewToList()}
setIssueView("list");
setGroupByProperty(null);
}}
> >
<ListBulletIcon className="h-4 w-4" /> <ListBulletIcon className="h-4 w-4" />
</button> </button>
@ -296,10 +250,7 @@ const SingleCycle: React.FC<Props> = () => {
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${ 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" : "" issueView === "kanban" ? "bg-gray-200" : ""
}`} }`}
onClick={() => { onClick={() => setIssueViewToKanban()}
setIssueView("kanban");
setGroupByProperty("state_detail.name");
}}
> >
<Squares2X2Icon className="h-4 w-4" /> <Squares2X2Icon className="h-4 w-4" />
</button> </button>
@ -326,7 +277,7 @@ const SingleCycle: React.FC<Props> = () => {
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 right-0 z-10 mt-1 w-screen max-w-xs transform p-3 bg-white rounded-lg shadow-lg overflow-hidden"> <Popover.Panel className="absolute right-0 z-20 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>
@ -424,6 +375,7 @@ const SingleCycle: React.FC<Props> = () => {
openCreateIssueModal={openCreateIssueModal} openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
removeIssueFromCycle={removeIssueFromCycle} removeIssueFromCycle={removeIssueFromCycle}
setPreloadedData={setPreloadedData}
/> />
) : ( ) : (
<div className="h-screen"> <div className="h-screen">
@ -435,6 +387,9 @@ const SingleCycle: React.FC<Props> = () => {
members={members} members={members}
openCreateIssueModal={openCreateIssueModal} openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData}
/> />
</div> </div>
)} )}

View File

@ -106,7 +106,13 @@ const ProjectSprints: NextPage = () => {
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}
action={() => setCreateUpdateCycleModal(true)} action={() => {
const e = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "q",
});
document.dispatchEvent(e);
}}
/> />
</EmptySpace> </EmptySpace>
</div> </div>

View File

@ -1,24 +1,21 @@
import React, { useCallback, useEffect, useState } from "react";
// next // next
import Link from "next/link"; import Link from "next/link";
import dynamic from "next/dynamic";
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";
// react
import React, { useCallback, useEffect, useState } from "react";
// swr // swr
import useSWR, { mutate } from "swr"; import { mutate } from "swr";
// react hook form // react hook form
import { useForm } 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";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// services // services
import issuesServices from "lib/services/issues.service"; import issuesServices from "lib/services/issues.service";
// fetch keys // common
import { import { debounce } from "constants/common";
PROJECT_ISSUES_ACTIVITY,
PROJECT_ISSUES_COMMENTS,
PROJECT_ISSUES_LIST,
} from "constants/fetch-keys";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// hoc // hoc
@ -26,22 +23,23 @@ import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// components // components
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection";
import AddAsSubIssue from "components/project/issues/issue-detail/add-as-sub-issue"; import AddAsSubIssue from "components/project/issues/issue-detail/add-as-sub-issue";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
// common
import { debounce } from "constants/common";
// components
import IssueDetailSidebar from "components/project/issues/issue-detail/issue-detail-sidebar"; import IssueDetailSidebar from "components/project/issues/issue-detail/issue-detail-sidebar";
// activites import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection";
import IssueActivitySection from "components/project/issues/issue-detail/activity"; const IssueActivitySection = dynamic(
() => import("components/project/issues/issue-detail/activity"),
{
loading: () => (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
),
ssr: false,
}
);
// ui // ui
import { Spinner, TextArea } from "ui"; import { Spinner, TextArea, HeaderButton, Breadcrumbs } from "ui";
import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types
import { IIssue, IIssueComment, IssueResponse } from "types";
// icons // icons
import { import {
ChevronLeftIcon, ChevronLeftIcon,
@ -49,31 +47,52 @@ import {
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
PlusIcon, PlusIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
const RichTextEditor = dynamic(() => import("components/lexical/editor"), { const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false, ssr: false,
}); });
const defaultValues = {
name: "",
description: "",
state: "",
assignees_list: [],
priority: "low",
blockers_list: [],
blocked_list: [],
target_date: new Date().toString(),
issue_cycle: null,
labels_list: [],
};
const IssueDetail: NextPage = () => { const IssueDetail: NextPage = () => {
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const router = useRouter();
const { issueId, projectId } = router.query;
const { activeWorkspace, activeProject, issues, mutateIssues } = useUser();
const issueDetail = issues?.results?.find((issue) => issue.id === issueId);
const prevIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) - 1];
const nextIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) + 1];
const subIssues = (issues && issues.results.filter((i) => i.parent === issueId)) ?? [];
const siblingIssues =
issueDetail &&
issues?.results.filter((i) => i.parent === issueDetail.parent && i.id !== issueId);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false); const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false);
const [issueDetail, setIssueDetail] = useState<IIssue | undefined>(undefined);
const [preloadedData, setPreloadedData] = useState< const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined (Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined); >(undefined);
const [issueDescriptionValue, setIssueDescriptionValue] = useState(""); const [issueDescriptionValue, setIssueDescriptionValue] = useState("");
const router = useRouter();
const { issueId, projectId } = router.query;
const { activeWorkspace, activeProject, issues, mutateIssues, states } = useUser();
const handleDescriptionChange: any = (value: any) => { const handleDescriptionChange: any = (value: any) => {
console.log(value); console.log(value);
setIssueDescriptionValue(value); setIssueDescriptionValue(value);
@ -87,44 +106,9 @@ const IssueDetail: NextPage = () => {
control, control,
watch, watch,
} = useForm<IIssue>({ } = useForm<IIssue>({
defaultValues: { defaultValues,
name: "",
description: "",
state: "",
assignees_list: [],
priority: "low",
blockers_list: [],
blocked_list: [],
target_date: new Date().toString(),
cycle: "",
labels_list: [],
},
}); });
const { data: issueActivities } = useSWR<any[]>(
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null,
activeWorkspace && projectId && issueId
? () =>
issuesServices.getIssueActivities(
activeWorkspace.slug,
projectId as string,
issueId as string
)
: null
);
const { data: issueComments } = useSWR<IIssueComment[]>(
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS : null,
activeWorkspace && projectId && issueId
? () =>
issuesServices.getIssueComments(
activeWorkspace.slug,
projectId as string,
issueId as string
)
: null
);
const submitChanges = useCallback( const submitChanges = useCallback(
(formData: Partial<IIssue>) => { (formData: Partial<IIssue>) => {
if (!activeWorkspace || !activeProject || !issueId) return; if (!activeWorkspace || !activeProject || !issueId) return;
@ -150,7 +134,15 @@ const IssueDetail: NextPage = () => {
issuesServices issuesServices
.patchIssue(activeWorkspace.slug, projectId as string, issueId as string, payload) .patchIssue(activeWorkspace.slug, projectId as string, issueId as string, payload)
.then((response) => { .then((response) => {
console.log(response); mutateIssues((prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((issue) => {
if (issue.id === issueId) {
return { ...issue, ...response };
}
return issue;
}),
}));
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@ -172,25 +164,11 @@ const IssueDetail: NextPage = () => {
issueDetail.blocked_issues?.map((issue) => issue.blocked_issue_detail?.id), issueDetail.blocked_issues?.map((issue) => issue.blocked_issue_detail?.id),
assignees_list: assignees_list:
issueDetail.assignees_list ?? issueDetail.assignee_details?.map((user) => user.id), issueDetail.assignees_list ?? issueDetail.assignee_details?.map((user) => user.id),
labels_list: issueDetail.labels_list ?? issueDetail.labels?.map((label) => label), labels_list: issueDetail.labels_list ?? issueDetail.labels,
labels: issueDetail.labels_list ?? issueDetail.labels,
}); });
}, [issueDetail, reset]); }, [issueDetail, reset]);
useEffect(() => {
const issueIndex = issues?.results.findIndex((issue) => issue.id === issueId);
if (issueIndex === undefined) return;
const issueDetail = issues?.results[issueIndex];
setIssueDetail(issueDetail);
}, [issues, issueId]);
const prevIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) - 1];
const nextIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) + 1];
const subIssues = (issues && issues.results.filter((i) => i.parent === issueDetail?.id)) ?? [];
const siblingIssues =
issueDetail &&
issues?.results.filter((i) => i.parent === issueDetail.parent && i.id !== issueDetail.id);
const handleSubIssueRemove = (issueId: string) => { const handleSubIssueRemove = (issueId: string) => {
if (activeWorkspace && activeProject) { if (activeWorkspace && activeProject) {
issuesServices issuesServices
@ -213,19 +191,17 @@ const IssueDetail: NextPage = () => {
} }
}; };
// console.log(issueDetail);
return ( return (
<AppLayout <AppLayout
noPadding={true} noPadding={true}
bg="secondary" bg="secondary"
breadcrumbs={ breadcrumbs={
<Breadcrumbs> <Breadcrumbs>
<BreadcrumbItem <Breadcrumbs.BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Issues`} title={`${activeProject?.name ?? "Project"} Issues`}
link={`/projects/${activeProject?.id}/issues`} link={`/projects/${activeProject?.id}/issues`}
/> />
<BreadcrumbItem <Breadcrumbs.BreadcrumbItem
title={`Issue ${activeProject?.identifier ?? "Project"}-${ title={`Issue ${activeProject?.identifier ?? "Project"}-${
issueDetail?.sequence_id ?? "..." issueDetail?.sequence_id ?? "..."
} Details`} } Details`}
@ -237,7 +213,7 @@ const IssueDetail: NextPage = () => {
<HeaderButton <HeaderButton
Icon={ChevronLeftIcon} Icon={ChevronLeftIcon}
label="Previous" label="Previous"
className={`${!prevIssue ? "cursor-not-allowed opacity-70" : ""}`} className={!prevIssue ? "cursor-not-allowed opacity-70" : ""}
onClick={() => { onClick={() => {
if (!prevIssue) return; if (!prevIssue) return;
router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`); router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`);
@ -247,7 +223,7 @@ const IssueDetail: NextPage = () => {
Icon={ChevronRightIcon} Icon={ChevronRightIcon}
disabled={!nextIssue} disabled={!nextIssue}
label="Next" label="Next"
className={`${!nextIssue ? "cursor-not-allowed opacity-70" : ""}`} className={!nextIssue ? "cursor-not-allowed opacity-70" : ""}
onClick={() => { onClick={() => {
if (!nextIssue) return; if (!nextIssue) return;
router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`); router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`);
@ -257,6 +233,7 @@ const IssueDetail: NextPage = () => {
</div> </div>
} }
> >
{isOpen && (
<CreateUpdateIssuesModal <CreateUpdateIssuesModal
isOpen={isOpen} isOpen={isOpen}
setIsOpen={setIsOpen} setIsOpen={setIsOpen}
@ -265,19 +242,17 @@ const IssueDetail: NextPage = () => {
...preloadedData, ...preloadedData,
}} }}
/> />
<ConfirmIssueDeletion )}
handleClose={() => setDeleteIssueModal(false)} {isAddAsSubIssueOpen && (
isOpen={deleteIssueModal}
data={issueDetail}
/>
<AddAsSubIssue <AddAsSubIssue
isOpen={isAddAsSubIssueOpen} isOpen={isAddAsSubIssueOpen}
setIsOpen={setIsAddAsSubIssueOpen} setIsOpen={setIsAddAsSubIssueOpen}
parent={issueDetail} parent={issueDetail}
/> />
)}
{issueDetail && activeProject ? ( {issueDetail && activeProject ? (
<div className="flex gap-5"> <div className="h-full flex gap-5">
<div className="basis-3/4 space-y-5 p-5"> <div className="basis-2/3 space-y-5 p-5">
<div className="mb-5"></div> <div className="mb-5"></div>
<div className="rounded-lg"> <div className="rounded-lg">
{issueDetail.parent !== null && issueDetail.parent !== "" ? ( {issueDetail.parent !== null && issueDetail.parent !== "" ? (
@ -285,7 +260,7 @@ const IssueDetail: NextPage = () => {
<Link href={`/projects/${activeProject.id}/issues/${issueDetail.parent}`}> <Link href={`/projects/${activeProject.id}/issues/${issueDetail.parent}`}>
<a className="flex items-center gap-2"> <a className="flex items-center gap-2">
<span <span
className={`h-1.5 w-1.5 block rounded-full`} className="h-1.5 w-1.5 block rounded-full"
style={{ style={{
backgroundColor: issueDetail.state_detail.color, backgroundColor: issueDetail.state_detail.color,
}} }}
@ -433,7 +408,6 @@ const IssueDetail: NextPage = () => {
<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-50"> <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-50">
<div className="p-1"> <div className="p-1">
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => (
<button <button
type="button" type="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" 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"
@ -441,7 +415,6 @@ const IssueDetail: NextPage = () => {
> >
Add an existing issue Add an existing issue
</button> </button>
)}
</Menu.Item> </Menu.Item>
</div> </div>
</Menu.Items> </Menu.Items>
@ -498,14 +471,12 @@ const IssueDetail: NextPage = () => {
<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-50"> <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-50">
<div className="p-1"> <div className="p-1">
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => (
<button <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" 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={() => handleSubIssueRemove(subIssue.id)} onClick={() => handleSubIssueRemove(subIssue.id)}
> >
Remove as sub-issue Remove as sub-issue
</button> </button>
)}
</Menu.Item> </Menu.Item>
</div> </div>
</Menu.Items> </Menu.Items>
@ -535,13 +506,12 @@ const IssueDetail: NextPage = () => {
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 origin-top-right left-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10"> <Menu.Items className="absolute origin-top-right left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="p-1"> <div className="py-1">
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => (
<button <button
type="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" className="text-left p-2 text-gray-900 hover:bg-indigo-50 text-xs whitespace-nowrap w-full"
onClick={() => { onClick={() => {
setIsOpen(true); setIsOpen(true);
setPreloadedData({ setPreloadedData({
@ -552,13 +522,11 @@ const IssueDetail: NextPage = () => {
> >
Create new Create new
</button> </button>
)}
</Menu.Item> </Menu.Item>
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => (
<button <button
type="button" type="button"
className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap" className="p-2 text-left text-gray-900 hover:bg-indigo-50 text-xs whitespace-nowrap"
onClick={() => { onClick={() => {
setIsAddAsSubIssueOpen(true); setIsAddAsSubIssueOpen(true);
setPreloadedData({ setPreloadedData({
@ -569,7 +537,6 @@ const IssueDetail: NextPage = () => {
> >
Add an existing issue Add an existing issue
</button> </button>
)}
</Menu.Item> </Menu.Item>
</div> </div>
</Menu.Items> </Menu.Items>
@ -596,31 +563,22 @@ const IssueDetail: NextPage = () => {
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel> <Tab.Panel>
<IssueCommentSection <IssueCommentSection />
comments={issueComments}
workspaceSlug={activeWorkspace?.slug as string}
projectId={projectId as string}
issueId={issueId as string}
/>
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel>
<IssueActivitySection <IssueActivitySection />
issueActivities={issueActivities}
states={states}
issues={issues}
/>
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>
</div> </div>
<div className="h-full basis-1/4 space-y-5 p-5 border-l"> <div className="h-full basis-1/3 space-y-5 p-5 border-l">
{/* TODO add flex-grow, if needed */}
<IssueDetailSidebar <IssueDetailSidebar
control={control} control={control}
issueDetail={issueDetail} issueDetail={issueDetail}
submitChanges={submitChanges} submitChanges={submitChanges}
watch={watch} watch={watch}
setDeleteIssueModal={setDeleteIssueModal}
/> />
</div> </div>
</div> </div>

View File

@ -17,6 +17,8 @@ import useIssuesProperties from "lib/hooks/useIssuesProperties";
import { PROJECT_MEMBERS } from "constants/api-routes"; import { PROJECT_MEMBERS } from "constants/api-routes";
// services // services
import projectService from "lib/services/project.service"; import projectService from "lib/services/project.service";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
// commons // commons
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common"; import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// layouts // layouts
@ -24,10 +26,10 @@ import AppLayout from "layouts/app-layout";
// hooks // hooks
import useIssuesFilter from "lib/hooks/useIssuesFilter"; import useIssuesFilter from "lib/hooks/useIssuesFilter";
// components // components
import ListView from "components/project/issues/ListView"; import ListView from "components/project/issues/list-view";
import BoardView from "components/project/issues/BoardView"; import BoardView from "components/project/issues/BoardView";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
// ui // ui
import { import {
Spinner, Spinner,
@ -42,41 +44,10 @@ import {
import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import { PlusIcon, Squares2X2Icon } from "@heroicons/react/20/solid"; import { PlusIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
// types // types
import type { IIssue, Properties, NestedKeyOf, IssueResponse } from "types"; import type { IIssue, Properties, IssueResponse } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
{ name: "Created By", key: "created_by" },
{ name: "None", key: null },
];
const orderByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
{ name: "Last created", key: "created_at" },
{ name: "Last updated", key: "updated_at" },
{ name: "Priority", key: "priority" },
];
const filterIssueOptions: Array<{
name: string;
key: "activeIssue" | "backlogIssue" | null;
}> = [
{
name: "All",
key: null,
},
{
name: "Active Issues",
key: "activeIssue",
},
{
name: "Backlog Issues",
key: "backlogIssue",
},
];
const ProjectIssues: NextPage = () => { const ProjectIssues: NextPage = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -133,7 +104,6 @@ const ProjectIssues: NextPage = () => {
const { const {
issueView, issueView,
setIssueView,
groupByProperty, groupByProperty,
setGroupByProperty, setGroupByProperty,
groupedByIssues, groupedByIssues,
@ -141,7 +111,11 @@ const ProjectIssues: NextPage = () => {
setFilterIssue, setFilterIssue,
orderBy, orderBy,
filterIssue, filterIssue,
} = useIssuesFilter(projectIssues?.results ?? []); resetFilterToDefault,
setNewFilterDefaultView,
setIssueViewToKanban,
setIssueViewToList,
} = useIssuesFilter(projectIssues?.results.filter((p) => p.parent === null) ?? []);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@ -168,10 +142,7 @@ const ProjectIssues: NextPage = () => {
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${ 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" : "" issueView === "list" ? "bg-gray-200" : ""
}`} }`}
onClick={() => { onClick={() => setIssueViewToList()}
setIssueView("list");
setGroupByProperty(null);
}}
> >
<ListBulletIcon className="h-4 w-4" /> <ListBulletIcon className="h-4 w-4" />
</button> </button>
@ -180,10 +151,7 @@ const ProjectIssues: NextPage = () => {
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${ 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" : "" issueView === "kanban" ? "bg-gray-200" : ""
}`} }`}
onClick={() => { onClick={() => setIssueViewToKanban()}
setIssueView("kanban");
setGroupByProperty("state_detail.name");
}}
> >
<Squares2X2Icon className="h-4 w-4" /> <Squares2X2Icon className="h-4 w-4" />
</button> </button>
@ -210,7 +178,7 @@ const ProjectIssues: 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 overflow-hidden"> <Popover.Panel className="absolute mr-5 right-1/2 z-20 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"> <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>
@ -291,6 +259,23 @@ const ProjectIssues: NextPage = () => {
))} ))}
</div> </div>
</div> </div>
<div className="border-b-2"></div>
<div className="relative flex justify-end gap-x-3">
<button
type="button"
className="text-xs"
onClick={() => resetFilterToDefault()}
>
Reset to default
</button>
<button
type="button"
className="text-xs font-medium text-theme"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div>
</div> </div>
</Popover.Panel> </Popover.Panel>
</Transition> </Transition>

View File

@ -5,28 +5,26 @@ import type { NextPage } from "next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// headless ui
import { Menu } from "@headlessui/react";
// services // services
import projectService from "lib/services/project.service"; import projectService from "lib/services/project.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// fetching keys
import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys";
// hoc // hoc
import withAuth from "lib/hoc/withAuthWrapper"; import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// components // components
import SendProjectInvitationModal from "components/project/SendProjectInvitationModal"; import ConfirmProjectMemberRemove from "components/project/confirm-project-member-remove";
import ConfirmProjectMemberRemove from "components/project/ConfirmProjectMemberRemove"; import SendProjectInvitationModal from "components/project/send-project-invitation-modal";
// headless ui
import { Menu } from "@headlessui/react";
// ui // ui
import { Spinner, CustomListbox } from "ui"; import { Spinner, CustomListbox, BreadcrumbItem, Breadcrumbs, HeaderButton } from "ui";
// icons // icons
import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
import HeaderButton from "ui/HeaderButton"; // fetch-keys
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs"; import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys";
const ROLE = { const ROLE = {
5: "Guest", 5: "Guest",
@ -63,6 +61,7 @@ const ProjectMembers: NextPage = () => {
}, },
} }
); );
const { data: projectInvitations, mutate: mutateInvitations } = useSWR( const { data: projectInvitations, mutate: mutateInvitations } = useSWR(
activeWorkspace && projectId ? PROJECT_INVITATIONS : null, activeWorkspace && projectId ? PROJECT_INVITATIONS : null,
activeWorkspace && projectId activeWorkspace && projectId

View File

@ -80,13 +80,17 @@ const ProjectModules: NextPage = () => {
title="Create a new module" title="Create a new module"
description={ description={
<span> <span>
Use <pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + Q</pre>{" "} Use <pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + M</pre>{" "}
shortcut to create a new cycle shortcut to create a new cycle
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}
action={() => { action={() => {
return; const e = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "m",
});
document.dispatchEvent(e);
}} }}
/> />
</EmptySpace> </EmptySpace>

View File

@ -1,217 +0,0 @@
import React, { useEffect, useState } from "react";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// swr
import useSWR, { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import SettingsLayout from "layouts/settings-layout";
import AppLayout from "layouts/app-layout";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// fetch keys
import { PROJECT_DETAILS, PROJECTS_LIST } from "constants/fetch-keys";
// ui
import { Spinner } from "ui";
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
// types
import type { IProject, IWorkspace } from "types";
const GeneralSettings = dynamic(() => import("components/project/settings/GeneralSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const ControlSettings = dynamic(() => import("components/project/settings/ControlSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const StatesSettings = dynamic(() => import("components/project/settings/StatesSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const LabelsSettings = dynamic(() => import("components/project/settings/LabelsSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const defaultValues: Partial<IProject> = {
name: "",
description: "",
identifier: "",
network: 0,
};
const ProjectSettings: NextPage = () => {
const {
register,
handleSubmit,
reset,
control,
setError,
formState: { errors, isSubmitting },
} = useForm<IProject>({
defaultValues,
});
const router = useRouter();
const { projectId } = router.query;
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR<IProject>(
activeWorkspace && projectId ? PROJECT_DETAILS(projectId as string) : null,
activeWorkspace
? () => projectServices.getProject(activeWorkspace.slug, projectId as string)
: null
);
useEffect(() => {
projectDetails &&
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id,
project_lead: projectDetails.project_lead?.id,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
const onSubmit = async (formData: IProject) => {
if (!activeWorkspace || !projectId) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
default_assignee: formData.default_assignee,
project_lead: formData.project_lead,
};
await projectServices
.updateProject(activeWorkspace.slug, projectId as string, payload)
.then((res) => {
mutate<IProject>(
PROJECT_DETAILS(projectId as string),
(prevData) => ({ ...prevData, ...res }),
false
);
mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
const sidebarLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: "#",
},
{
label: "Control",
href: "#",
},
{
label: "States",
href: "#",
},
{
label: "Labels",
href: "#",
},
];
return (
<AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Settings`} />
</Breadcrumbs>
}
// links={sidebarLinks}
>
{projectDetails ? (
<div className="space-y-3">
<Tab.Group>
<Tab.List className="flex items-center gap-x-4 gap-y-2 flex-wrap mb-8">
{["General", "Control", "States", "Labels"].map((tab, index) => (
<Tab
key={index}
className={({ selected }) =>
`px-6 py-2 border-2 border-theme hover:bg-theme hover:text-white text-xs font-medium rounded-md outline-none duration-300 ${
selected ? "bg-theme text-white" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<form onSubmit={handleSubmit(onSubmit)}>
<Tab.Panel>
<GeneralSettings
register={register}
errors={errors}
setError={setError}
isSubmitting={isSubmitting}
/>
</Tab.Panel>
<Tab.Panel>
<ControlSettings control={control} isSubmitting={isSubmitting} />
</Tab.Panel>
</form>
<Tab.Panel>
<StatesSettings projectId={projectId} />
</Tab.Panel>
<Tab.Panel>
<LabelsSettings />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
) : (
<div className="h-full w-full flex justify-center items-center">
<Spinner />
</div>
)}
</AppLayout>
);
};
export default withAuth(ProjectSettings);

View File

@ -0,0 +1,279 @@
// react
import React, { useEffect } from "react";
// swr
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts
import SettingsLayout from "layouts/settings-layout";
// services
import projectService from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// hooks
import useToast from "lib/hooks/useToast";
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
import { BreadcrumbItem, Breadcrumbs, Button } from "ui";
// icons
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { IProject, IWorkspace } from "types";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
const ControlSettings = () => {
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR<IProject>(
activeWorkspace && activeProject ? PROJECT_DETAILS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.getProject(activeWorkspace.slug, activeProject.id)
: null
);
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<IProject>({});
const onSubmit = async (formData: IProject) => {
if (!activeWorkspace || !activeProject) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
default_assignee: formData.default_assignee,
project_lead: formData.project_lead,
icon: formData.icon,
};
await projectService
.updateProject(activeWorkspace.slug, activeProject.id, payload)
.then((res) => {
mutate<IProject>(
PROJECT_DETAILS(activeProject.id),
(prevData) => ({ ...prevData, ...res }),
false
);
mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
projectDetails &&
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id,
project_lead: projectDetails.project_lead?.id,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
return (
<SettingsLayout
type="project"
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"}`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem title="Control Settings" />
</Breadcrumbs>
}
>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-8">
<div>
<h3 className="text-3xl font-bold leading-6 text-gray-900">Control</h3>
<p className="mt-4 text-sm text-gray-500">Set the control for the project.</p>
</div>
<div className="grid grid-cols-2 gap-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Project Lead</h4>
<p className="text-sm text-gray-500 mb-3">Select the project leader.</p>
<Controller
control={control}
name="project_lead"
render={({ field: { onChange, value } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((person) => person.member.id === value)?.member
.first_name ?? "Select Lead"}
</span>
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</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-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-default select-none relative px-3 py-2`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Default Assignee</h4>
<p className="text-sm text-gray-500 mb-3">
Select the default assignee for the project.
</p>
<Controller
control={control}
name="default_assignee"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((p) => p.member.id === value)?.member.first_name ??
"Select Default Assignee"}
</span>
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</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-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-default select-none relative px-3 py-2`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
<div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</div>
</form>
</SettingsLayout>
);
};
export default ControlSettings;

View File

@ -0,0 +1,243 @@
// react
import { useCallback, useEffect } from "react";
// swr
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts
import SettingsLayout from "layouts/settings-layout";
// services
import projectService from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// ui
import { BreadcrumbItem, Breadcrumbs, Button, EmojiIconPicker, Input, Select, TextArea } from "ui";
// types
import { IProject, IWorkspace } from "types";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
// common
import { debounce } from "constants/common";
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const defaultValues: Partial<IProject> = {
name: "",
description: "",
identifier: "",
network: 0,
};
const GeneralSettings = () => {
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR<IProject>(
activeWorkspace && activeProject ? PROJECT_DETAILS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.getProject(activeWorkspace.slug, activeProject.id)
: null
);
const {
register,
handleSubmit,
reset,
control,
setError,
formState: { errors, isSubmitting },
} = useForm<IProject>({
defaultValues,
});
const onSubmit = async (formData: IProject) => {
if (!activeWorkspace || !activeProject) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
default_assignee: formData.default_assignee,
project_lead: formData.project_lead,
icon: formData.icon,
};
await projectService
.updateProject(activeWorkspace.slug, activeProject.id, payload)
.then((res) => {
mutate<IProject>(
PROJECT_DETAILS(activeProject.id),
(prevData) => ({ ...prevData, ...res }),
false
);
mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
const checkIdentifier = (slug: string, value: string) => {
projectService.checkProjectIdentifierAvailability(slug, value).then((response) => {
console.log(response);
if (response.exists) setError("identifier", { message: "Identifier already exists" });
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
useEffect(() => {
projectDetails &&
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id,
project_lead: projectDetails.project_lead?.id,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
return (
<SettingsLayout
type="project"
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"}`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem title="General Settings" />
</Breadcrumbs>
}
>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-8">
<div>
<h3 className="text-3xl font-bold leading-6 text-gray-900">General</h3>
<p className="mt-4 text-sm text-gray-500">
This information will be displayed to every member of the project.
</p>
</div>
<div className="grid grid-cols-2 gap-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Icon & Name</h4>
<p className="text-sm text-gray-500 mb-3">
Select an icon and a name for the project.
</p>
<div className="flex gap-2">
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? String.fromCodePoint(parseInt(value)) : "Icon"}
value={value}
onChange={onChange}
/>
)}
/>
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
size="lg"
className="w-auto"
validations={{
required: "Name is required",
}}
/>
</div>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Description</h4>
<p className="text-sm text-gray-500 mb-3">Give a description to the project.</p>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Identifier</h4>
<p className="text-sm text-gray-500 mb-3">
Create a 1-6 characters{"'"} identifier for the project.
</p>
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
className="w-40"
size="lg"
onChange={(e: any) => {
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{
required: "Identifier is required",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 9,
message: "Identifier must at most be of 9 characters",
},
}}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Network</h4>
<p className="text-sm text-gray-500 mb-3">Select privacy type for the project.</p>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
size="lg"
register={register}
validations={{
required: "Network is required",
}}
className="w-40"
/>
</div>
</div>
<div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</div>
</form>
</SettingsLayout>
);
};
export default GeneralSettings;

View File

@ -0,0 +1,238 @@
// react
import React, { useState } from "react";
// swr
import useSWR from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color";
// services
import issuesService from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import SettingsLayout from "layouts/settings-layout";
// components
import SingleLabel from "components/project/settings/single-label";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { BreadcrumbItem, Breadcrumbs, Button, Input, Spinner } from "ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
// types
import { IIssueLabels } from "types";
const defaultValues: Partial<IIssueLabels> = {
name: "",
colour: "#ff0000",
};
const LabelsSettings = () => {
const [newLabelForm, setNewLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [labelIdForUpdate, setLabelidForUpdate] = useState<string | null>(null);
const { activeWorkspace, activeProject } = useUser();
const {
register,
handleSubmit,
reset,
control,
setValue,
formState: { errors, isSubmitting },
watch,
} = useForm<IIssueLabels>({ defaultValues });
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
activeProject && activeWorkspace
? () => issuesService.getIssueLabels(activeWorkspace.slug, activeProject.id)
: null
);
const handleNewLabel: SubmitHandler<IIssueLabels> = (formData) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
issuesService.createIssueLabel(activeWorkspace.slug, activeProject.id, formData).then((res) => {
console.log(res);
reset(defaultValues);
mutate((prevData) => [...(prevData ?? []), res], false);
setNewLabelForm(false);
});
};
const editLabel = (label: IIssueLabels) => {
setNewLabelForm(true);
setValue("colour", label.colour);
setValue("name", label.name);
setIsUpdating(true);
setLabelidForUpdate(label.id);
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = (formData) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
issuesService
.patchIssueLabel(activeWorkspace.slug, activeProject.id, labelIdForUpdate ?? "", formData)
.then((res) => {
console.log(res);
reset(defaultValues);
mutate(
(prevData) =>
prevData?.map((p) => (p.id === labelIdForUpdate ? { ...p, ...formData } : p)),
false
);
setNewLabelForm(false);
});
};
const handleLabelDelete = (labelId: string) => {
if (activeWorkspace && activeProject) {
mutate((prevData) => prevData?.filter((p) => p.id !== labelId), false);
issuesService
.deleteIssueLabel(activeWorkspace.slug, activeProject.id, labelId)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
}
};
return (
<SettingsLayout
type="project"
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"}`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem title="Labels Settings" />
</Breadcrumbs>
}
>
<section className="space-y-8">
<div>
<h3 className="text-3xl font-bold leading-6 text-gray-900">Labels</h3>
<p className="mt-4 text-sm text-gray-500">Manage the labels of this project.</p>
</div>
<div className="md:w-2/3 flex justify-between items-center gap-2">
<h4 className="text-md leading-6 text-gray-900 mb-1">Manage labels</h4>
<Button
theme="secondary"
className="flex items-center gap-x-1"
onClick={() => setNewLabelForm(true)}
>
<PlusIcon className="h-4 w-4" />
New label
</Button>
</div>
<div className="space-y-5">
<div
className={`md:w-2/3 border p-3 rounded-md flex items-center gap-2 ${
newLabelForm ? "" : "hidden"
}`}
>
<div className="flex-shrink-0 h-8 w-8">
<Popover className="relative w-full h-full flex justify-center items-center bg-gray-200 rounded-xl">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
open ? "text-gray-900" : "text-gray-500"
}`}
>
{watch("colour") && watch("colour") !== "" && (
<span
className="w-4 h-4 rounded"
style={{
backgroundColor: watch("colour") ?? "green",
}}
></span>
)}
</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 top-full z-20 left-0 mt-3 px-2 w-screen max-w-xs sm:px-0">
<Controller
name="colour"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="w-full flex flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Lable title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<Button type="button" theme="secondary" onClick={() => setNewLabelForm(false)}>
Cancel
</Button>
{isUpdating ? (
<Button
type="button"
onClick={handleSubmit(handleLabelUpdate)}
disabled={isSubmitting}
>
{isSubmitting ? "Updating" : "Update"}
</Button>
) : (
<Button type="button" onClick={handleSubmit(handleNewLabel)} disabled={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</Button>
)}
</div>
<>
{issueLabels ? (
issueLabels.map((label) => (
<SingleLabel
key={label.id}
label={label}
issueLabels={issueLabels}
editLabel={editLabel}
handleLabelDelete={handleLabelDelete}
/>
))
) : (
<div className="flex justify-center py-4">
<Spinner />
</div>
)}
</>
</div>
</section>
</SettingsLayout>
);
};
export default LabelsSettings;

View File

@ -0,0 +1,278 @@
// react
import { useState } from "react";
// next
import Image from "next/image";
// swr
import useSWR from "swr";
// services
import projectService from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// layouts
import SettingsLayout from "layouts/settings-layout";
// components
import ConfirmProjectMemberRemove from "components/project/confirm-project-member-remove";
import SendProjectInvitationModal from "components/project/send-project-invitation-modal";
// ui
import { BreadcrumbItem, Breadcrumbs, Button, CustomListbox, CustomMenu, Spinner } from "ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// fetch-keys
import { PROJECT_INVITATIONS, PROJECT_MEMBERS } from "constants/fetch-keys";
const ROLE = {
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
};
const MembersSettings = () => {
const [selectedMember, setSelectedMember] = useState<string | null>(null);
const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState<string | null>(null);
const [inviteModal, setInviteModal] = useState(false);
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const { data: projectMembers, mutate: mutateMembers } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id)
: null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
);
const { data: projectInvitations, mutate: mutateInvitations } = useSWR(
activeWorkspace && activeProject ? PROJECT_INVITATIONS : null,
activeWorkspace && activeProject
? () => projectService.projectInvitations(activeWorkspace.slug, activeProject.id)
: null
);
let members = [
...(projectMembers?.map((item: any) => ({
id: item.id,
avatar: item.member?.avatar,
first_name: item.member?.first_name,
last_name: item.member?.last_name,
email: item.member?.email,
role: item.role,
status: true,
member: true,
})) || []),
...(projectInvitations?.map((item: any) => ({
id: item.id,
avatar: item.avatar ?? "",
first_name: item.first_name ?? item.email,
last_name: item.last_name ?? "",
email: item.email,
role: item.role,
status: item.accepted,
member: false,
})) || []),
];
return (
<>
<ConfirmProjectMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
setSelectedRemoveMember(null);
setSelectedInviteRemoveMember(null);
}}
data={members.find(
(item) => item.id === selectedRemoveMember || item.id === selectedInviteRemoveMember
)}
handleDelete={async () => {
if (!activeWorkspace || !activeProject) return;
if (selectedRemoveMember) {
await projectService.deleteProjectMember(
activeWorkspace.slug,
activeProject.id,
selectedRemoveMember
);
mutateMembers(
(prevData) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
false
);
}
if (selectedInviteRemoveMember) {
await projectService.deleteProjectInvitation(
activeWorkspace.slug,
activeProject.id,
selectedInviteRemoveMember
);
mutateInvitations(
(prevData) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
false
);
}
setToastAlert({
type: "success",
message: "Member removed successfully",
title: "Success",
});
}}
/>
<SendProjectInvitationModal
isOpen={inviteModal}
setIsOpen={setInviteModal}
members={members}
/>
<SettingsLayout
type="project"
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"}`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem title="Members Settings" />
</Breadcrumbs>
}
>
<section className="space-y-8">
<div>
<h3 className="text-3xl font-bold leading-6 text-gray-900">Members</h3>
<p className="mt-4 text-sm text-gray-500">Manage all the members of the project.</p>
</div>
{!projectMembers || !projectInvitations ? (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<Spinner />
</div>
) : (
<div className="md:w-2/3">
<div className="flex justify-between items-center gap-2">
<h4 className="text-md leading-6 text-gray-900 mb-1">Manage members</h4>
<Button
theme="secondary"
className="flex items-center gap-x-1"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</Button>
</div>
<div className="space-y-6 mt-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex justify-between items-center">
<div className="flex items-center gap-x-8 gap-y-2">
<div className="h-10 w-10 p-4 flex items-center justify-center bg-gray-700 text-white rounded capitalize relative">
{member.avatar && member.avatar !== "" ? (
<Image
src={member.avatar}
alt={member.first_name}
layout="fill"
objectFit="cover"
className="rounded"
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="text-xs text-gray-500">{member.email}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{selectedMember === member.id ? (
<CustomListbox
options={Object.keys(ROLE).map((key) => ({
value: key,
display: ROLE[parseInt(key) as keyof typeof ROLE],
}))}
title={ROLE[member.role as keyof typeof ROLE] ?? "Select Role"}
value={member.role}
onChange={(value) => {
if (!activeWorkspace || !activeProject) return;
projectService
.updateProjectMember(
activeWorkspace.slug,
activeProject.id,
member.id,
{
role: value,
}
)
.then((res) => {
setToastAlert({
type: "success",
message: "Member role updated successfully.",
title: "Success",
});
mutateMembers(
(prevData: any) =>
prevData.map((m: any) => {
return m.id === selectedMember
? { ...m, ...res, role: value }
: m;
}),
false
);
setSelectedMember(null);
})
.catch((err) => {
console.log(err);
});
}}
/>
) : (
ROLE[member.role as keyof typeof ROLE] ?? "None"
)}
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (!member.member) {
setToastAlert({
type: "error",
message: "You can't edit a pending invitation.",
title: "Error",
});
} else {
setSelectedMember(member.id);
}
}}
>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) {
setSelectedRemoveMember(member.id);
} else {
setSelectedInviteRemoveMember(member.id);
}
}}
>
Remove
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
</div>
</div>
)}
</section>
</SettingsLayout>
</>
);
};
export default MembersSettings;

View File

@ -0,0 +1,141 @@
// react
import { useState } from "react";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import SettingsLayout from "layouts/settings-layout";
// components
import ConfirmStateDeletion from "components/project/issues/BoardView/state/confirm-state-delete";
import {
CreateUpdateStateInline,
StateGroup,
} from "components/project/issues/BoardView/state/create-update-state-inline";
// ui
import { BreadcrumbItem, Breadcrumbs, Spinner } from "ui";
// icons
import { PencilSquareIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
// types
import { IState } from "types";
// common
import { addSpaceIfCamelCase, groupBy } from "constants/common";
const StatesSettings = () => {
const [activeGroup, setActiveGroup] = useState<StateGroup>(null);
const [selectedState, setSelectedState] = useState<string | null>(null);
const [selectDeleteState, setSelectDeleteState] = useState<string | null>(null);
const { activeWorkspace, activeProject, states } = useUser();
const groupedStates: {
[key: string]: IState[];
} = groupBy(states ?? [], "group");
return (
<>
<ConfirmStateDeletion
isOpen={!!selectDeleteState}
data={states?.find((state) => state.id === selectDeleteState) ?? null}
onClose={() => setSelectDeleteState(null)}
/>
<SettingsLayout
type="project"
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"}`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem title="States Settings" />
</Breadcrumbs>
}
>
<div className="space-y-8">
<div>
<h3 className="text-3xl font-bold leading-6 text-gray-900">States</h3>
<p className="mt-4 text-sm text-gray-500">Manage the state of this project.</p>
</div>
<div className="flex flex-col justify-between gap-4">
{states && activeProject ? (
Object.keys(groupedStates).map((key) => (
<div key={key}>
<div className="flex justify-between w-full md:w-2/3 mb-2">
<p className="text-md leading-6 text-gray-900 capitalize">{key} states</p>
<button
type="button"
onClick={() => setActiveGroup(key as keyof StateGroup)}
className="flex items-center gap-2 text-theme text-xs"
>
<PlusIcon className="h-3 w-3 text-theme" />
Add
</button>
</div>
<div className="md:w-2/3 space-y-1 border p-1 rounded-xl">
{key === activeGroup && (
<CreateUpdateStateInline
projectId={activeProject.id}
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
workspaceSlug={activeWorkspace?.slug}
data={null}
selectedGroup={key as keyof StateGroup}
/>
)}
{groupedStates[key]?.map((state) =>
state.id !== selectedState ? (
<div
key={state.id}
className={`bg-gray-50 p-3 flex justify-between items-center gap-2 border-b ${
Boolean(activeGroup !== key) ? "last:border-0" : ""
}`}
>
<div className="flex items-center gap-2">
<div
className="flex-shrink-0 h-3 w-3 rounded-full"
style={{
backgroundColor: state.color,
}}
></div>
<h6 className="text-sm">{addSpaceIfCamelCase(state.name)}</h6>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setSelectDeleteState(state.id)}>
<TrashIcon className="h-4 w-4 text-red-400" />
</button>
<button type="button" onClick={() => setSelectedState(state.id)}>
<PencilSquareIcon className="h-4 w-4 text-gray-400" />
</button>
</div>
</div>
) : (
<div className={`border-b last:border-b-0`} key={state.id}>
<CreateUpdateStateInline
projectId={activeProject.id}
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
workspaceSlug={activeWorkspace?.slug}
data={states?.find((state) => state.id === selectedState) ?? null}
selectedGroup={key as keyof StateGroup}
/>
</div>
)
)}
</div>
</div>
))
) : (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<Spinner />
</div>
)}
</div>
</div>
</SettingsLayout>
</>
);
};
export default StatesSettings;

View File

@ -8,7 +8,7 @@ import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// components // components
import ProjectMemberInvitations from "components/project/memberInvitations"; import ProjectMemberInvitations from "components/project/member-invitations";
import ConfirmProjectDeletion from "components/project/confirm-project-deletion"; import ConfirmProjectDeletion from "components/project/confirm-project-deletion";
// ui // ui
import { import {

View File

@ -223,7 +223,14 @@ const WorkspaceSettings = () => {
</div> </div>
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel>
<div> <div className="space-y-3">
<h2 className="text-2xl text-red-500 font-semibold">Danger Zone</h2>
<p className="w-full md:w-1/2">
The danger zone of the workspace delete page is a critical area that requires
careful consideration and attention. When deleting a workspace, all of the
data and resources within that workspace will be permanently removed and
cannot be recovered.
</p>
<Button theme="danger" onClick={() => setIsOpen(true)}> <Button theme="danger" onClick={() => setIsOpen(true)}>
Delete the workspace Delete the workspace
</Button> </Button>

View File

@ -11,13 +11,25 @@ export interface IssueResponse {
results: IIssue[]; results: IIssue[];
} }
export interface IIssueCycle {
id: string;
cycle_detail: ICycle;
created_at: Date;
updated_at: Date;
created_by: string;
updated_by: string;
project: string;
workspace: string;
issue: string;
cycle: string;
}
export interface IIssue { export interface IIssue {
id: string; id: string;
state_detail: IState; state_detail: IState;
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[];
@ -38,7 +50,7 @@ export interface IIssue {
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
workspace: string; workspace: string;
}; } | null;
description: any; description: any;
priority: string | null; priority: string | null;
start_date: string | null; start_date: string | null;
@ -60,6 +72,9 @@ export interface IIssue {
blocked_issue_details: any[]; blocked_issue_details: any[];
sprints: string | null; sprints: string | null;
cycle: string | null; cycle: string | null;
cycle_detail: ICycle | null;
issue_cycle: IIssueCycle;
} }
export interface BlockeIssue { export interface BlockeIssue {
@ -116,8 +131,9 @@ export type Properties = {
assignee: boolean; assignee: boolean;
priority: boolean; priority: boolean;
start_date: boolean; start_date: boolean;
target_date: boolean; due_date: boolean;
cycle: boolean; cycle: boolean;
children_count: boolean;
}; };
export interface IIssueLabels { export interface IIssueLabels {

View File

@ -14,6 +14,7 @@ export interface IProject {
slug: string; slug: string;
created_by: string; created_by: string;
updated_by: string; updated_by: string;
icon: string;
} }
type ProjectViewTheme = { type ProjectViewTheme = {
@ -30,7 +31,9 @@ export interface IProjectMember {
workspace: IWorkspace; workspace: IWorkspace;
comment: string; comment: string;
role: 5 | 10 | 15 | 20; role: 5 | 10 | 15 | 20;
view_props: ProjectViewTheme; view_props: ProjectViewTheme;
default_props: ProjectViewTheme;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;

View File

@ -1,3 +1,5 @@
import { IIssue, NestedKeyOf, Properties } from "./";
export interface IUser { export interface IUser {
id: readonly string; id: readonly string;
last_login: readonly Date; last_login: readonly Date;
@ -14,6 +16,12 @@ export interface IUser {
created_location: readonly string; created_location: readonly string;
is_email_verified: boolean; is_email_verified: boolean;
token: string; token: string;
my_issues_prop?: {
properties: Properties;
groupBy: NestedKeyOf<IIssue> | null;
};
[...rest: string]: any; [...rest: string]: any;
} }

View File

@ -1,4 +1,4 @@
import type { IUser, IUserLite } from "./"; import type { IProjectMember, IUser, IUserLite } from "./";
export interface IWorkspace { export interface IWorkspace {
readonly id: string; readonly id: string;
@ -36,3 +36,8 @@ export interface IWorkspaceMember {
created_by: string; created_by: string;
updated_by: string; updated_by: string;
} }
export interface ILastActiveWorkspaceDetails {
workspace_details: IWorkspace;
project_details?: IProjectMember[];
}

View File

@ -6,7 +6,7 @@ type BreadcrumbsProps = {
children: any; children: any;
}; };
const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ children }: BreadcrumbsProps) => { const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
const router = useRouter(); const router = useRouter();
return ( return (
@ -54,4 +54,6 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
); );
}; };
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
export { Breadcrumbs, BreadcrumbItem }; export { Breadcrumbs, BreadcrumbItem };

View File

@ -33,13 +33,14 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(
type={type} type={type}
disabled={disabled} disabled={disabled}
className={classNames( className={classNames(
className,
"inline-flex items-center rounded justify-center font-medium", "inline-flex items-center rounded justify-center font-medium",
theme === "primary" theme === "primary"
? `${ ? `${
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 hover:bg-gray-100" ? "border bg-transparent hover:bg-gray-100"
: theme === "success" : theme === "success"
? `${ ? `${
disabled ? "opacity-70" : "" disabled ? "opacity-70" : ""
@ -53,8 +54,7 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(
? "px-3 py-2 text-base" ? "px-3 py-2 text-base"
: 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
)} )}
> >
{children} {children}

View File

@ -13,9 +13,11 @@ const Input: React.FC<Props> = ({
error, error,
mode = "primary", mode = "primary",
onChange, onChange,
className, className = "",
type, type,
id, id,
size = "rg",
fullWidth = true,
...rest ...rest
}) => { }) => {
return ( return (
@ -35,14 +37,16 @@ const Input: React.FC<Props> = ({
onChange && onChange(e); onChange && onChange(e);
}} }}
className={classNames( className={classNames(
"mt-1 block w-full px-3 py-2 text-base focus:outline-none sm:text-sm rounded-md bg-transparent", "block text-base focus:outline-none sm:text-sm rounded-md bg-transparent",
mode === "primary" ? "border border-gray-300 rounded-md" : "", mode === "primary" ? "border border-gray-300 rounded-md" : "",
mode === "transparent" mode === "transparent"
? "bg-transparent border-none transition-all ring-0 focus:ring-1 focus:ring-indigo-500 rounded" ? "bg-transparent border-none transition-all ring-0 focus:ring-1 focus:ring-indigo-500 rounded"
: "", : "",
error ? "border-red-500" : "", error ? "border-red-500" : "",
error && mode === "primary" ? "bg-red-100" : "", error && mode === "primary" ? "bg-red-100" : "",
className ?? "" fullWidth ? "w-full" : "",
size === "rg" ? "px-3 py-2" : size === "lg" ? "p-3" : "",
className
)} )}
{...rest} {...rest}
/> />

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