feat: inbox (#1023)
* dev: initialize inbox * dev: inbox and inbox issues models, views and serializers * dev: issue object filter for inbox * dev: filter for search issues * dev: inbox snooze and duplicates * dev: set duplicate to null by default * feat: inbox ui and services * feat: project detail in inbox * style: layout, popover, icons, sidebar * dev: default inbox for project and pending issues count * dev: fix exception when creating default inbox * fix: empty state for inbox * dev: auto issue state updation when rejected or marked duplicate * fix: inbox update status * fix: hydrating chose with old values filters workflow * feat: inbox issue filtering * fix: issue inbox filtering * feat: filter inbox issues * refactor: analytics, border colors * dev: filters and views for inbox * dev: source for inboxissue and update list inbox issue * dev: update list endpoint to house filters and additional data * dev: bridge id for list * dev: remove print logs * dev: update inbox issue workflow * dev: add description_html in issue details * fix: inbox track event auth, chore: inbox issue action authorization * fix: removed unnecessary api calls * style: viewed issues * fix: priority validation * dev: remove print logs * dev: update issue inbox update workflow * chore: added inbox view context * fix: type errors * fix: build errors and warnings * dev: update issue inbox workflow and log all the changes * fix: filters logic, sidebar fields to show * dev: update issue filtering status * chore: update create inbox issue modal, fix: mutation issues * dev: update issue accept workflow * chore: add comment to inbox issues * chore: remove inboxIssueId from url after deleting * dev: update the issue triage workflow * fix: mutation after issue status change * chore: issue details sidebar divider * fix: issue activity for inbox issues * dev: update inbox perrmissions * dev: create new permission layer * chore: auth layer for inbox * chore: show accepting status * chore: show issue status at the top of issue details --------- Co-authored-by: Dakshesh Jain <dakshesh.jain14@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
parent
963ccd808d
commit
e9a0eb87cc
71 changed files with 3712 additions and 737 deletions
|
|
@ -102,7 +102,7 @@ export const CommandPalette: React.FC = () => {
|
|||
const page = pages[pages.length - 1];
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
const { workspaceSlug, projectId, issueId, inboxId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
|
@ -145,7 +145,7 @@ export const CommandPalette: React.FC = () => {
|
|||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId]
|
||||
[workspaceSlug, issueId, projectId, user]
|
||||
);
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
|
|
@ -372,6 +372,7 @@ export const CommandPalette: React.FC = () => {
|
|||
<CreateUpdateIssueModal
|
||||
isOpen={isIssueModalOpen}
|
||||
handleClose={() => setIsIssueModalOpen(false)}
|
||||
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
|
||||
/>
|
||||
<BulkDeleteIssuesModal
|
||||
isOpen={isBulkDeleteIssuesModalOpen}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
|
||||
[
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
orderBy,
|
||||
params,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
const getStyle = (
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
|||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, params]
|
||||
[workspaceSlug, projectId, cycleId, moduleId, viewId, params, user]
|
||||
);
|
||||
|
||||
const handleCopyText = () => {
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export const IssuesView: React.FC<Props> = ({
|
|||
handleDeleteIssue,
|
||||
params,
|
||||
states,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -154,7 +154,18 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
|
||||
[
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
orderBy,
|
||||
params,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
const handleCopyText = () => {
|
||||
|
|
|
|||
24
apps/app/components/icons/inbox-icon.tsx
Normal file
24
apps/app/components/icons/inbox-icon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const InboxIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1.75 15.5C1.41667 15.5 1.125 15.375 0.875 15.125C0.625 14.875 0.5 14.5833 0.5 14.25V1.75C0.5 1.41667 0.625 1.125 0.875 0.875C1.125 0.625 1.41667 0.5 1.75 0.5H14.25C14.5833 0.5 14.875 0.625 15.125 0.875C15.375 1.125 15.5 1.41667 15.5 1.75V14.25C15.5 14.5833 15.375 14.875 15.125 15.125C14.875 15.375 14.5833 15.5 14.25 15.5H1.75ZM1.75 14.25H14.25V11.4167H11.2083C10.8472 11.9722 10.3785 12.3993 9.80208 12.6979C9.22569 12.9965 8.625 13.1458 8 13.1458C7.375 13.1458 6.77431 12.9965 6.19792 12.6979C5.62153 12.3993 5.15278 11.9722 4.79167 11.4167H1.75V14.25ZM8.00035 11.8958C8.48623 11.8958 8.93403 11.7743 9.34375 11.5312C9.75347 11.2882 10.0764 10.9514 10.3125 10.5208C10.4097 10.3819 10.5382 10.2882 10.6979 10.2396C10.8576 10.191 11.0208 10.1667 11.1875 10.1667H14.25V1.75H1.75V10.1667H4.8125C4.97917 10.1667 5.14236 10.191 5.30208 10.2396C5.46181 10.2882 5.59028 10.3819 5.6875 10.5208C5.92361 10.9514 6.24665 11.2882 6.6566 11.5312C7.06656 11.7743 7.51448 11.8958 8.00035 11.8958Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -78,3 +78,5 @@ export * from "./video-file-icon";
|
|||
export * from "./audio-file-icon";
|
||||
export * from "./command-icon";
|
||||
export * from "./color-picker-icon";
|
||||
export * from "./inbox-icon";
|
||||
export * from "./stacked-layers-horizontal-icon";
|
||||
|
|
|
|||
24
apps/app/components/icons/stacked-layers-horizontal-icon.tsx
Normal file
24
apps/app/components/icons/stacked-layers-horizontal-icon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const StackedLayersHorizontalIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
className,
|
||||
color = "#858e96",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 17 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.16567 12.75C3.849 12.75 3.56862 12.6312 3.32452 12.3937C3.08042 12.1562 2.95837 11.8792 2.95837 11.5625V4.81354C2.95837 4.64531 3.0156 4.5043 3.13007 4.39049C3.24454 4.27669 3.38638 4.21979 3.55559 4.21979C3.72481 4.21979 3.86549 4.27669 3.97764 4.39049C4.0898 4.5043 4.14587 4.64531 4.14587 4.81354V11.5625H12.5177C12.686 11.5625 12.827 11.6197 12.9408 11.7342C13.0546 11.8487 13.1115 11.9905 13.1115 12.1597C13.1115 12.3289 13.0546 12.4696 12.9408 12.5818C12.827 12.6939 12.686 12.75 12.5177 12.75H4.16567ZM6.52087 10.375C6.20421 10.375 5.92712 10.2562 5.68962 10.0187C5.45212 9.78125 5.33337 9.50417 5.33337 9.1875V2.0625C5.33337 1.74583 5.45212 1.46875 5.68962 1.23125C5.92712 0.99375 6.20421 0.875 6.52087 0.875H15.2292C15.5459 0.875 15.823 0.99375 16.0605 1.23125C16.298 1.46875 16.4167 1.74583 16.4167 2.0625V9.1875C16.4167 9.50417 16.298 9.78125 16.0605 10.0187C15.823 10.2562 15.5459 10.375 15.2292 10.375H6.52087ZM6.52087 9.1875H15.2292V3.28958H6.52087V9.1875ZM1.77087 15.125C1.45421 15.125 1.17712 15.0062 0.939624 14.7687C0.702124 14.5312 0.583374 14.2542 0.583374 13.9375V7.18854C0.583374 7.02031 0.640605 6.8793 0.755067 6.76549C0.869542 6.65169 1.01138 6.59479 1.18059 6.59479C1.34981 6.59479 1.49049 6.65169 1.60264 6.76549C1.7148 6.8793 1.77087 7.02031 1.77087 7.18854V13.9375H10.123C10.2912 13.9375 10.4322 13.9947 10.546 14.1092C10.6598 14.2237 10.7167 14.3655 10.7167 14.5347C10.7167 14.7039 10.6598 14.8446 10.546 14.9568C10.4322 15.0689 10.2912 15.125 10.123 15.125H1.77087Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
164
apps/app/components/inbox/decline-issue-modal.tsx
Normal file
164
apps/app/components/inbox/decline-issue-modal.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUser from "hooks/use-user";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// types
|
||||
import type { IInboxIssue, ICurrentUserResponse, IInboxIssueDetail } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUES, INBOX_ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data: IInboxIssue | undefined;
|
||||
};
|
||||
|
||||
export const DeclineIssueModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
|
||||
const [isDeclining, setIsDeclining] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
const { params } = useInboxView();
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeclining(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
if (!workspaceSlug || !projectId || !inboxId || !data) return;
|
||||
|
||||
setIsDeclining(true);
|
||||
|
||||
inboxServices
|
||||
.markInboxStatus(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
data.bridge_id,
|
||||
{
|
||||
status: -1,
|
||||
},
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutate<IInboxIssueDetail>(
|
||||
INBOX_ISSUE_DETAILS(inboxId.toString(), data.bridge_id),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
issue_inbox: [{ ...prevData.issue_inbox[0], status: -1 }],
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
mutate<IInboxIssue[]>(
|
||||
INBOX_ISSUES(inboxId.toString(), params),
|
||||
(prevData) =>
|
||||
prevData?.map((i) =>
|
||||
i.bridge_id === data.bridge_id
|
||||
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], status: -1 }] }
|
||||
: i
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue declined successfully.",
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be declined. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsDeclining(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" 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-brand-backdrop bg-opacity-50 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 border border-brand-base bg-brand-base text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex w-full items-center justify-start gap-6">
|
||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-6 w-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center justify-start">
|
||||
<h3 className="text-xl font-medium 2xl:text-2xl">Decline Issue</h3>
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Are you sure you want to decline issue{" "}
|
||||
<span className="break-all font-medium text-brand-base">
|
||||
{data?.project_detail?.identifier}-{data?.sequence_id}
|
||||
</span>
|
||||
{""}? This action cannot be undone.
|
||||
</p>
|
||||
</span>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDecline} loading={isDeclining}>
|
||||
{isDeclining ? "Declining..." : "Decline Issue"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
150
apps/app/components/inbox/delete-issue-modal.tsx
Normal file
150
apps/app/components/inbox/delete-issue-modal.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUser from "hooks/use-user";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data: IInboxIssue | undefined;
|
||||
};
|
||||
|
||||
export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
const { params } = useInboxView();
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeleting(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!workspaceSlug || !projectId || !inboxId || !data) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
inboxServices
|
||||
.deleteInboxIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
data.bridge_id.toString(),
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutate<IInboxIssue[]>(
|
||||
INBOX_ISSUES(inboxId.toString(), params),
|
||||
(prevData) => (prevData ?? []).filter((i) => i.id !== data.id),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue deleted successfully.",
|
||||
});
|
||||
|
||||
// remove inboxIssueId from the url
|
||||
router.push({
|
||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
||||
});
|
||||
|
||||
onClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be deleted. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsDeleting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" 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-brand-backdrop bg-opacity-50 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 border border-brand-base bg-brand-base text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex w-full items-center justify-start gap-6">
|
||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-6 w-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center justify-start">
|
||||
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Issue</h3>
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Are you sure you want to delete issue{" "}
|
||||
<span className="break-all font-medium text-brand-base">
|
||||
{data?.project_detail?.identifier}-{data?.sequence_id}
|
||||
</span>
|
||||
{""}? This action cannot be undone.
|
||||
</p>
|
||||
</span>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDelete} loading={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Delete Issue"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
63
apps/app/components/inbox/filters-dropdown.tsx
Normal file
63
apps/app/components/inbox/filters-dropdown.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// ui
|
||||
import { MultiLevelDropdown } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
// types
|
||||
import { IInboxFilterOptions } from "types";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/project";
|
||||
import { STATUS } from "constants/inbox";
|
||||
|
||||
type Props = {
|
||||
filters: Partial<IInboxFilterOptions>;
|
||||
onSelect: (option: any) => void;
|
||||
direction?: "left" | "right";
|
||||
height?: "sm" | "md" | "rg" | "lg";
|
||||
};
|
||||
|
||||
export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction, height }) => (
|
||||
<MultiLevelDropdown
|
||||
label="Filters"
|
||||
onSelect={onSelect}
|
||||
direction={direction}
|
||||
height={height}
|
||||
options={[
|
||||
{
|
||||
id: "priority",
|
||||
label: "Priority",
|
||||
value: PRIORITIES,
|
||||
children: [
|
||||
...PRIORITIES.map((priority) => ({
|
||||
id: priority ?? "none",
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getPriorityIcon(priority)} {priority ?? "None"}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "priority",
|
||||
value: priority,
|
||||
},
|
||||
selected: filters?.priority?.includes(priority ?? "none"),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inbox_status",
|
||||
label: "Status",
|
||||
value: Object.values(STATUS),
|
||||
children: [
|
||||
...Object.keys(STATUS).map((status) => ({
|
||||
id: status,
|
||||
label: status,
|
||||
value: {
|
||||
key: "inbox_status",
|
||||
value: STATUS[status],
|
||||
},
|
||||
selected: filters?.inbox_status?.includes(STATUS[status]),
|
||||
})),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
226
apps/app/components/inbox/inbox-action-headers.tsx
Normal file
226
apps/app/components/inbox/inbox-action-headers.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-datepicker
|
||||
import DatePicker from "react-datepicker";
|
||||
// headless ui
|
||||
import { Popover } from "@headlessui/react";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// components
|
||||
import { FiltersDropdown } from "components/inbox";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { InboxIcon, StackedLayersHorizontalIcon } from "components/icons";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issueCount: number;
|
||||
currentIssueIndex: number;
|
||||
issue?: IInboxIssue;
|
||||
onAccept: () => Promise<void>;
|
||||
onDecline: () => void;
|
||||
onMarkAsDuplicate: () => void;
|
||||
onSnooze: (date: Date | string) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export const InboxActionHeader: React.FC<Props> = (props) => {
|
||||
const {
|
||||
issueCount,
|
||||
currentIssueIndex,
|
||||
onAccept,
|
||||
onDecline,
|
||||
onMarkAsDuplicate,
|
||||
onSnooze,
|
||||
onDelete,
|
||||
issue,
|
||||
} = props;
|
||||
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
const [date, setDate] = useState(new Date());
|
||||
|
||||
const router = useRouter();
|
||||
const { inboxIssueId } = router.query;
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { filters, setFilters, filtersLength } = useInboxView();
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const handleAcceptIssue = () => {
|
||||
setIsAccepting(true);
|
||||
|
||||
onAccept().finally(() => setIsAccepting(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.issue_inbox[0].snoozed_till) return;
|
||||
|
||||
setDate(new Date(issue.issue_inbox[0].snoozed_till));
|
||||
}, [issue]);
|
||||
|
||||
const issueStatus = issue?.issue_inbox[0].status;
|
||||
const isAllowed = memberRole.isMember || memberRole.isOwner;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 border-b border-brand-base divide-x divide-brand-base">
|
||||
<div className="col-span-1 flex justify-between p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<InboxIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h3 className="font-medium">Inbox</h3>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<FiltersDropdown
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
|
||||
const valueExists = (filters[key] as any[])?.includes(option.value);
|
||||
|
||||
if (valueExists) {
|
||||
setFilters({
|
||||
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
||||
(val) => val !== option.value
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setFilters({
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
});
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
height="rg"
|
||||
/>
|
||||
{filtersLength > 0 && (
|
||||
<div className="absolute -top-2 -right-2 h-4 w-4 text-[0.65rem] grid place-items-center rounded-full text-brand-base bg-brand-surface-2 border border-brand-base z-10">
|
||||
<span>{filtersLength}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{inboxIssueId && (
|
||||
<div className="flex justify-between items-center gap-4 px-4 col-span-3">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<ChevronUpIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="text-sm">
|
||||
{currentIssueIndex + 1}/{issueCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{isAllowed && (
|
||||
<div className={`flex gap-3 flex-wrap ${issueStatus !== -2 ? "opacity-70" : ""}`}>
|
||||
<Popover className="relative">
|
||||
<Popover.Button as="button" type="button" disabled={issueStatus !== -2}>
|
||||
<SecondaryButton
|
||||
className={`flex gap-x-1 items-center ${
|
||||
issueStatus !== -2 ? "cursor-not-allowed" : ""
|
||||
}`}
|
||||
size="sm"
|
||||
>
|
||||
<ClockIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<span>Snooze</span>
|
||||
</SecondaryButton>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="w-80 p-2 absolute right-0 z-10 mt-2 rounded-md border border-brand-base bg-brand-surface-2 shadow-lg">
|
||||
{({ close }) => (
|
||||
<div className="w-full h-full flex flex-col gap-y-1">
|
||||
<DatePicker
|
||||
selected={date ? new Date(date) : null}
|
||||
onChange={(val) => {
|
||||
if (!val) return;
|
||||
setDate(val);
|
||||
}}
|
||||
dateFormat="dd-MM-yyyy"
|
||||
inline
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
close();
|
||||
onSnooze(date);
|
||||
}}
|
||||
>
|
||||
Snooze
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={onMarkAsDuplicate}
|
||||
disabled={issueStatus !== -2}
|
||||
>
|
||||
<StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<span>Mark as duplicate</span>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={handleAcceptIssue}
|
||||
disabled={issueStatus !== -2}
|
||||
loading={isAccepting}
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
<span>{isAccepting ? "Accepting..." : "Accept"}</span>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={onDecline}
|
||||
disabled={issueStatus !== -2}
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Decline</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
{(isAllowed || user?.id === issue?.created_by) && (
|
||||
<div className="flex-shrink-0">
|
||||
<SecondaryButton size="sm" className="flex gap-2 items-center" onClick={onDelete}>
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Delete</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
apps/app/components/inbox/inbox-issue-card.tsx
Normal file
117
apps/app/components/inbox/inbox-issue-card.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||
import { CalendarDaysIcon, ClockIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IInboxIssue;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export const InboxIssueCard: React.FC<Props> = (props) => {
|
||||
const { issue, active } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const issueStatus = issue.issue_inbox[0].status;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.bridge_id}`}
|
||||
>
|
||||
<a>
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
issueStatus === -2
|
||||
? "Pending issue"
|
||||
: issueStatus === -1
|
||||
? "Declined issue"
|
||||
: issueStatus === 0
|
||||
? "Snoozed issue"
|
||||
: issueStatus === 1
|
||||
? "Accepted issue"
|
||||
: "Marked as duplicate"
|
||||
}
|
||||
position="right"
|
||||
>
|
||||
<div
|
||||
id={issue.id}
|
||||
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-brand-base hover:bg-brand-accent hover:bg-opacity-10 ${
|
||||
active ? "bg-brand-accent bg-opacity-5" : " "
|
||||
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="flex-shrink-0 text-brand-secondary text-xs">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</p>
|
||||
<h5 className="truncate text-sm">{issue.name}</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(issue.state_detail?.name ?? "Triage")}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
|
||||
{getStateGroupIcon(
|
||||
issue.state_detail?.group ?? "backlog",
|
||||
"14",
|
||||
"14",
|
||||
issue.state_detail?.color
|
||||
)}
|
||||
{issue.state_detail?.name ?? "Triage"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
|
||||
<div
|
||||
className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${
|
||||
issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: issue.priority === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "border-brand-base"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltipHeading="Created at"
|
||||
tooltipContent={`${renderShortNumericDateFormat(issue.created_at ?? "")}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
|
||||
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||
<span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{issue.issue_inbox[0].snoozed_till && (
|
||||
<div className="text-xs flex items-center gap-1 text-brand-accent">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Snoozed till {renderShortNumericDateFormat(issue.issue_inbox[0].snoozed_till)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
259
apps/app/components/inbox/inbox-main-content.tsx
Normal file
259
apps/app/components/inbox/inbox-main-content.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// components
|
||||
import {
|
||||
AddComment,
|
||||
IssueActivitySection,
|
||||
IssueDescriptionForm,
|
||||
IssueDetailsSidebar,
|
||||
} from "components/issues";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { IInboxIssue, IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUES, INBOX_ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
description_html: "",
|
||||
estimate_point: null,
|
||||
assignees_list: [],
|
||||
priority: "low",
|
||||
target_date: new Date().toString(),
|
||||
labels_list: [],
|
||||
};
|
||||
|
||||
export const InboxMainContent: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { params } = useInboxView();
|
||||
|
||||
const { reset, control, watch } = useForm<IIssue>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { data: issueDetails, mutate: mutateIssueDetails } = useSWR(
|
||||
workspaceSlug && projectId && inboxId && inboxIssueId
|
||||
? INBOX_ISSUE_DETAILS(inboxId.toString(), inboxIssueId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxId && inboxIssueId
|
||||
? () =>
|
||||
inboxServices.getInboxIssueById(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
inboxIssueId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issueDetails || !inboxIssueId) return;
|
||||
|
||||
reset({
|
||||
...issueDetails,
|
||||
assignees_list:
|
||||
issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id),
|
||||
labels_list: issueDetails.labels_list ?? issueDetails.labels,
|
||||
});
|
||||
}, [issueDetails, reset, inboxIssueId]);
|
||||
|
||||
const submitChanges = useCallback(
|
||||
async (formData: Partial<IInboxIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return;
|
||||
|
||||
mutateIssueDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
}, false);
|
||||
mutate<IInboxIssue[]>(
|
||||
INBOX_ISSUES(inboxId.toString(), params),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) => {
|
||||
if (i.bridge_id === inboxIssueId) {
|
||||
return {
|
||||
...i,
|
||||
...formData,
|
||||
};
|
||||
}
|
||||
|
||||
return i;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { issue: { ...formData } };
|
||||
|
||||
await inboxServices
|
||||
.patchInboxIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
issueDetails.issue_inbox[0].id,
|
||||
payload,
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutateIssueDetails();
|
||||
mutate(INBOX_ISSUES(inboxId.toString(), params));
|
||||
});
|
||||
},
|
||||
[
|
||||
workspaceSlug,
|
||||
inboxIssueId,
|
||||
projectId,
|
||||
mutateIssueDetails,
|
||||
inboxId,
|
||||
user,
|
||||
issueDetails,
|
||||
params,
|
||||
]
|
||||
);
|
||||
|
||||
const issueStatus = issueDetails?.issue_inbox[0].status;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueDetails ? (
|
||||
<div className="flex h-full overflow-auto divide-x">
|
||||
<div className="basis-2/3 h-full overflow-auto p-5 space-y-3">
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 text-sm border rounded-md ${
|
||||
issueStatus === -2
|
||||
? "text-orange-500 border-orange-500 bg-orange-500/10"
|
||||
: issueStatus === -1
|
||||
? "text-red-500 border-red-500 bg-red-500/10"
|
||||
: issueStatus === 0
|
||||
? "text-blue-500 border-blue-500 bg-blue-500/10"
|
||||
: issueStatus === 1
|
||||
? "text-green-500 border-green-500 bg-green-500/10"
|
||||
: issueStatus === 2
|
||||
? "text-yellow-500 border-yellow-500 bg-yellow-500/10"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{issueStatus === -2 ? (
|
||||
<>
|
||||
<ExclamationTriangleIcon className="h-5 w-5" />
|
||||
<p>This issue is still pending.</p>
|
||||
</>
|
||||
) : issueStatus === -1 ? (
|
||||
<>
|
||||
<XCircleIcon className="h-5 w-5" />
|
||||
<p>This issue has been declined.</p>
|
||||
</>
|
||||
) : issueStatus === 0 ? (
|
||||
<>
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
<p>
|
||||
This issue has been snoozed till{" "}
|
||||
{renderShortNumericDateFormat(issueDetails.issue_inbox[0].snoozed_till ?? "")}.
|
||||
</p>
|
||||
</>
|
||||
) : issueStatus === 1 ? (
|
||||
<>
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
<p>This issue has been accepted.</p>
|
||||
</>
|
||||
) : issueStatus === 2 ? (
|
||||
<>
|
||||
<DocumentDuplicateIcon className="h-5 w-5" />
|
||||
<p className="flex items-center gap-1">
|
||||
This issue has been marked as a duplicate of
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueDetails.issue_inbox[0].duplicate_to}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline flex items-center gap-2"
|
||||
>
|
||||
this issue <ArrowTopRightOnSquareIcon className="h-3 w-3" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<IssueDescriptionForm
|
||||
issue={{
|
||||
name: issueDetails.name,
|
||||
description: issueDetails.description,
|
||||
description_html: issueDetails.description_html,
|
||||
}}
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={
|
||||
memberRole.isMember || memberRole.isOwner || user?.id === issueDetails.created_by
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg text-brand-base">Comments/Activity</h3>
|
||||
<IssueActivitySection issueId={issueDetails.id} user={user} />
|
||||
<AddComment issueId={issueDetails.id} user={user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="basis-1/3 space-y-5 border-brand-base p-5">
|
||||
<IssueDetailsSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
submitChanges={submitChanges}
|
||||
watch={watch}
|
||||
fieldsToShow={["assignee", "priority", "estimate", "dueDate", "label", "state"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
<div className="basis-2/3 space-y-2">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="40%" />
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
8
apps/app/components/inbox/index.ts
Normal file
8
apps/app/components/inbox/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./decline-issue-modal";
|
||||
export * from "./delete-issue-modal";
|
||||
export * from "./filters-dropdown";
|
||||
export * from "./inbox-action-headers";
|
||||
export * from "./inbox-issue-card";
|
||||
export * from "./inbox-main-content";
|
||||
export * from "./issues-list-sidebar";
|
||||
export * from "./select-duplicate";
|
||||
44
apps/app/components/inbox/issues-list-sidebar.tsx
Normal file
44
apps/app/components/inbox/issues-list-sidebar.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
// components
|
||||
import { InboxIssueCard } from "components/inbox";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
|
||||
export const IssuesListSidebar = () => {
|
||||
const router = useRouter();
|
||||
const { inboxIssueId } = router.query;
|
||||
|
||||
const { issues: inboxIssues } = useInboxView();
|
||||
|
||||
return (
|
||||
<>
|
||||
{inboxIssues ? (
|
||||
inboxIssues.length > 0 ? (
|
||||
<div className="divide-y divide-brand-base overflow-auto h-full pb-10">
|
||||
{inboxIssues.map((issue) => (
|
||||
<InboxIssueCard
|
||||
key={issue.id}
|
||||
active={issue.bridge_id === inboxIssueId}
|
||||
issue={issue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4 grid place-items-center text-center text-sm text-brand-secondary">
|
||||
No issues found for the selected filters. Try changing the filters.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="p-4 space-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
193
apps/app/components/inbox/select-duplicate.tsx
Normal file
193
apps/app/components/inbox/select-duplicate.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
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 { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
value?: string | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (issueId: string) => void;
|
||||
};
|
||||
|
||||
export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, onClose, onSubmit, value } = props;
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState<string>("");
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = 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)
|
||||
.then((res) => res.filter((issue) => issue.id !== issueId))
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedItem("");
|
||||
return;
|
||||
} else setSelectedItem(value);
|
||||
}, [value]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedItem || selectedItem.length === 0)
|
||||
return setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
});
|
||||
onSubmit(selectedItem);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const filteredIssues =
|
||||
(query === "" ? issues : issues?.filter((issue) => issue.name.includes(query))) ?? [];
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<div className="flex flex-wrap items-start py-2">
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<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-brand-backdrop bg-opacity-50 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 rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
|
||||
<Combobox
|
||||
value={selectedItem}
|
||||
onChange={(value) => {
|
||||
setSelectedItem(value);
|
||||
}}
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base 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-brand-base overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-brand-base">
|
||||
Select issue
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-brand-base">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="div"
|
||||
value={issue.id}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${
|
||||
active || selected ? "bg-brand-surface-2 text-brand-base" : ""
|
||||
} `
|
||||
}
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||
{
|
||||
issues?.find((i) => i.id === issue.id)?.project_detail
|
||||
?.identifier
|
||||
}
|
||||
-{issue.sequence_id}
|
||||
</span>
|
||||
<span className="text-brand-muted-1">{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-sm text-brand-secondary">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-brand-surface-2 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">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleSubmit}>Mark as original</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,6 +4,14 @@ import { useRouter } from "next/router";
|
|||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
// components
|
||||
import { CommentCard } from "components/issues/comment";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
|
|
@ -17,20 +25,13 @@ import {
|
|||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// components
|
||||
import { CommentCard } from "components/issues/comment";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
|
|
@ -60,7 +61,7 @@ const activityDetails: {
|
|||
},
|
||||
estimate_point: {
|
||||
message: "set the estimate point to",
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-gray-500" aria-hidden="true" />,
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
labels: {
|
||||
icon: <TagIcon height="12" width="12" color="#6b7280" />,
|
||||
|
|
@ -99,25 +100,26 @@ const activityDetails: {
|
|||
},
|
||||
estimate: {
|
||||
message: "updated the estimate",
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-gray-500" aria-hidden="true" />,
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
link: {
|
||||
message: "updated the link",
|
||||
icon: <LinkIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <LinkIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
attachment: {
|
||||
message: "updated the attachment",
|
||||
icon: <PaperClipIcon className="h-3 w-3 text-gray-500 " aria-hidden="true" />,
|
||||
icon: <PaperClipIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const IssueActivitySection: React.FC<Props> = ({ user }) => {
|
||||
export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { isEstimateActive, estimatePoints } = useEstimateOption();
|
||||
|
||||
|
|
|
|||
|
|
@ -41,10 +41,11 @@ const defaultValues: Partial<IIssueComment> = {
|
|||
};
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const AddComment: React.FC<Props> = ({ user }) => {
|
||||
export const AddComment: React.FC<Props> = ({ issueId, user }) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
|
|
@ -56,7 +57,7 @@ export const AddComment: React.FC<Props> = ({ user }) => {
|
|||
const editorRef = React.useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
|||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssue } from "types";
|
||||
import type { IIssue, ICurrentUserResponse } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import dynamic from "next/dynamic";
|
|||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// hooks
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
// components
|
||||
|
|
@ -28,16 +26,23 @@ export interface IssueDescriptionFormValues {
|
|||
}
|
||||
|
||||
export interface IssueDetailsProps {
|
||||
issue: IIssue;
|
||||
issue: {
|
||||
name: string;
|
||||
description: string;
|
||||
description_html: string;
|
||||
};
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
||||
isAllowed: boolean;
|
||||
}
|
||||
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormSubmit }) => {
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
issue,
|
||||
handleFormSubmit,
|
||||
isAllowed,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const {
|
||||
|
|
@ -78,8 +83,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
|||
});
|
||||
}, [issue, reset]);
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
|
|
@ -106,6 +109,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
|||
overflow-hidden rounded border-none bg-transparent
|
||||
px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-theme"
|
||||
role="textbox"
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
{characterLimit && (
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 z-[2] rounded bg-brand-surface-2 p-1 text-xs">
|
||||
|
|
@ -156,7 +160,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
|||
});
|
||||
}}
|
||||
placeholder="Description"
|
||||
editable={!isNotAllowed}
|
||||
editable={isAllowed}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,19 @@ export interface IssueFormProps {
|
|||
handleClose: () => void;
|
||||
status: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
fieldsToShow: (
|
||||
| "project"
|
||||
| "name"
|
||||
| "description"
|
||||
| "state"
|
||||
| "priority"
|
||||
| "assignee"
|
||||
| "label"
|
||||
| "dueDate"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "all"
|
||||
)[];
|
||||
}
|
||||
|
||||
export const IssueForm: FC<IssueFormProps> = ({
|
||||
|
|
@ -105,6 +118,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||
handleClose,
|
||||
status,
|
||||
user,
|
||||
fieldsToShow,
|
||||
}) => {
|
||||
// states
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
|
||||
|
|
@ -252,243 +266,271 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||
<form onSubmit={handleSubmit(handleCreateUpdateIssue)}>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueProjectSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
setActiveProject={setActiveProject}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueProjectSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
setActiveProject={setActiveProject}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-xl font-semibold leading-6 text-brand-base">
|
||||
{status ? "Update" : "Create"} Issue
|
||||
</h3>
|
||||
</div>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-brand-surface-2 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issues.find((i) => i.id === watch("parent"))?.state_detail
|
||||
.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-brand-secondary">
|
||||
{/* {projects?.find((p) => p.id === projectId)?.identifier}- */}
|
||||
{issues.find((i) => i.id === watch("parent"))?.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)}
|
||||
</span>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => setValue("parent", null)}
|
||||
/>
|
||||
{watch("parent") &&
|
||||
watch("parent") !== "" &&
|
||||
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-brand-surface-2 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issues.find((i) => i.id === watch("parent"))?.state_detail
|
||||
.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-brand-secondary">
|
||||
{/* {projects?.find((p) => p.id === projectId)?.identifier}- */}
|
||||
{issues.find((i) => i.id === watch("parent"))?.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)}
|
||||
</span>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => setValue("parent", null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
onChange={handleTitleChange}
|
||||
className="resize-none text-xl"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{mostSimilarIssue && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue.id}`}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && (
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
onChange={handleTitleChange}
|
||||
className="resize-none text-xl"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{mostSimilarIssue && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue.id}`}
|
||||
>
|
||||
<a target="_blank" type="button" className="inline text-left">
|
||||
<span>Did you mean </span>
|
||||
<span className="italic">
|
||||
{mostSimilarIssue.project_detail.identifier}-
|
||||
{mostSimilarIssue.sequence_id}: {mostSimilarIssue.name}{" "}
|
||||
</span>
|
||||
?
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-brand-accent"
|
||||
onClick={() => {
|
||||
setMostSimilarIssue(undefined);
|
||||
}}
|
||||
>
|
||||
<a target="_blank" type="button" className="inline text-left">
|
||||
<span>Did you mean </span>
|
||||
<span className="italic">
|
||||
{mostSimilarIssue.project_detail.identifier}-
|
||||
{mostSimilarIssue.sequence_id}: {mostSimilarIssue.name}{" "}
|
||||
</span>
|
||||
?
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
|
||||
<div className="relative">
|
||||
<div className="-mb-2 flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response..."
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-brand-accent"
|
||||
onClick={() => {
|
||||
setMostSimilarIssue(undefined);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
No
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="-mb-2 flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response..."
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description"
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GptAssistantModal
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
setGptAssistantModal(false);
|
||||
// this is done so that the title do not reset after gpt popover closed
|
||||
reset(getValues());
|
||||
}}
|
||||
inset="top-2 left-0"
|
||||
content=""
|
||||
htmlContent={watch("description_html")}
|
||||
onResponse={(response) => {
|
||||
handleAiAssistance(response);
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description"
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GptAssistantModal
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
setGptAssistantModal(false);
|
||||
// this is done so that the title do not reset after gpt popover closed
|
||||
reset(getValues());
|
||||
}}
|
||||
inset="top-2 left-0"
|
||||
content=""
|
||||
htmlContent={watch("description_html")}
|
||||
onResponse={(response) => {
|
||||
handleAiAssistance(response);
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueStateSelect
|
||||
setIsOpen={setStateModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssuePrioritySelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueDateSelect value={value} onChange={onChange} />
|
||||
<IssueStateSelect
|
||||
setIsOpen={setStateModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueEstimateSelect value={value} onChange={onChange} />
|
||||
<IssuePrioritySelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IssueParentSelect
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
setIsOpen={setParentIssueListModalOpen}
|
||||
issues={issues ?? []}
|
||||
/>
|
||||
<CustomMenu ellipsis>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect
|
||||
projectId={projectId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueDateSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueEstimateSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<IssueParentSelect
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
setIsOpen={setParentIssueListModalOpen}
|
||||
issues={issues ?? []}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<CustomMenu ellipsis>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Change parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setValue("parent", null)}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Change parent issue
|
||||
Select Parent Issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setValue("parent", null)}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Select Parent Issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||
import projectService from "services/project.service";
|
||||
import modulesService from "services/modules.service";
|
||||
import issuesService from "services/issues.service";
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
// components
|
||||
import { IssueForm } from "components/issues";
|
||||
// types
|
||||
|
|
@ -32,7 +34,10 @@ import {
|
|||
CYCLE_DETAILS,
|
||||
MODULE_DETAILS,
|
||||
VIEW_ISSUES,
|
||||
INBOX_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
// constants
|
||||
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -40,6 +45,19 @@ export interface IssuesModalProps {
|
|||
data?: IIssue | null;
|
||||
prePopulateData?: Partial<IIssue>;
|
||||
isUpdatingSingleIssue?: boolean;
|
||||
fieldsToShow?: (
|
||||
| "project"
|
||||
| "name"
|
||||
| "description"
|
||||
| "state"
|
||||
| "priority"
|
||||
| "assignee"
|
||||
| "label"
|
||||
| "dueDate"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "all"
|
||||
)[];
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
|
@ -48,17 +66,19 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||
data,
|
||||
prePopulateData,
|
||||
isUpdatingSingleIssue = false,
|
||||
fieldsToShow = ["all"],
|
||||
}) => {
|
||||
// states
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query;
|
||||
|
||||
const { issueView, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { order_by, group_by, ...viewGanttParams } = params;
|
||||
const { params: inboxParams } = useInboxView();
|
||||
|
||||
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
||||
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
|
||||
|
|
@ -140,6 +160,45 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const addIssueToInbox = async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !inboxId) return;
|
||||
|
||||
const payload = {
|
||||
issue: {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
description_html: formData.description_html,
|
||||
priority: formData.priority,
|
||||
},
|
||||
source: INBOX_ISSUE_SOURCE,
|
||||
};
|
||||
|
||||
await inboxServices
|
||||
.createInboxIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
payload,
|
||||
user
|
||||
)
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
mutate(INBOX_ISSUES(inboxId.toString(), inboxParams));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const calendarFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
|
||||
: moduleId
|
||||
|
|
@ -157,37 +216,40 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "");
|
||||
|
||||
const createIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!workspaceSlug) return;
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await issuesService
|
||||
.createIssues(workspaceSlug as string, activeProject ?? "", payload, user)
|
||||
.then(async (res) => {
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module);
|
||||
if (inboxId) await addIssueToInbox(payload);
|
||||
else
|
||||
await issuesService
|
||||
.createIssues(workspaceSlug as string, activeProject ?? "", payload, user)
|
||||
.then(async (res) => {
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "")
|
||||
await addIssueToModule(res.id, payload.module);
|
||||
|
||||
if (issueView === "calendar") mutate(calendarFetchKey);
|
||||
if (issueView === "gantt_chart") mutate(ganttFetchKey);
|
||||
if (issueView === "calendar") mutate(calendarFetchKey);
|
||||
if (issueView === "gantt_chart") mutate(ganttFetchKey);
|
||||
|
||||
if (!createMore) handleClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE);
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE);
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
if (!createMore) handleClose();
|
||||
};
|
||||
|
||||
const updateIssue = async (payload: Partial<IIssue>) => {
|
||||
|
|
@ -226,8 +288,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
assignees_list: formData.assignees,
|
||||
labels_list: formData.labels,
|
||||
assignees_list: formData.assignees ?? [],
|
||||
labels_list: formData.labels ?? [],
|
||||
description: formData.description ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
};
|
||||
|
|
@ -274,6 +336,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||
setActiveProject={setActiveProject}
|
||||
status={data ? true : false}
|
||||
user={user}
|
||||
fieldsToShow={fieldsToShow}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
|||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId]
|
||||
[workspaceSlug, projectId, user]
|
||||
);
|
||||
|
||||
const handleCopyText = () => {
|
||||
|
|
|
|||
|
|
@ -53,10 +53,26 @@ import type { ICycle, IIssue, IIssueLabels, IIssueLink, IModule } from "types";
|
|||
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
control: any;
|
||||
submitChanges: (formData: any) => void;
|
||||
issueDetail: IIssue | undefined;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
fieldsToShow?: (
|
||||
| "state"
|
||||
| "assignee"
|
||||
| "priority"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "blocker"
|
||||
| "blocked"
|
||||
| "dueDate"
|
||||
| "cycle"
|
||||
| "module"
|
||||
| "label"
|
||||
| "link"
|
||||
| "delete"
|
||||
| "all"
|
||||
)[];
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
|
|
@ -69,6 +85,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||
submitChanges,
|
||||
issueDetail,
|
||||
watch: watchIssue,
|
||||
fieldsToShow = ["all"],
|
||||
}) => {
|
||||
const [createLabelForm, setCreateLabelForm] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
|
|
@ -140,7 +157,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueDetail]
|
||||
[workspaceSlug, projectId, issueId, issueDetail, user]
|
||||
);
|
||||
|
||||
const handleModuleChange = useCallback(
|
||||
|
|
@ -161,7 +178,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueDetail]
|
||||
[workspaceSlug, projectId, issueId, issueDetail, user]
|
||||
);
|
||||
|
||||
const handleCreateLink = async (formData: IIssueLink) => {
|
||||
|
|
@ -230,6 +247,25 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||
reset();
|
||||
}, [createLabelForm, reset]);
|
||||
|
||||
const showFirstSection =
|
||||
fieldsToShow.includes("all") ||
|
||||
fieldsToShow.includes("state") ||
|
||||
fieldsToShow.includes("assignee") ||
|
||||
fieldsToShow.includes("priority") ||
|
||||
fieldsToShow.includes("estimate");
|
||||
|
||||
const showSecondSection =
|
||||
fieldsToShow.includes("all") ||
|
||||
fieldsToShow.includes("parent") ||
|
||||
fieldsToShow.includes("blocker") ||
|
||||
fieldsToShow.includes("blocked") ||
|
||||
fieldsToShow.includes("dueDate");
|
||||
|
||||
const showThirdSection =
|
||||
fieldsToShow.includes("all") ||
|
||||
fieldsToShow.includes("cycle") ||
|
||||
fieldsToShow.includes("module");
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
|
||||
return (
|
||||
|
|
@ -258,7 +294,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||
>
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!isNotAllowed && (
|
||||
{!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
|
||||
|
|
@ -270,402 +306,434 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="divide-y-2 divide-brand-base">
|
||||
<div className="py-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarStateSelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees_list"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarAssigneeSelect
|
||||
value={value}
|
||||
onChange={(val: string[]) => submitChanges({ assignees_list: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarPrioritySelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ priority: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarEstimateSelect
|
||||
value={value}
|
||||
onChange={(val: number | null) => submitChanges({ estimate_point: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<SidebarParentSelect
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={
|
||||
issues?.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-brand-surface-2 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-brand-surface-1 px-3 py-2 text-xs">
|
||||
No parent selected
|
||||
</div>
|
||||
)
|
||||
}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<SidebarBlockerSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<SidebarBlockedSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2">
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Due date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
{showFirstSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
placeholder="Due date"
|
||||
<SidebarStateSelect
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-brand-surface-1"
|
||||
disabled={isNotAllowed}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<SidebarCycleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleCycleChange={handleCycleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<SidebarModuleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleModuleChange={handleModuleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex basis-1/2 items-center gap-x-2 text-sm text-brand-secondary">
|
||||
<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((labelId) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
if (label)
|
||||
return (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1 py-0.5 text-xs hover:border-red-500/20 hover:bg-red-500/20"
|
||||
onClick={() => {
|
||||
const updatedLabels = watchIssue("labels_list")?.filter(
|
||||
(l) => l !== labelId
|
||||
);
|
||||
submitChanges({
|
||||
labels_list: updatedLabels,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels_list"
|
||||
name="assignees_list"
|
||||
render={({ field: { value } }) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
<SidebarAssigneeSelect
|
||||
value={value}
|
||||
onChange={(val: any) => submitChanges({ labels_list: val })}
|
||||
className="flex-shrink-0"
|
||||
multiple
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-2 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
>
|
||||
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-brand-surface-2 py-1 text-xs shadow-lg border border-brand-base focus:outline-none">
|
||||
<div className="py-1">
|
||||
{issueLabels ? (
|
||||
issueLabels.length > 0 ? (
|
||||
issueLabels.map((label: IIssueLabels) => {
|
||||
const children = issueLabels?.filter(
|
||||
(l) => l.parent === label.id
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-brand-surface-1" : ""
|
||||
} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== ""
|
||||
? label.color
|
||||
: "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</Listbox.Option>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="border-y border-brand-base bg-brand-surface-1">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-brand-base">
|
||||
<RectangleGroupIcon className="h-3 w-3" />
|
||||
{label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Listbox.Option
|
||||
key={child.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-brand-base" : ""} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center">No labels found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
onChange={(val: string[]) => submitChanges({ assignees_list: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
<XMarkIcon className="h-3 w-3" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" /> New
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{createLabelForm && (
|
||||
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
|
||||
<div>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`flex items-center gap-1 rounded-md bg-brand-surface-2 p-1 outline-none focus:ring-2 focus:ring-brand-accent`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarPrioritySelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ priority: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Title"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "This is required",
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid place-items-center rounded bg-red-500 p-2.5"
|
||||
onClick={() => setCreateLabelForm(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid place-items-center rounded bg-green-500 p-2.5"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</form>
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarEstimateSelect
|
||||
value={value}
|
||||
onChange={(val: number | null) => submitChanges({ estimate_point: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showSecondSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<SidebarParentSelect
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={
|
||||
issues?.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-brand-surface-2 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-brand-surface-1 px-3 py-2 text-xs">
|
||||
No parent selected
|
||||
</div>
|
||||
)
|
||||
}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
||||
<SidebarBlockerSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
|
||||
<SidebarBlockedSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-brand-secondary 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
|
||||
placeholder="Due date"
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-brand-surface-1"
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showThirdSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && (
|
||||
<SidebarCycleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleCycleChange={handleCycleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && (
|
||||
<SidebarModuleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleModuleChange={handleModuleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-[116px] py-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-1"
|
||||
onClick={() => setLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex basis-1/2 items-center gap-x-2 text-sm text-brand-secondary">
|
||||
<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((labelId) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
if (label)
|
||||
return (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1 py-0.5 text-xs hover:border-red-500/20 hover:bg-red-500/20"
|
||||
onClick={() => {
|
||||
const updatedLabels = watchIssue("labels_list")?.filter(
|
||||
(l) => l !== labelId
|
||||
);
|
||||
submitChanges({
|
||||
labels_list: updatedLabels,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels_list"
|
||||
render={({ field: { value } }) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={value}
|
||||
onChange={(val: any) => submitChanges({ labels_list: val })}
|
||||
className="flex-shrink-0"
|
||||
multiple
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-2 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
>
|
||||
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-brand-surface-2 py-1 text-xs shadow-lg border border-brand-base focus:outline-none">
|
||||
<div className="py-1">
|
||||
{issueLabels ? (
|
||||
issueLabels.length > 0 ? (
|
||||
issueLabels.map((label: IIssueLabels) => {
|
||||
const children = issueLabels?.filter(
|
||||
(l) => l.parent === label.id
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-brand-surface-1" : ""
|
||||
} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== ""
|
||||
? label.color
|
||||
: "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</Listbox.Option>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="border-y border-brand-base bg-brand-surface-1">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-brand-base">
|
||||
<RectangleGroupIcon className="h-3 w-3" />
|
||||
{label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Listbox.Option
|
||||
key={child.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-brand-base" : ""
|
||||
} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center">No labels found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
<XMarkIcon className="h-3 w-3" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" /> New
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{createLabelForm && (
|
||||
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
|
||||
<div>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`flex items-center gap-1 rounded-md bg-brand-surface-2 p-1 outline-none focus:ring-2 focus:ring-brand-accent`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
className="grid place-items-center rounded bg-red-500 p-2.5"
|
||||
onClick={() => setCreateLabelForm(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid place-items-center rounded bg-green-500 p-2.5"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? (
|
||||
<LinksList
|
||||
links={issueDetail.issue_link}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
<div className="min-h-[116px] py-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-1"
|
||||
onClick={() => setLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? (
|
||||
<LinksList
|
||||
links={issueDetail.issue_link}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||
})
|
||||
.finally(() => onClose());
|
||||
},
|
||||
[workspaceSlug, projectId, pageId, onClose, setToastAlert]
|
||||
[workspaceSlug, projectId, pageId, onClose, setToastAlert, user]
|
||||
);
|
||||
|
||||
const updatePageBlock = useCallback(
|
||||
|
|
@ -181,7 +181,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||
})
|
||||
.finally(() => onClose());
|
||||
},
|
||||
[workspaceSlug, projectId, pageId, data, onClose, setIsSyncing]
|
||||
[workspaceSlug, projectId, pageId, data, onClose, setIsSyncing, user]
|
||||
);
|
||||
|
||||
const handleAutoGenerateDescription = async () => {
|
||||
|
|
|
|||
|
|
@ -19,11 +19,7 @@ export const SecondaryButton: React.FC<ButtonProps> = ({
|
|||
: size === "md"
|
||||
? "rounded-md px-3.5 py-2 text-sm"
|
||||
: "rounded-lg px-4 py-2 text-base"
|
||||
} ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-brand-base bg-brand-surface-1 hover:border-brand-base hover:border-opacity-100 hover:bg-brand-surface-1 hover:bg-opacity-100"
|
||||
: ""
|
||||
} ${
|
||||
} ${disabled ? "cursor-not-allowed border-brand-base bg-brand-surface-1" : ""} ${
|
||||
outline
|
||||
? "bg-transparent hover:bg-brand-surface-2"
|
||||
: "bg-brand-surface-2 hover:border-opacity-70 hover:bg-opacity-70"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fe
|
|||
import { PRIORITIES } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
filters: IIssueFilterOptions | IQuery;
|
||||
filters: Partial<IIssueFilterOptions> | IQuery;
|
||||
onSelect: (option: any) => void;
|
||||
direction?: "left" | "right";
|
||||
height?: "sm" | "md" | "rg" | "lg";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue