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:
parent
6966666bf5
commit
d3b73dc32f
177 changed files with 4767 additions and 5404 deletions
261
apps/app/components/issues/activity.tsx
Normal file
261
apps/app/components/issues/activity.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
114
apps/app/components/issues/comment/add-comment.tsx
Normal file
114
apps/app/components/issues/comment/add-comment.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
132
apps/app/components/issues/comment/comment-card.tsx
Normal file
132
apps/app/components/issues/comment/comment-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
apps/app/components/issues/comment/index.ts
Normal file
2
apps/app/components/issues/comment/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./add-comment";
|
||||
export * from "./comment-card";
|
||||
182
apps/app/components/issues/delete-issue-modal.tsx
Normal file
182
apps/app/components/issues/delete-issue-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
127
apps/app/components/issues/my-issues-list-item.tsx
Normal file
127
apps/app/components/issues/my-issues-list-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
229
apps/app/components/issues/parent-issues-list-modal.tsx
Normal file
229
apps/app/components/issues/parent-issues-list-modal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
145
apps/app/components/issues/sidebar-select/assignee.tsx
Normal file
145
apps/app/components/issues/sidebar-select/assignee.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
307
apps/app/components/issues/sidebar-select/blocked.tsx
Normal file
307
apps/app/components/issues/sidebar-select/blocked.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
306
apps/app/components/issues/sidebar-select/blocker.tsx
Normal file
306
apps/app/components/issues/sidebar-select/blocker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
apps/app/components/issues/sidebar-select/cycle.tsx
Normal file
104
apps/app/components/issues/sidebar-select/cycle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
apps/app/components/issues/sidebar-select/index.ts
Normal file
8
apps/app/components/issues/sidebar-select/index.ts
Normal 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";
|
||||
103
apps/app/components/issues/sidebar-select/module.tsx
Normal file
103
apps/app/components/issues/sidebar-select/module.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
95
apps/app/components/issues/sidebar-select/parent.tsx
Normal file
95
apps/app/components/issues/sidebar-select/parent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
66
apps/app/components/issues/sidebar-select/priority.tsx
Normal file
66
apps/app/components/issues/sidebar-select/priority.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
apps/app/components/issues/sidebar-select/state.tsx
Normal file
102
apps/app/components/issues/sidebar-select/state.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
484
apps/app/components/issues/sidebar.tsx
Normal file
484
apps/app/components/issues/sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
205
apps/app/components/issues/sub-issues-list-modal.tsx
Normal file
205
apps/app/components/issues/sub-issues-list-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
}}
|
||||
106
apps/app/components/issues/view-select/assignee.tsx
Normal file
106
apps/app/components/issues/view-select/assignee.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
apps/app/components/issues/view-select/due-date.tsx
Normal file
36
apps/app/components/issues/view-select/due-date.tsx
Normal 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>
|
||||
);
|
||||
4
apps/app/components/issues/view-select/index.ts
Normal file
4
apps/app/components/issues/view-select/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./assignee";
|
||||
export * from "./due-date";
|
||||
export * from "./priority";
|
||||
export * from "./state";
|
||||
88
apps/app/components/issues/view-select/priority.tsx
Normal file
88
apps/app/components/issues/view-select/priority.tsx
Normal 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>
|
||||
);
|
||||
75
apps/app/components/issues/view-select/state.tsx
Normal file
75
apps/app/components/issues/view-select/state.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue