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:
pablohashescobar 2023-06-16 18:57:17 +05:30 committed by GitHub
parent 963ccd808d
commit e9a0eb87cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 3712 additions and 737 deletions

View file

@ -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}

View file

@ -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 = (

View file

@ -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 = () => {

View file

@ -276,6 +276,7 @@ export const IssuesView: React.FC<Props> = ({
handleDeleteIssue,
params,
states,
user,
]
);

View file

@ -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 = () => {

View 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>
);

View file

@ -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";

View 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>
);

View 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>
);
};

View 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>
);
};

View 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]),
})),
],
},
]}
/>
);

View 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>
);
};

View 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>
);
};

View 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;
};

View 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";

View 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>
)}
</>
);
};

View 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>
);
};

View file

@ -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();

View file

@ -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();

View file

@ -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,

View file

@ -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}
/>
);
}}

View file

@ -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>

View file

@ -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>

View file

@ -67,7 +67,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
console.log(error);
});
},
[workspaceSlug, projectId]
[workspaceSlug, projectId, user]
);
const handleCopyText = () => {

View file

@ -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>
</>
);

View file

@ -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 () => {

View file

@ -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"

View file

@ -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";