release: Stage Release (#251)

* feat: manual ordering for issues in kanban

* refactor: issues folder structure

* refactor: modules and states folder structure

* refactor: datepicker code

* fix: create issue modal bug

* feat: custom progress bar added

* refactor: created global component for kanban board

* refactor: update cycle and module issue create

* refactor: return modules created

* refactor: integrated global kanban view everywhere

* refactor: integrated global list view everywhere

* refactor: removed unnecessary api calls

* refactor: update nomenclature for consistency

* refactor: global select component for issue view

* refactor: track cycles and modules for issue

* fix: tracking new cycles and modules in activities

* feat: segregate api token workspace

* fix: workpsace id during token creation

* refactor: update model association to cascade on delete

* feat: sentry integrated (#235)

* feat: sentry integrated

* fix: removed unnecessary env variable

* fix: update remirror description to save empty string and empty paragraph (#237)

* Update README.md

* fix: description and comment_json default value to remove warnings

* feat: link option in remirror (#240)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* feat: module and cycle settings under project

* fix:  module issue assignment

* fix: module issue updation and activity logging

* fix: typo while creating module issues

* fix: string comparison for update operation

* fix: ui fixes (#246)

* style: shortcut command label bg color change

* sidebar shortcut ui fix

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>

* fix: update empty passwords to hashed string and add hashing for magic sign in

* refactor: remove print logs from back migrations

* build(deps): bump django in /apiserver/requirements

Bumps [django](https://github.com/django/django) from 3.2.16 to 3.2.17.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.16...3.2.17)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat: cycles and modules toggle in settings, refactor: folder structure (#247)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* refactor: constants folder

* refactor: layouts folder structure

* fix: issue view context

* feat: cycles and modules toggle in settings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sphynxux <122926002+sphynxux@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
sriram veeraghanta 2023-02-08 10:15:18 +05:30 committed by GitHub
parent 6966666bf5
commit d3b73dc32f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
177 changed files with 4767 additions and 5404 deletions

View file

@ -0,0 +1,261 @@
import React from "react";
import { useRouter } from "next/router";
import Image from "next/image";
import { KeyedMutator } from "swr";
// icons
import {
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleBottomCenterTextIcon,
RectangleGroupIcon,
Squares2X2Icon,
UserIcon,
} from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
// components
import { CommentCard } from "components/issues/comment";
// ui
import { Loader } from "components/ui";
// icons
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
// helpers
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssueActivity, IIssueComment } from "types";
const activityDetails: {
[key: string]: {
message?: string;
icon: JSX.Element;
};
} = {
assignee: {
message: "removed the assignee",
icon: <UserGroupIcon className="h-4 w-4" />,
},
assignees: {
message: "added a new assignee",
icon: <UserGroupIcon className="h-4 w-4" />,
},
blocks: {
message: "marked this issue being blocked by",
icon: <BlockedIcon height="16" width="16" />,
},
blocking: {
message: "marked this issue is blocking",
icon: <BlockerIcon height="16" width="16" />,
},
cycles: {
message: "set the cycle to",
icon: <CyclesIcon height="16" width="16" />,
},
labels: {
icon: <TagIcon height="16" width="16" />,
},
modules: {
message: "set the module to",
icon: <RectangleGroupIcon className="h-4 w-4" />,
},
state: {
message: "set the state to",
icon: <Squares2X2Icon className="h-4 w-4" />,
},
priority: {
message: "set the priority to",
icon: <ChartBarIcon className="h-4 w-4" />,
},
name: {
message: "set the name to",
icon: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
},
description: {
message: "updated the description.",
icon: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
},
target_date: {
message: "set the due date to",
icon: <CalendarDaysIcon className="h-4 w-4" />,
},
parent: {
message: "set the parent to",
icon: <UserIcon className="h-4 w-4" />,
},
};
type Props = {
issueActivities: IIssueActivity[];
mutate: KeyedMutator<IIssueActivity[]>;
};
export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const onCommentUpdate = async (comment: IIssueComment) => {
if (!workspaceSlug || !projectId || !issueId) return;
await issuesServices
.patchIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
comment.id,
comment
)
.then((res) => {
mutate();
});
};
const onCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueId) return;
await issuesServices
.deleteIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
commentId
)
.then((response) => {
mutate();
console.log(response);
});
};
return (
<>
{issueActivities ? (
<div className="space-y-4">
{issueActivities.map((activity, index) => {
if ("field" in activity && activity.field !== "updated_by") {
return (
<div key={activity.id} className="relative flex w-full items-center gap-x-2">
{issueActivities.length > 1 && index !== issueActivities.length - 1 ? (
<span
className="absolute top-5 left-2.5 h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
{activity.field ? (
<div className="relative z-10 -ml-1 flex-shrink-0">
<div className="grid h-8 w-8 place-items-center bg-white">
{activityDetails[activity.field as keyof typeof activityDetails]?.icon}
</div>
</div>
) : (
<div className="relative z-10 -ml-4 flex-shrink-0 rounded-full border-2 border-white">
<div className="grid h-12 w-12 place-items-center">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<Image
src={activity.actor_detail.avatar}
alt={activity.actor_detail.name}
height={30}
width={30}
className="rounded-full"
/>
) : (
<div
className={`grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-gray-700 text-white`}
>
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
</div>
</div>
)}
<div className={`${activity.field ? "ml-1.5" : ""} w-full text-xs`}>
<p>
<span className="font-medium">
{activity.actor_detail.first_name} {activity.actor_detail.last_name}
</span>
<span>
{" "}
{activity.field === "labels"
? activity.new_value !== ""
? "added a new label"
: "removed the label"
: activity.field === "blocking"
? activity.new_value !== ""
? "marked this issue is blocking"
: "removed the issue from blocking"
: activity.field === "blocks"
? activity.new_value !== ""
? "marked this issue being blocked by"
: "removed blocker"
: activity.field === "target_date"
? activity.new_value && activity.new_value !== ""
? "set the due date to"
: "removed the due date"
: activityDetails[activity.field as keyof typeof activityDetails]
?.message}{" "}
</span>
<span className="font-medium">
{activity.verb === "created" &&
activity.field !== "cycles" &&
activity.field !== "modules" ? (
<span className="text-gray-600">created this issue.</span>
) : activity.field === "description" ? null : activity.field === "state" ? (
activity.new_value ? (
addSpaceIfCamelCase(activity.new_value)
) : (
"None"
)
) : activity.field === "labels" ||
activity.field === "blocking" ||
activity.field === "blocks" ? (
activity.new_value !== "" ? (
activity.new_value
) : (
activity.old_value
)
) : activity.field === "assignee" ? (
activity.old_value
) : activity.field === "target_date" ? (
activity.new_value ? (
renderShortNumericDateFormat(activity.new_value as string)
) : null
) : activity.field === "description" ? (
""
) : (
activity.new_value ?? "None"
)}
</span>
<span className="ml-2 text-gray-500">{timeAgo(activity.created_at)}</span>
</p>
</div>
</div>
);
} else if ("comment_json" in activity)
return (
<CommentCard
key={activity.id}
comment={activity as any}
onSubmit={onCommentUpdate}
handleCommentDeletion={onCommentDelete}
/>
);
})}
</div>
) : (
<Loader className="space-y-4">
<div className="space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
</div>
<div className="space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
</div>
<div className="space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
</div>
</Loader>
)}
</>
);
};

View file

@ -0,0 +1,114 @@
import React, { useMemo } from "react";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// services
import issuesServices from "services/issues.service";
// ui
import { Loader } from "components/ui";
// helpers
import { debounce } from "helpers/common.helper";
// types
import type { IIssueActivity, IIssueComment } from "types";
import type { KeyedMutator } from "swr";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader className="mb-5">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
const defaultValues: Partial<IIssueComment> = {
comment_html: "",
comment_json: "",
};
export const AddComment: React.FC<{
mutate: KeyedMutator<IIssueActivity[]>;
}> = ({ mutate }) => {
const {
handleSubmit,
control,
setValue,
formState: { isSubmitting },
reset,
} = useForm<IIssueComment>({ defaultValues });
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const onSubmit = async (formData: IIssueComment) => {
if (
!workspaceSlug ||
!projectId ||
!issueId ||
isSubmitting ||
!formData.comment_html ||
!formData.comment_json
)
return;
await issuesServices
.createIssueComment(workspaceSlug as string, projectId as string, issueId as string, formData)
.then(() => {
mutate();
reset(defaultValues);
})
.catch((error) => {
console.error(error);
});
};
const updateDescription = useMemo(
() =>
debounce((key: any, val: any) => {
setValue(key, val);
}, 1000),
[setValue]
);
const updateDescriptionHTML = useMemo(
() =>
debounce((key: any, val: any) => {
setValue(key, val);
}, 1000),
[setValue]
);
return (
<div className="space-y-5">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="issue-comments-section">
<Controller
name="comment_html"
control={control}
render={({ field: { value } }) => (
<RemirrorRichTextEditor
value={value}
onBlur={(jsonValue, htmlValue) => {
setValue("comment_json", jsonValue);
setValue("comment_html", htmlValue);
}}
placeholder="Enter Your comment..."
/>
)}
/>
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-gray-300 p-2 px-4 text-sm text-black hover:bg-gray-300"
>
{isSubmitting ? "Adding..." : "Comment"}
</button>
</div>
</form>
</div>
);
};

View file

@ -0,0 +1,132 @@
import React, { useEffect, useState } from "react";
import Image from "next/image";
import dynamic from "next/dynamic";
// react-hook-form
import { useForm } from "react-hook-form";
// icons
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
// hooks
import useUser from "hooks/use-user";
// ui
import { CustomMenu } from "components/ui";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
import type { IIssueComment } from "types";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { ssr: false });
type Props = {
comment: IIssueComment;
onSubmit: (comment: IIssueComment) => void;
handleCommentDeletion: (comment: string) => void;
};
export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
const { user } = useUser();
const [isEditing, setIsEditing] = useState(false);
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
setValue,
} = useForm<IIssueComment>({
defaultValues: comment,
});
const onEnter = (formData: IIssueComment) => {
if (isSubmitting) return;
setIsEditing(false);
onSubmit(formData);
};
useEffect(() => {
isEditing && setFocus("comment");
}, [isEditing, setFocus]);
return (
<div className="-ml-1 flex h-full w-full justify-between">
<div className="flex w-full gap-x-4">
<div className="flex-shrink-0">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<Image
src={comment.actor_detail.avatar}
alt={comment.actor_detail.name}
height={30}
width={30}
className="rounded-full"
/>
) : (
<div
className={`grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{comment.actor_detail.first_name.charAt(0)}
</div>
)}
</div>
<div className="w-full space-y-1">
<p className="flex items-center gap-2 text-xs text-gray-500">
<span>
{comment.actor_detail.first_name} {comment.actor_detail.last_name}
</span>
<span>{timeAgo(comment.created_at)}</span>
</p>
<div className="issue-comments-section">
{isEditing ? (
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}>
<RemirrorRichTextEditor
value={comment.comment_html}
onBlur={(jsonValue, htmlValue) => {
setValue("comment_json", jsonValue);
setValue("comment_html", htmlValue);
}}
placeholder="Enter Your comment..."
/>
<div className="flex gap-1 self-end">
<button
type="submit"
disabled={isSubmitting}
className="group rounded border border-green-500 bg-green-100 p-2 shadow-md duration-300 hover:bg-green-500"
>
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
</button>
<button
type="button"
className="group rounded border border-red-500 bg-red-100 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={() => setIsEditing(false)}
>
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
) : (
<>
<RemirrorRichTextEditor
value={comment.comment_html}
editable={false}
onBlur={() => ({})}
/>
</>
)}
</div>
</div>
</div>
{user?.id === comment.actor && (
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => setIsEditing(true)}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
handleCommentDeletion(comment.id);
}}
>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
);
};

View file

@ -0,0 +1,2 @@
export * from "./add-comment";
export * from "./comment-card";

View file

@ -0,0 +1,182 @@
import React, { useEffect, useRef, useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import issueServices from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "components/ui";
// types
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types";
// fetch-keys
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES, USER_ISSUE } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: IIssue | null;
};
export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
const cancelButtonRef = useRef(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId: queryProjectId } = router.query;
const { setToastAlert } = useToast();
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !workspaceSlug) return;
const projectId = data.project;
await issueServices
.deleteIssue(workspaceSlug as string, projectId, data.id)
.then(() => {
const cycleId = data?.cycle;
const moduleId = data?.module;
if (cycleId) {
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId),
(prevData) => prevData?.filter((i) => i.issue !== data.id),
false
);
}
if (moduleId) {
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId),
(prevData) => prevData?.filter((i) => i.issue !== data.id),
false
);
}
if (!queryProjectId)
mutate<IIssue[]>(
USER_ISSUE(workspaceSlug as string),
(prevData) => prevData?.filter((i) => i.id !== data.id),
false
);
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId),
(prevData) => ({
...(prevData as IssueResponse),
results: prevData?.results.filter((i) => i.id !== data.id) ?? [],
count: (prevData?.count as number) - 1,
}),
false
);
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Issue deleted successfully",
});
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" initialFocus={cancelButtonRef} onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div>
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full bg-red-100">
<ExclamationTriangleIcon
className="h-8 w-8 text-red-600"
aria-hidden="true"
/>
</div>
<Dialog.Title
as="h3"
className="mt-3 text-lg font-medium leading-6 text-gray-900"
>
Are you sure you want to delete {`"`}
{data?.project_detail.identifier}-{data?.sequence_id} - {data?.name}?{`"`}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
All of the data related to the issue will be permanently removed. This
action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={onClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View file

@ -16,7 +16,7 @@ import {
IssueStateSelect,
} from "components/issues/select";
import { CycleSelect as IssueCycleSelect } from "components/cycles/select";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal";
import { CreateUpdateStateModal } from "components/states";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui
import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";

View file

@ -1,5 +1,12 @@
export * from "./list-item";
export * from "./comment";
export * from "./sidebar-select";
export * from "./activity";
export * from "./delete-issue-modal";
export * from "./description-form";
export * from "./sub-issue-list";
export * from "./form";
export * from "./modal";
export * from "./my-issues-list-item";
export * from "./parent-issues-list-modal";
export * from "./sidebar";
export * from "./sub-issues-list";
export * from "./sub-issues-list-modal";

View file

@ -1,144 +0,0 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { AssigneesList } from "components/ui/avatar";
// icons
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// helpers
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, Properties } from "types";
// constants
import { getPriorityIcon } from "constants/global";
type Props = {
type?: string;
issue: IIssue;
properties: Properties;
editIssue?: () => void;
handleDeleteIssue?: () => void;
removeIssue?: () => void;
};
export const IssueListItem: React.FC<Props> = (props) => {
// const { type, issue, properties, editIssue, handleDeleteIssue, removeIssue } = props;
const { issue, properties } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties?.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<div
className={`group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(issue.priority)}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
/>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.due_date && (
<div
className={`group group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Due 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.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<div className="flex items-center gap-1">
<AssigneesList userIds={issue.assignees ?? []} />
</div>
)}
</div>
</div>
);
};

View file

@ -16,11 +16,7 @@ import issuesService from "services/issues.service";
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// components
import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import { IssueForm } from "components/issues";
// common
import { renderDateFormat } from "helpers/date-time.helper";
// types
import type { IIssue, IssueResponse } from "types";
// fetch keys
@ -54,7 +50,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const [activeProject, setActiveProject] = useState<string | null>(null);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
const { user } = useUser();
const { setToastAlert } = useToast();
@ -176,7 +175,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
.then((res) => {
if (isUpdatingSingleIssue) {
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
} else
} else {
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""),
(prevData) => ({
@ -187,8 +186,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}),
})
);
}
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
if (!createMore) handleClose();
@ -206,15 +207,16 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
};
const handleFormSubmit = async (formData: Partial<IIssue>) => {
if (workspaceSlug && activeProject) {
const payload: Partial<IIssue> = {
...formData,
target_date: formData.target_date ? renderDateFormat(formData.target_date ?? "") : null,
};
if (!workspaceSlug || !activeProject) return;
if (!data) await createIssue(payload);
else await updateIssue(payload);
}
const payload: Partial<IIssue> = {
...formData,
description: formData.description ? formData.description : "",
description_html: formData.description_html ? formData.description_html : "<p></p>",
};
if (!data) await createIssue(payload);
else await updateIssue(payload);
};
return (

View file

@ -0,0 +1,127 @@
import React, { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// components
import {
ViewDueDateSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues/view-select";
// ui
import { AssigneesList } from "components/ui/avatar";
import { CustomMenu } from "components/ui";
// types
import { IIssue, Properties } from "types";
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
type Props = {
issue: IIssue;
properties: Properties;
projectId: string;
handleDeleteIssue: () => void;
};
export const MyIssuesListItem: React.FC<Props> = ({
issue,
properties,
projectId,
handleDeleteIssue,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug) return;
mutate<IIssue[]>(
USER_ISSUE(workspaceSlug as string),
(prevData) =>
prevData?.map((p) => {
if (p.id === issue.id) return { ...p, ...formData };
return p;
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
mutate(USER_ISSUE(workspaceSlug as string));
})
.catch((error) => {
console.log(error);
});
},
[workspaceSlug, projectId, issue]
);
const isNotAllowed = false;
return (
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties?.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<div className="flex items-center gap-1">
<AssigneesList userIds={issue.assignees ?? []} />
</div>
)}
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleDeleteIssue}>Delete permanently</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
);
};

View file

@ -0,0 +1,229 @@
import React, { useState } from "react";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "components/ui";
// types
import { IIssue } from "types";
import { LayerDiagonalIcon } from "components/icons";
type Props = {
isOpen: boolean;
handleClose: () => void;
value?: any;
onChange: (...event: any[]) => void;
issues: IIssue[];
title?: string;
multiple?: boolean;
customDisplay?: JSX.Element;
};
export const ParentIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
value,
onChange,
issues,
title = "Issues",
multiple = false,
customDisplay,
}) => {
const [query, setQuery] = useState("");
const [values, setValues] = useState<string[]>([]);
const handleClose = () => {
onClose();
setQuery("");
setValues([]);
};
const filteredIssues: IIssue[] =
query === ""
? issues ?? []
: issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
{multiple ? (
<>
<Combobox value={value} onChange={() => ({})} multiple>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
displayValue={() => ""}
/>
</div>
<div className="p-3">{customDisplay}</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
{title}
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.id}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button type="button" size="sm" onClick={() => onChange(values)}>
Add issues
</Button>
</div>
</>
) : (
<Combobox value={value} onChange={onChange}>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
displayValue={() => ""}
/>
</div>
<div className="p-3">{customDisplay}</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
{title}
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
onClick={() => handleClose()}
>
<>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</>
</Combobox.Option>
))}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox.Options>
</Combobox>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};

View file

@ -1,6 +1,6 @@
export * from "./assignee";
export * from "./label";
export * from "./parent-issue";
export * from "./parent";
export * from "./priority";
export * from "./project";
export * from "./state";

View file

@ -72,7 +72,7 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
const options = issueLabels?.map((label) => ({
value: label.id,
display: label.name,
color: label.colour,
color: label.color,
}));
const filteredOptions =

View file

@ -1,7 +1,7 @@
import React from "react";
import { Controller, Control } from "react-hook-form";
// components
import IssuesListModal from "components/project/issues/issues-list-modal";
import { ParentIssuesListModal } from "components/issues";
// types
import type { IIssue } from "types";
@ -17,7 +17,7 @@ export const IssueParentSelect: React.FC<Props> = ({ control, isOpen, setIsOpen,
control={control}
name="parent"
render={({ field: { onChange } }) => (
<IssuesListModal
<ParentIssuesListModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
onChange={onChange}

View file

@ -2,9 +2,10 @@ import React from "react";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { getPriorityIcon } from "components/icons/priority-icon";
// constants
import { getPriorityIcon } from "constants/global";
import { PRIORITIES } from "constants/";
import { PRIORITIES } from "constants/project";
type Props = {
value: string | null;

View file

@ -0,0 +1,145 @@
import React from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { Control, Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// services
import { UserGroupIcon } from "@heroicons/react/24/outline";
import workspaceService from "services/workspace.service";
// hooks
// ui
import { AssigneesList } from "components/ui/avatar";
import { Spinner } from "components/ui";
// types
import { IIssue, UserAuth } from "types";
// constants
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
userAuth: UserAuth;
};
export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: people } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Assignees</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="assignees_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={true}
onChange={(value: any) => {
submitChanges({ assignees_list: value });
}}
className="flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<div className="relative">
<Listbox.Button
className={`flex w-full ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<span
className={`hidden truncate text-left sm:block ${
value ? "" : "text-gray-900"
}`}
>
<div className="flex items-center gap-1 text-xs">
{value && Array.isArray(value) ? (
<AssigneesList userIds={value} length={10} />
) : null}
</div>
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options className="absolute left-0 z-10 mt-1 max-h-48 w-full overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{people ? (
people.length > 0 ? (
people.map((option) => (
<Listbox.Option
key={option.member.id}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
}
value={option.member.id}
>
{option.member.avatar && option.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={option.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name.charAt(0)
: option.member.email.charAt(0)}
</div>
)}
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name
: option.member.email}
</Listbox.Option>
))
) : (
<div className="text-center">No assignees found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,307 @@
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// services
import issuesService from "services/issues.service";
// ui
import { Button } from "components/ui";
// icons
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { BlockedIcon, LayerDiagonalIcon } from "components/icons";
// types
import { IIssue, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = {
blocked_issue_ids: string[];
};
type Props = {
submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
};
export const SidebarBlockedSelect: React.FC<Props> = ({
submitChanges,
issuesList,
watch,
userAuth,
}) => {
const [query, setQuery] = useState("");
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null
);
const {
handleSubmit,
reset,
watch: watchBlocked,
setValue,
} = useForm<FormInput>({
defaultValues: {
blocked_issue_ids: [],
},
});
const handleClose = () => {
setIsBlockedModalOpen(false);
reset();
};
const onSubmit: SubmitHandler<FormInput> = (data) => {
if (!data.blocked_issue_ids || data.blocked_issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (!Array.isArray(data.blocked_issue_ids)) data.blocked_issue_ids = [data.blocked_issue_ids];
const newBlocked = [...watch("blocked_list"), ...data.blocked_issue_ids];
submitChanges({ blocks_list: newBlocked });
handleClose();
};
const filteredIssues: IIssue[] =
query === ""
? issuesList
: issuesList.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<BlockedIcon height={16} width={16} />
<p>Blocked by</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1">
{watch("blocked_list") && watch("blocked_list").length > 0
? watch("blocked_list").map((issue) => (
<span
key={issue}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-white px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500 hover:bg-red-50"
onClick={() => {
const updatedBlocked: string[] = watch("blocked_list").filter(
(i) => i !== issue
);
submitChanges({
blocks_list: updatedBlocked,
});
}}
>
<Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${
issues?.results.find((i) => i.id === issue)?.id
}`}
>
<a className="flex items-center gap-1">
<BlockedIcon height={10} width={10} />
{`${
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`}
</a>
</Link>
<span className="opacity-0 duration-300 group-hover:opacity-100">
<XMarkIcon className="h-2 w-2" />
</span>
</span>
))
: null}
</div>
<Transition.Root
show={isBlockedModalOpen}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<form>
<Combobox
onChange={(val: string) => {
const selectedIssues = watchBlocked("blocked_issue_ids");
if (selectedIssues.includes(val))
setValue(
"blocked_issue_ids",
selectedIssues.filter((i) => i !== val)
);
else {
const newBlocked = selectedIssues;
newBlocked.push(val);
setValue("blocked_issue_ids", newBlocked);
}
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<input
type="text"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select blocked issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (
!watch("blocked_list").includes(issue.id) &&
!watch("blockers_list").includes(issue.id)
) {
return (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={watchBlocked("blocked_issue_ids").includes(
issue.id
)}
readOnly
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{
issues?.results.find((i) => i.id === issue.id)
?.project_detail?.identifier
}
-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</Combobox.Option>
);
}
})}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox.Options>
</Combobox>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<div>
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Close
</Button>
</div>
<Button onClick={handleSubmit(onSubmit)} size="sm">
Add selected issues
</Button>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<button
type="button"
className={`flex w-full ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
} items-center justify-between gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
onClick={() => setIsBlockedModalOpen(true)}
disabled={isNotAllowed}
>
Select issues
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,306 @@
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// services
import issuesServices from "services/issues.service";
// ui
import { Button } from "components/ui";
// icons
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon, LayerDiagonalIcon } from "components/icons";
// types
import { IIssue, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = {
blocker_issue_ids: string[];
};
type Props = {
submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
};
export const SidebarBlockerSelect: React.FC<Props> = ({
submitChanges,
issuesList,
watch,
userAuth,
}) => {
const [query, setQuery] = useState("");
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const {
handleSubmit,
reset,
watch: watchBlocker,
setValue,
} = useForm<FormInput>({
defaultValues: {
blocker_issue_ids: [],
},
});
const handleClose = () => {
setIsBlockerModalOpen(false);
reset();
};
const onSubmit: SubmitHandler<FormInput> = (data) => {
if (!data.blocker_issue_ids || data.blocker_issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (!Array.isArray(data.blocker_issue_ids)) data.blocker_issue_ids = [data.blocker_issue_ids];
const newBlockers = [...watch("blockers_list"), ...data.blocker_issue_ids];
submitChanges({ blockers_list: newBlockers });
handleClose();
};
const filteredIssues: IIssue[] =
query === ""
? issuesList
: issuesList.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<BlockerIcon height={16} width={16} />
<p>Blocking</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1">
{watch("blockers_list") && watch("blockers_list").length > 0
? watch("blockers_list").map((issue) => (
<div
key={issue}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-white px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500 hover:bg-yellow-50"
>
<Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${
issues?.results.find((i) => i.id === issue)?.id
}`}
>
<a className="flex items-center gap-1">
<BlockerIcon height={10} width={10} />
{`${
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`}
</a>
</Link>
<span
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers: string[] = watch("blockers_list").filter(
(i) => i !== issue
);
submitChanges({
blockers_list: updatedBlockers,
});
}}
>
<XMarkIcon className="h-2 w-2" />
</span>
</div>
))
: null}
</div>
<Transition.Root
show={isBlockerModalOpen}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<Combobox
onChange={(val: string) => {
const selectedIssues = watchBlocker("blocker_issue_ids");
if (selectedIssues.includes(val))
setValue(
"blocker_issue_ids",
selectedIssues.filter((i) => i !== val)
);
else {
const newBlockers = selectedIssues;
newBlockers.push(val);
setValue("blocker_issue_ids", newBlockers);
}
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<input
type="text"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select blocker issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (
!watch("blockers_list").includes(issue.id) &&
!watch("blocked_list").includes(issue.id)
)
return (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={watchBlocker("blocker_issue_ids").includes(
issue.id
)}
readOnly
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{
issues?.results.find((i) => i.id === issue.id)
?.project_detail?.identifier
}
-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</Combobox.Option>
);
})}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox.Options>
</Combobox>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<div>
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Close
</Button>
</div>
<Button onClick={handleSubmit(onSubmit)} size="sm">
Add selected issues
</Button>
</div>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<button
type="button"
className={`flex w-full ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
} items-center justify-between gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
onClick={() => setIsBlockerModalOpen(true)}
disabled={isNotAllowed}
>
Select issues
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,104 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import issuesService from "services/issues.service";
import cyclesService from "services/cycles.service";
// ui
import { Spinner, CustomSelect } from "components/ui";
// icons
import { CyclesIcon } from "components/icons";
// types
import { ICycle, IIssue, UserAuth } from "types";
// fetch-keys
import { CYCLE_ISSUES, CYCLE_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
type Props = {
issueDetail: IIssue | undefined;
handleCycleChange: (cycle: ICycle) => void;
userAuth: UserAuth;
};
export const SidebarCycleSelect: React.FC<Props> = ({
issueDetail,
handleCycleChange,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: cycles } = useSWR(
workspaceSlug && projectId ? CYCLE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => cyclesService.getCycles(workspaceSlug as string, projectId as string)
: null
);
const removeIssueFromCycle = (bridgeId: string, cycleId: string) => {
if (!workspaceSlug || !projectId) return;
issuesService
.removeIssueFromCycle(workspaceSlug as string, projectId as string, cycleId, bridgeId)
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(CYCLE_ISSUES(cycleId));
})
.catch((e) => {
console.log(e);
});
};
const issueCycle = issueDetail?.issue_cycle;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CyclesIcon className="h-4 w-4 flex-shrink-0" />
<p>Cycle</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<span
className={`hidden truncate text-left sm:block ${issueCycle ? "" : "text-gray-900"}`}
>
{issueCycle ? issueCycle.cycle_detail.name : "None"}
</span>
}
value={issueCycle?.cycle_detail.id}
onChange={(value: any) => {
value === null
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
: handleCycleChange(cycles?.find((c) => c.id === value) as ICycle);
}}
disabled={isNotAllowed}
>
{cycles ? (
cycles.length > 0 ? (
<>
<CustomSelect.Option value={null} className="capitalize">
None
</CustomSelect.Option>
{cycles.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
{option.name}
</CustomSelect.Option>
))}
</>
) : (
<div className="text-center">No cycles found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
);
};

View file

@ -0,0 +1,8 @@
export * from "./assignee";
export * from "./blocked";
export * from "./blocker";
export * from "./cycle";
export * from "./module";
export * from "./parent";
export * from "./priority";
export * from "./state";

View file

@ -0,0 +1,103 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import modulesService from "services/modules.service";
// ui
import { Spinner, CustomSelect } from "components/ui";
// icons
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IModule, UserAuth } from "types";
// fetch-keys
import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys";
type Props = {
issueDetail: IIssue | undefined;
handleModuleChange: (module: IModule) => void;
userAuth: UserAuth;
};
export const SidebarModuleSelect: React.FC<Props> = ({
issueDetail,
handleModuleChange,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: modules } = useSWR(
workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => modulesService.getModules(workspaceSlug as string, projectId as string)
: null
);
const removeIssueFromModule = (bridgeId: string, moduleId: string) => {
if (!workspaceSlug || !projectId) return;
modulesService
.removeIssueFromModule(workspaceSlug as string, projectId as string, moduleId, bridgeId)
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(MODULE_ISSUES(moduleId));
})
.catch((e) => {
console.log(e);
});
};
const issueModule = issueDetail?.issue_module;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<span
className={`hidden truncate text-left sm:block ${issueModule ? "" : "text-gray-900"}`}
>
{modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"}
</span>
}
value={issueModule?.module_detail?.id}
onChange={(value: any) => {
value === null
? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "")
: handleModuleChange(modules?.find((m) => m.id === value) as IModule);
}}
disabled={isNotAllowed}
>
{modules ? (
modules.length > 0 ? (
<>
<CustomSelect.Option value={null} className="capitalize">
None
</CustomSelect.Option>
{modules.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
{option.name}
</CustomSelect.Option>
))}
</>
) : (
<div className="text-center">No modules found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
);
};

View file

@ -0,0 +1,95 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Control, Controller, UseFormWatch } from "react-hook-form";
// fetch keys
import { UserIcon } from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
// components
import { ParentIssuesListModal } from "components/issues";
// icons
// types
import { IIssue, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
customDisplay: JSX.Element;
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
};
export const SidebarParentSelect: React.FC<Props> = ({
control,
submitChanges,
issuesList,
customDisplay,
watch,
userAuth,
}) => {
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Parent</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="parent"
render={({ field: { value, onChange } }) => (
<ParentIssuesListModal
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(val) => {
submitChanges({ parent: val });
onChange(val);
}}
issues={issuesList}
title="Select Parent"
value={value}
customDisplay={customDisplay}
/>
)}
/>
<button
type="button"
className={`flex w-full ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
} items-center justify-between gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
onClick={() => setIsParentModalOpen(true)}
disabled={isNotAllowed}
>
{watch("parent") && watch("parent") !== ""
? `${
issues?.results.find((i) => i.id === watch("parent"))?.project_detail?.identifier
}-${issues?.results.find((i) => i.id === watch("parent"))?.sequence_id}`
: "Select issue"}
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,66 @@
import React from "react";
// react-hook-form
import { Control, Controller } from "react-hook-form";
// ui
import { CustomSelect } from "components/ui";
// icons
import { ChartBarIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon } from "components/icons/priority-icon";
// types
import { IIssue, UserAuth } from "types";
// constants
import { PRIORITIES } from "constants/project";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
userAuth: UserAuth;
};
export const SidebarPrioritySelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ChartBarIcon className="h-4 w-4 flex-shrink-0" />
<p>Priority</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="priority"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={`flex items-center gap-2 text-left capitalize ${
value ? "" : "text-gray-900"
}`}
>
{getPriorityIcon(value && value !== "" ? value ?? "" : "None", "text-sm")}
{value && value !== "" ? value : "None"}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ priority: value });
}}
disabled={isNotAllowed}
>
{PRIORITIES.map((option) => (
<CustomSelect.Option key={option} value={option} className="capitalize">
<>
{getPriorityIcon(option, "text-sm")}
{option ?? "None"}
</>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,102 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Control, Controller } from "react-hook-form";
// services
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import stateService from "services/state.service";
// ui
import { Spinner, CustomSelect } from "components/ui";
// icons
// types
import { IIssue, UserAuth } from "types";
// constants
import { STATE_LIST } from "constants/fetch-keys";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
userAuth: UserAuth;
};
export const SidebarStateSelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
<p>State</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="state"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={`flex items-center gap-2 text-left ${value ? "" : "text-gray-900"}`}
>
{value ? (
<>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: states?.find((option) => option.id === value)?.color,
}}
/>
{states?.find((option) => option.id === value)?.name}
</>
) : (
"None"
)}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ state: value });
}}
disabled={isNotAllowed}
>
{states ? (
states.length > 0 ? (
states.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<>
{option.color && (
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: option.color }}
/>
)}
{option.name}
</>
</CustomSelect.Option>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
)}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,484 @@
import React, { useCallback, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm, Controller, UseFormWatch, Control } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color";
// headless ui
import { Popover, Listbox, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// services
import issuesServices from "services/issues.service";
import modulesService from "services/modules.service";
// components
import {
DeleteIssueModal,
SidebarAssigneeSelect,
SidebarBlockedSelect,
SidebarBlockerSelect,
SidebarCycleSelect,
SidebarModuleSelect,
SidebarParentSelect,
SidebarPrioritySelect,
SidebarStateSelect,
} from "components/issues";
// ui
import { Input, Button, Spinner, CustomDatePicker } from "components/ui";
// icons
import {
TagIcon,
ChevronDownIcon,
LinkIcon,
CalendarDaysIcon,
TrashIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import type { ICycle, IIssue, IIssueLabels, IModule, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
issueDetail: IIssue | undefined;
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
color: "#ff0000",
};
export const IssueDetailsSidebar: React.FC<Props> = ({
control,
submitChanges,
issueDetail,
watch: watchIssue,
userAuth,
}) => {
const [createLabelForm, setCreateLabelForm] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { setToastAlert } = useToast();
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const {
register,
handleSubmit,
formState: { isSubmitting },
reset,
watch,
control: controlLabel,
} = useForm({
defaultValues,
});
const handleNewLabel = (formData: any) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
issuesServices
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
.then((res) => {
reset(defaultValues);
issueLabelMutate((prevData) => [...(prevData ?? []), res], false);
submitChanges({ labels_list: [...(issueDetail?.labels ?? []), res.id] });
setCreateLabelForm(false);
});
};
const handleCycleChange = useCallback(
(cycleDetail: ICycle) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
issuesServices
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, {
issues: [issueDetail.id],
})
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
});
},
[workspaceSlug, projectId, issueId, issueDetail]
);
const handleModuleChange = useCallback(
(moduleDetail: IModule) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
modulesService
.addIssuesToModule(workspaceSlug as string, projectId as string, moduleDetail.id, {
issues: [issueDetail.id],
})
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
});
},
[workspaceSlug, projectId, issueId, issueDetail]
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueDetail ?? null}
/>
<div className="w-full divide-y-2 divide-gray-100 sticky top-5">
<div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium">
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
</h4>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/${workspaceSlug}/projects/${issueDetail?.project_detail?.id}/issues/${issueDetail?.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Issue link copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
{!isNotAllowed && (
<button
type="button"
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() => setDeleteIssueModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="divide-y-2 divide-gray-100">
<div className="py-1">
<SidebarStateSelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
<SidebarAssigneeSelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
<SidebarPrioritySelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
</div>
<div className="py-1">
<SidebarParentSelect
control={control}
submitChanges={submitChanges}
issuesList={
issues?.results.filter(
(i) =>
i.id !== issueDetail?.id &&
i.id !== issueDetail?.parent &&
i.parent !== issueDetail?.id
) ?? []
}
customDisplay={
issueDetail?.parent_detail ? (
<button
type="button"
className="flex items-center gap-2 rounded bg-gray-100 px-3 py-2 text-xs"
onClick={() => submitChanges({ parent: null })}
>
{issueDetail.parent_detail?.name}
<XMarkIcon className="h-3 w-3" />
</button>
) : (
<div className="inline-block rounded bg-gray-100 px-3 py-2 text-xs">
No parent selected
</div>
)
}
watch={watchIssue}
userAuth={userAuth}
/>
<SidebarBlockerSelect
submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue}
userAuth={userAuth}
/>
<SidebarBlockedSelect
submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue}
userAuth={userAuth}
/>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>Due date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="target_date"
render={({ field: { value } }) => (
<CustomDatePicker
value={value}
onChange={(val) =>
submitChanges({
target_date: val,
})
}
disabled={isNotAllowed}
/>
)}
/>
</div>
</div>
</div>
<div className="py-1">
<SidebarCycleSelect
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
userAuth={userAuth}
/>
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
userAuth={userAuth}
/>
</div>
</div>
<div className="space-y-3 pt-3">
<div className="flex items-start justify-between">
<div className="flex basis-1/2 items-center gap-x-2 text-sm">
<TagIcon className="h-4 w-4" />
<p>Label</p>
</div>
<div className="basis-1/2">
<div className="flex flex-wrap gap-1">
{watchIssue("labels_list")?.map((label) => {
const singleLabel = issueLabels?.find((l) => l.id === label);
if (!singleLabel) return null;
return (
<span
key={singleLabel.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border px-1 py-0.5 text-xs hover:border-red-500 hover:bg-red-50"
onClick={() => {
const updatedLabels = watchIssue("labels_list")?.filter((l) => l !== label);
submitChanges({
labels_list: updatedLabels,
});
}}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: singleLabel?.color ?? "green" }}
/>
{singleLabel.name}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
</span>
);
})}
<Controller
control={control}
name="labels_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
onChange={(val: any) => submitChanges({ labels_list: val })}
className="flex-shrink-0"
multiple
disabled={isNotAllowed}
>
{({ open }) => (
<>
<Listbox.Label className="sr-only">Label</Listbox.Label>
<div className="relative">
<Listbox.Button
className={`flex ${
isNotAllowed
? "cursor-not-allowed"
: "cursor-pointer hover:bg-gray-100"
} items-center gap-2 rounded-2xl border px-2 py-0.5 text-xs`}
>
Select Label
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{issueLabels ? (
issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} relative flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
}
value={label.id}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: label.color ?? "green" }}
/>
{label.name}
</Listbox.Option>
))
) : (
<div className="text-center">No labels found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
<button
type="button"
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
} items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs`}
onClick={() => setCreateLabelForm((prevData) => !prevData)}
disabled={isNotAllowed}
>
{createLabelForm ? (
<>
<XMarkIcon className="h-3 w-3" /> Cancel
</>
) : (
<>
<PlusIcon className="h-3 w-3" /> New
</>
)}
</button>
</div>
</div>
</div>
{createLabelForm && (
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
<div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`flex items-center gap-1 rounded-md bg-white p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
>
{watch("color") && watch("color") !== "" && (
<span
className="h-5 w-5 rounded"
style={{
backgroundColor: watch("color") ?? "green",
}}
/>
)}
<ChevronDownIcon className="h-3 w-3" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 bottom-8 z-10 mt-1 max-w-xs transform px-2 sm:px-0">
<Controller
name="color"
control={controlLabel}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<Input
id="name"
name="name"
placeholder="Title"
register={register}
validations={{
required: "This is required",
}}
autoComplete="off"
/>
<Button type="submit" theme="danger" onClick={() => setCreateLabelForm(false)}>
<XMarkIcon className="h-4 w-4 text-white" />
</Button>
<Button type="submit" theme="success" disabled={isSubmitting}>
<PlusIcon className="h-4 w-4 text-white" />
</Button>
</form>
)}
</div>
</div>
</>
);
};

View file

@ -0,0 +1,205 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// icons
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IssueResponse } from "types";
// constants
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
parent: IIssue | undefined;
};
export const SubIssuesListModal: React.FC<Props> = ({ isOpen, handleClose, parent }) => {
const [query, setQuery] = useState("");
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const handleCommandPaletteClose = () => {
handleClose();
setQuery("");
};
const addAsSubIssue = (issue: IIssue) => {
if (!workspaceSlug || !projectId) return;
mutate<IIssue[]>(
SUB_ISSUES(parent?.id ?? ""),
(prevData) => {
let newSubIssues = [...(prevData as IIssue[])];
newSubIssues.push(issue);
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
return newSubIssues;
},
false
);
issuesServices
.patchIssue(workspaceSlug as string, projectId as string, issue.id, { parent: parent?.id })
.then((res) => {
mutate(SUB_ISSUES(parent?.id ?? ""));
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((p) => {
if (p.id === res.id)
return {
...p,
...res,
};
return p;
}),
}),
false
);
})
.catch((e) => {
console.log(e);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleCommandPaletteClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<Combobox>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (
(issue.parent === "" || issue.parent === null) && // issue does not have any other parent
issue.id !== parent?.id && // issue is not itself
issue.id !== parent?.parent // issue is not it's parent
)
return (
<Combobox.Option
key={issue.id}
value={{
name: issue.name,
}}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
onClick={() => {
addAsSubIssue(issue);
handleClose();
}}
>
<span
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
{issue.name}
</Combobox.Option>
);
})}
</ul>
</li>
</>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View file

@ -4,8 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react";
import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
// components
import { CustomMenu } from "components/ui";
import { CreateUpdateIssueModal } from "components/issues";
import AddAsSubIssue from "components/project/issues/issue-detail/add-as-sub-issue";
import { CreateUpdateIssueModal, SubIssuesListModal } from "components/issues";
// types
import { IIssue, UserAuth } from "types";
@ -18,7 +17,7 @@ export interface SubIssueListProps {
userAuth: UserAuth;
}
export const SubIssueList: FC<SubIssueListProps> = ({
export const SubIssuesList: FC<SubIssueListProps> = ({
issues = [],
handleSubIssueRemove,
parentIssue,
@ -28,7 +27,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
}) => {
// states
const [isIssueModalActive, setIssueModalActive] = useState(false);
const [isSubIssueModalActive, setSubIssueModalActive] = useState(false);
const [subIssuesListModal, setSubIssuesListModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null);
const openIssueModal = () => {
@ -40,11 +39,11 @@ export const SubIssueList: FC<SubIssueListProps> = ({
};
const openSubIssueModal = () => {
setSubIssueModalActive(true);
setSubIssuesListModal(true);
};
const closeSubIssueModal = () => {
setSubIssueModalActive(false);
setSubIssuesListModal(false);
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@ -56,9 +55,9 @@ export const SubIssueList: FC<SubIssueListProps> = ({
prePopulateData={{ ...preloadedData }}
handleClose={closeIssueModal}
/>
<AddAsSubIssue
isOpen={isSubIssueModalActive}
setIsOpen={setSubIssueModalActive}
<SubIssuesListModal
isOpen={subIssuesListModal}
handleClose={() => setSubIssuesListModal(false)}
parent={parentIssue}
/>
<Disclosure defaultOpen={true}>
@ -88,7 +87,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
setSubIssueModalActive(true);
setSubIssuesListModal(true);
}}
>
Add an existing issue
@ -114,7 +113,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
<a className="flex items-center gap-2 rounded text-xs">
<span
className={`block h-1.5 w-1.5 rounded-full`}
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}

View file

@ -0,0 +1,106 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// services
import projectService from "services/project.service";
// ui
import { AssigneesList, Avatar } from "components/ui";
// types
import { IIssue } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>) => void;
position?: "left" | "right";
isNotAllowed: boolean;
};
export const ViewAssigneeSelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
position = "right",
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
return (
<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 });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList userIds={issue.assignees ?? []} />
</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 z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
position === "left" ? "left-0" : "right-0"
}`}
>
{members?.map((member) => (
<Listbox.Option
key={member.member.id}
className={({ active, selected }) =>
`flex items-center gap-x-1 cursor-pointer select-none p-2 whitespace-nowrap ${
active ? "bg-indigo-50" : ""
} ${
selected || issue.assignees?.includes(member.member.id)
? "bg-indigo-50 font-medium"
: "font-normal"
}`
}
value={member.member.id}
>
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
);
};

View file

@ -0,0 +1,36 @@
// ui
import { CustomDatePicker } from "components/ui";
// helpers
import { findHowManyDaysLeft } from "helpers/date-time.helper";
// types
import { IIssue } from "types";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>) => void;
isNotAllowed: boolean;
};
export const ViewDueDateSelect: React.FC<Props> = ({ issue, partialUpdateIssue, isNotAllowed }) => (
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
disabled={isNotAllowed}
/>
</div>
);

View file

@ -0,0 +1,4 @@
export * from "./assignee";
export * from "./due-date";
export * from "./priority";
export * from "./state";

View file

@ -0,0 +1,88 @@
import React from "react";
// ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { getPriorityIcon } from "components/icons/priority-icon";
// types
import { IIssue } from "types";
// constants
import { PRIORITIES } from "constants/project";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>) => void;
position?: "left" | "right";
isNotAllowed: boolean;
};
export const ViewPrioritySelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
position = "right",
isNotAllowed,
}) => (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<div>
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</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 max-h-48 w-36 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
position === "left" ? "left-0" : "right-0"
}`}
>
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={priority}
>
{getPriorityIcon(priority, "text-sm")}
{priority ?? "None"}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
);

View file

@ -0,0 +1,75 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import stateService from "services/state.service";
// ui
import { CustomSelect } from "components/ui";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IState } from "types";
// fetch-keys
import { STATE_LIST } from "constants/fetch-keys";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>) => void;
position?: "left" | "right";
isNotAllowed: boolean;
};
export const ViewStateSelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
position,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: states } = useSWR<IState[]>(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
return (
<CustomSelect
label={
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: states?.find((s) => s.id === issue.state)?.color,
}}
/>
{addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")}
</>
}
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data });
}}
maxHeight="md"
noChevron
disabled={isNotAllowed}
>
{states?.map((state) => (
<CustomSelect.Option key={state.id} value={state.id}>
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
/>
{addSpaceIfCamelCase(state.name)}
</>
</CustomSelect.Option>
))}
</CustomSelect>
);
};