Compare commits

...

34 Commits

Author SHA1 Message Date
Anmol Singh Bhatia
d86be92754 chore: code refactor 2024-07-02 13:16:31 +05:30
Anmol Singh Bhatia
c423b6d090 fix: issue relation endpoint 2024-07-01 21:07:09 +05:30
Anmol Singh Bhatia
6e97178e1d Merge branch 'preview' of github.com:makeplane/plane into chore/issue-detail-page-central-pane 2024-07-01 20:45:40 +05:30
Anmol Singh Bhatia
88d5327334 chore: issue attachment added to central pane 2024-07-01 20:45:16 +05:30
Anmol Singh Bhatia
e528b431be chore: accordion improvement 2024-07-01 20:27:26 +05:30
Anmol Singh Bhatia
73824af1f8 chore: link list enhancement 2024-07-01 20:26:09 +05:30
Anmol Singh Bhatia
58c503a4a0 chore: dropdown icon added 2024-07-01 20:08:53 +05:30
Anmol Singh Bhatia
bfd7820961 chore: sub-issue and relation issue quick action menu updated 2024-07-01 19:21:54 +05:30
Anmol Singh Bhatia
606d379563 chore: attachment icon component improvement 2024-07-01 19:12:55 +05:30
Anmol Singh Bhatia
6f0a2eda6c chore: custom menu component improvement 2024-07-01 19:12:20 +05:30
Anmol Singh Bhatia
17a95f306b chore: calculateTimeAgoShort helper function added 2024-07-01 17:17:15 +05:30
Anmol Singh Bhatia
0f9ceaefa5 chore: openOnHover prop added to custom menu component 2024-07-01 17:15:12 +05:30
NarayanBavisetti
7e3cf69cd6 chore: changed updated_by in issue attachment 2024-07-01 15:29:06 +05:30
Anmol Singh Bhatia
aaf9e180db chore: issue relations response updated 2024-07-01 12:14:55 +05:30
pablohashescobar
07f2426574 dev: add label_ids and assignee_id 2024-07-01 12:04:32 +05:30
pablohashescobar
4e71b78a19 dev: update issue relation endpoint to return same response as sub issues 2024-07-01 11:58:23 +05:30
pablohashescobar
f27b54dda8 Merge branch 'chore/issue-detail-page-central-pane' of github.com:makeplane/plane into chore/issue-detail-page-central-pane 2024-07-01 11:50:47 +05:30
Anmol Singh Bhatia
87a0dde61d chore: issue attachments added to central pane 2024-06-28 19:16:58 +05:30
Anmol Singh Bhatia
0843785330 chore: code refactor 2024-06-28 19:06:56 +05:30
Anmol Singh Bhatia
0dee8a5248 fix: link count mutation 2024-06-28 19:01:44 +05:30
Anmol Singh Bhatia
60b5210106 chore: issue links list added to central pane 2024-06-28 17:34:34 +05:30
Anmol Singh Bhatia
5b744edd6d chore: issue relation list added to central pane 2024-06-28 14:44:31 +05:30
Anmol Singh Bhatia
9796bdbf02 chore: central pane helper and code refactor 2024-06-28 14:37:54 +05:30
Anmol Singh Bhatia
d67ee43670 chore: edit and delete modal added to sub issue list 2024-06-28 14:28:48 +05:30
Anmol Singh Bhatia
e3b7f6776e chore: issue relation component added 2024-06-28 14:27:44 +05:30
Anmol Singh Bhatia
424cbca9e6 chore: accordion button chevron updated 2024-06-28 13:38:41 +05:30
Anmol Singh Bhatia
68d30f502f chore: accordion conponent updated 2024-06-28 12:24:40 +05:30
Anmol Singh Bhatia
74a8062dc4 chore: issue relations added to issue detail central pane 2024-06-26 13:59:46 +05:30
Anmol Singh Bhatia
a83801920f chore: issue detail sidebar improvement 2024-06-26 13:50:22 +05:30
Anmol Singh Bhatia
625380f544 chore: sub-issue list improvement and code refactor 2024-06-26 13:28:30 +05:30
Anmol Singh Bhatia
ed720f8ff6 chore: issue detail central pane action button added 2024-06-25 19:59:06 +05:30
Anmol Singh Bhatia
299e1a6561 chore: issue relation icon added 2024-06-25 19:57:16 +05:30
Anmol Singh Bhatia
aa8658ad8c chore: sub issues added to issue detail central pane 2024-06-25 19:38:05 +05:30
Anmol Singh Bhatia
715a52d327 chore: accordion component added 2024-06-25 19:35:30 +05:30
71 changed files with 3094 additions and 271 deletions

View File

@@ -509,7 +509,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
"attributes",
"issue_id",
"updated_at",
"updated_by_id",
"updated_by",
]
read_only_fields = fields

View File

@@ -3,8 +3,11 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import Q
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third Party imports
from rest_framework.response import Response
@@ -20,6 +23,9 @@ from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Project,
IssueRelation,
Issue,
IssueAttachment,
IssueLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -61,56 +67,149 @@ class IssueRelationViewSet(BaseViewSet):
.order_by("-created_at")
.distinct()
)
# get all blocking issues
blocking_issues = issue_relations.filter(
relation_type="blocked_by", related_issue_id=issue_id
)
).values_list("issue_id", flat=True)
# get all blocked by issues
blocked_by_issues = issue_relations.filter(
relation_type="blocked_by", issue_id=issue_id
)
).values_list("related_issue_id", flat=True)
# get all duplicate issues
duplicate_issues = issue_relations.filter(
issue_id=issue_id, relation_type="duplicate"
)
).values_list("related_issue_id", flat=True)
# get all relates to issues
duplicate_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="duplicate"
)
).values_list("issue_id", flat=True)
# get all relates to issues
relates_to_issues = issue_relations.filter(
issue_id=issue_id, relation_type="relates_to"
)
).values_list("related_issue_id", flat=True)
# get all relates to issues
relates_to_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="relates_to"
)
).values_list("issue_id", flat=True)
blocked_by_issues_serialized = IssueRelationSerializer(
blocked_by_issues, many=True
).data
duplicate_issues_serialized = IssueRelationSerializer(
duplicate_issues, many=True
).data
relates_to_issues_serialized = IssueRelationSerializer(
relates_to_issues, many=True
).data
queryset = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
# revere relation for blocked by issues
blocking_issues_serialized = RelatedIssueSerializer(
blocking_issues, many=True
).data
# reverse relation for duplicate issues
duplicate_issues_related_serialized = RelatedIssueSerializer(
duplicate_issues_related, many=True
).data
# reverse relation for related issues
relates_to_issues_related_serialized = RelatedIssueSerializer(
relates_to_issues_related, many=True
).data
# Fields
fields = [
"id",
"name",
"state_id",
"sort_order",
"priority",
"sequence_id",
"project_id",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
"relation_type",
]
response_data = {
"blocking": blocking_issues_serialized,
"blocked_by": blocked_by_issues_serialized,
"duplicate": duplicate_issues_serialized
+ duplicate_issues_related_serialized,
"relates_to": relates_to_issues_serialized
+ relates_to_issues_related_serialized,
"blocking": queryset.filter(pk__in=blocking_issues)
.annotate(
relation_type=Value("blocking", output_field=CharField())
)
.values(*fields),
"blocked_by": queryset.filter(pk__in=blocked_by_issues)
.annotate(
relation_type=Value("blocked_by", output_field=CharField())
)
.values(*fields),
"duplicate": queryset.filter(pk__in=duplicate_issues)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=duplicate_issues_related)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields),
"relates_to": queryset.filter(pk__in=relates_to_issues)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=relates_to_issues_related)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields),
}
return Response(response_data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,49 @@
import React, { FC, useState, useEffect, useCallback } from "react";
import { Disclosure, Transition } from "@headlessui/react";
export type TAccordionProps = {
title: string | React.ReactNode;
children: React.ReactNode;
buttonClassName?: string;
isOpen?: boolean;
handleToggle?: () => void;
defaultOpen?: boolean;
};
export const Accordion: FC<TAccordionProps> = (props) => {
const { title, children, buttonClassName, isOpen, handleToggle, defaultOpen } = props;
// state
const [localIsOpen, setLocalIsOpen] = useState<boolean>(isOpen || defaultOpen ? true : false);
useEffect(() => {
if (isOpen !== undefined) {
setLocalIsOpen(isOpen);
}
}, [isOpen]);
// handlers
const handleOnClick = useCallback(() => {
setLocalIsOpen((prev) => !prev);
if (handleToggle) handleToggle();
}, [handleToggle]);
return (
<Disclosure>
<Disclosure.Button className={buttonClassName} onClick={handleOnClick}>
{title}
</Disclosure.Button>
<Transition
show={localIsOpen}
className="overflow-hidden"
enter="transition-max-height duration-400 ease-in-out"
enterFrom="max-h-0"
enterTo="max-h-screen"
leave="transition-max-height duration-400 ease-in-out"
leaveFrom="max-h-screen"
leaveTo="max-h-0"
>
<Disclosure.Panel static>{children}</Disclosure.Panel>
</Transition>
</Disclosure>
);
};

View File

@@ -0,0 +1 @@
export * from "./accordion";

View File

@@ -34,6 +34,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
onMenuClose,
tabIndex,
closeOnSelect,
openOnHover = false,
} = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
@@ -68,12 +69,24 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
if (closeOnSelect) closeDropdown();
};
const handleMenuButtonClick = (e:React.MouseEvent<HTMLButtonElement, MouseEvent>)=>{
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault()
e.preventDefault();
isOpen ? closeDropdown() : openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}
};
const handleMouseEnter = () => {
if (openOnHover) openDropdown();
};
const handleMouseLeave = () => {
if (openOnHover && isOpen) {
setTimeout(() => {
closeDropdown();
}, 500);
}
};
useOutsideClickDetector(dropdownRef, closeDropdown);
@@ -111,6 +124,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
className={cn("relative w-min text-left", className)}
onKeyDownCapture={handleKeyDown}
onClick={handleOnClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{({ open }) => (
<>

View File

@@ -28,6 +28,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
onMenuClose?: () => void;
closeOnSelect?: boolean;
portalElement?: Element | null;
openOnHover?: boolean;
}
export interface ICustomSelectProps extends IDropdownProps {

View File

@@ -0,0 +1,15 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const DropdownIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 7 5"
className={`${className} stroke-2`}
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path d="M2.77267 4.0211L0.457719 1.78653C0.162864 1.50191 0.0961218 1.17467 0.257492 0.8048C0.418861 0.434934 0.70939 0.25 1.12908 0.25H5.72716C6.14685 0.25 6.43738 0.434934 6.59875 0.8048C6.76012 1.17467 6.69338 1.50191 6.39855 1.78653L4.08357 4.0211C3.98662 4.1147 3.88435 4.18329 3.77676 4.22687C3.66918 4.27046 3.55297 4.29225 3.42813 4.29225C3.30328 4.29225 3.18706 4.27046 3.07948 4.22687C2.97191 4.18329 2.86964 4.1147 2.77267 4.0211Z" />
</svg>
);

View File

@@ -21,3 +21,5 @@ export * from "./related-icon";
export * from "./side-panel-icon";
export * from "./transfer-icon";
export * from "./info-icon";
export * from "./relations-icon";
export * from "./dropdown-icon";

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const RelationsIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 16 16"
className={`${className}`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
<path d="M7.99998 4.66536C8.92045 4.66536 9.66665 3.91917 9.66665 2.9987C9.66665 2.07822 8.92045 1.33203 7.99998 1.33203C7.07951 1.33203 6.33331 2.07822 6.33331 2.9987C6.33331 3.91917 7.07951 4.66536 7.99998 4.66536Z" />
<path d="M6.80001 4.19922L4.20001 6.79922" />
<path d="M2.99998 9.66536C3.92045 9.66536 4.66665 8.91917 4.66665 7.9987C4.66665 7.07822 3.92045 6.33203 2.99998 6.33203C2.07951 6.33203 1.33331 7.07822 1.33331 7.9987C1.33331 8.91917 2.07951 9.66536 2.99998 9.66536Z" />
<path d="M4.66669 8H11.3334" />
<path d="M13 9.66536C13.9205 9.66536 14.6666 8.91917 14.6666 7.9987C14.6666 7.07822 13.9205 6.33203 13 6.33203C12.0795 6.33203 11.3333 7.07822 11.3333 7.9987C11.3333 8.91917 12.0795 9.66536 13 9.66536Z" />
<path d="M9.20001 11.7992L11.8 9.19922" />
<path d="M7.99998 14.6654C8.92045 14.6654 9.66665 13.9192 9.66665 12.9987C9.66665 12.0782 8.92045 11.332 7.99998 11.332C7.07951 11.332 6.33331 12.0782 6.33331 12.9987C6.33331 13.9192 7.07951 14.6654 7.99998 14.6654Z" />
</svg>
);

View File

@@ -20,3 +20,4 @@ export * from "./drag-handle";
export * from "./drop-indicator";
export * from "./favorite-star";
export * from "./loader";
export * from "./accordion";

View File

@@ -16,44 +16,44 @@ import {
VideoIcon,
} from "@/components/icons/attachment";
export const getFileIcon = (fileType: string) => {
export const getFileIcon = (fileType: string, size: number = 28) => {
switch (fileType) {
case "pdf":
return <PdfIcon height={28} width={28} />;
return <PdfIcon height={size} width={size} />;
case "csv":
return <CsvIcon height={28} width={28} />;
return <CsvIcon height={size} width={size} />;
case "xlsx":
return <SheetIcon height={28} width={28} />;
return <SheetIcon height={size} width={size} />;
case "css":
return <CssIcon height={28} width={28} />;
return <CssIcon height={size} width={size} />;
case "doc":
return <DocIcon height={28} width={28} />;
return <DocIcon height={size} width={size} />;
case "fig":
return <FigmaIcon height={28} width={28} />;
return <FigmaIcon height={size} width={size} />;
case "html":
return <HtmlIcon height={28} width={28} />;
return <HtmlIcon height={size} width={size} />;
case "png":
return <PngIcon height={28} width={28} />;
return <PngIcon height={size} width={size} />;
case "jpg":
return <JpgIcon height={28} width={28} />;
return <JpgIcon height={size} width={size} />;
case "js":
return <JavaScriptIcon height={28} width={28} />;
return <JavaScriptIcon height={size} width={size} />;
case "txt":
return <TxtIcon height={28} width={28} />;
return <TxtIcon height={size} width={size} />;
case "svg":
return <SvgIcon height={28} width={28} />;
return <SvgIcon height={size} width={size} />;
case "mp3":
return <AudioIcon height={28} width={28} />;
return <AudioIcon height={size} width={size} />;
case "wav":
return <AudioIcon height={28} width={28} />;
return <AudioIcon height={size} width={size} />;
case "mp4":
return <VideoIcon height={28} width={28} />;
return <VideoIcon height={size} width={size} />;
case "wmv":
return <VideoIcon height={28} width={28} />;
return <VideoIcon height={size} width={size} />;
case "mkv":
return <VideoIcon height={28} width={28} />;
return <VideoIcon height={size} width={size} />;
default:
return <DefaultIcon height={28} width={28} />;
return <DefaultIcon height={size} width={size} />;
}
};

View File

@@ -0,0 +1,93 @@
import { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
// hooks
import { MAX_FILE_SIZE } from "@/constants/common";
import { generateFileName } from "@/helpers/attachment.helper";
import { useInstance, useIssueDetail } from "@/hooks/store";
// components
import { IssueAttachmentsListItem } from "./attachment-list-item";
// types
import { TAttachmentOperations } from "./root";
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
type TIssueAttachmentItemList = {
workspaceSlug: string;
issueId: string;
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
disabled?: boolean;
};
export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((props) => {
const { workspaceSlug, issueId, handleAttachmentOperations, disabled } = props;
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { config } = useInstance();
const {
attachment: { getAttachmentsByIssueId },
} = useIssueDetail();
// derived values
const issueAttachments = getAttachmentsByIssueId(issueId);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
type: currentFile.type,
});
const formData = new FormData();
formData.append("asset", uploadedFile);
formData.append(
"attributes",
JSON.stringify({
name: uploadedFile.name,
size: uploadedFile.size,
})
);
setIsLoading(true);
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
},
[handleAttachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
disabled: isLoading || disabled,
});
if (!issueAttachments) return <></>;
return (
<div
{...getRootProps()}
className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
{isDragActive && (
<div className="absolute flex items-center justify-center left-0 top-0 h-full w-full bg-custom-background-90/75 z-30 ">
<div className="flex items-center justify-center p-1 rounded-md bg-custom-background-100">
<div className="flex flex-col justify-center items-center px-5 py-6 rounded-md border border-dashed border-custom-border-300">
<UploadCloud className="size-7" />
<span className="text-sm text-custom-text-300">Drag and drop anywhere to upload</span>
</div>
</div>
</div>
)}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
))}
</div>
);
});

View File

@@ -0,0 +1,111 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { Trash } from "lucide-react";
// ui
import { CustomMenu, Tooltip } from "@plane/ui";
// components
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons";
import { IssueAttachmentDeleteModal } from "@/components/issues";
// helpers
import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useIssueDetail, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import { TAttachmentOperations } from "./root";
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
type TIssueAttachmentsListItem = {
attachmentId: string;
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
disabled?: boolean;
};
export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer((props) => {
// props
const { attachmentId, handleAttachmentOperations, disabled } = props;
// store hooks
const { getUserDetails } = useMember();
const {
attachment: { getAttachmentById },
isDeleteAttachmentModalOpen,
toggleDeleteAttachmentModal,
} = useIssueDetail();
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
// hooks
const { isMobile } = usePlatformOS();
if (!attachment) return <></>;
return (
<>
{isDeleteAttachmentModalOpen === attachment.id && (
<IssueAttachmentDeleteModal
isOpen={!!isDeleteAttachmentModalOpen}
onClose={() => toggleDeleteAttachmentModal(null)}
handleAttachmentOperations={handleAttachmentOperations}
data={attachment}
/>
)}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(attachment.asset, "_blank");
}}
>
<div className="group flex items-center justify-between gap-3 h-11 hover:bg-custom-background-90 pl-9 pr-2">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex items-center gap-3 ">{getFileIcon(getFileExtension(attachment.asset), 18)}</div>
<Tooltip
tooltipContent={`${getFileName(attachment.attributes.name)}.${getFileExtension(attachment.asset)}`}
isMobile={isMobile}
>
<p className="text-custom-text-200 font-medium truncate">{`${getFileName(attachment.attributes.name)}.${getFileExtension(attachment.asset)}`}</p>
</Tooltip>
<span className="flex size-1.5 bg-custom-background-80 rounded-full" />
<span className="flex-shrink-0 text-custom-text-400">{convertBytesToSize(attachment.attributes.size)}</span>
</div>
<div className="flex items-center gap-3">
{attachment?.updated_by && (
<>
<Tooltip
isMobile={isMobile}
tooltipContent={`${
getUserDetails(attachment.updated_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<div className="flex items-center justify-center">
<ButtonAvatars showTooltip userIds={attachment?.updated_by} />
</div>
</Tooltip>
</>
)}
<CustomMenu ellipsis closeOnSelect placement="bottom-end" openOnHover disabled={disabled}>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDeleteAttachmentModal(attachment.id);
}}
>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>Delete</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</button>
</>
);
});

View File

@@ -3,3 +3,5 @@ export * from "./attachment-upload";
export * from "./attachments-list";
export * from "./delete-attachment-modal";
export * from "./root";
export * from "./attachment-list-item";
export * from "./attachment-item-list";

View File

@@ -10,6 +10,7 @@ export * from "./label";
export * from "./confirm-issue-discard";
export * from "./issue-update-status";
export * from "./create-issue-toast-action-items";
export * from "./relations";
// issue details
export * from "./issue-detail";

View File

@@ -0,0 +1,103 @@
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components
import { IssueAttachmentItemList, TAttachmentOperations } from "@/components/issues/attachment";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueAttachmentsAccordionContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const { createAttachment, removeAttachment } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
// handler
const handleAttachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (data: FormData) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
success: {
title: "Attachment uploaded",
message: () => "The attachment has been successfully uploaded",
},
error: {
title: "Attachment not uploaded",
message: () => "The attachment could not be uploaded",
},
});
const res = await attachmentUploadPromise;
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: res.id,
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
});
}
},
remove: async (attachmentId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
setToast({
message: "The attachment has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Attachment removed",
});
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
setToast({
message: "The Attachment could not be removed",
type: TOAST_TYPE.ERROR,
title: "Attachment not removed",
});
}
},
}),
[captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
return (
<IssueAttachmentItemList
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./content";
export * from "./root";
export * from "./title";

View File

@@ -0,0 +1,43 @@
import React, { FC, useState } from "react";
import { Accordion } from "@plane/ui";
// components
import {
IssueAttachmentsAccordionContent,
IssueAttachmentsAccordionTitle,
} from "@/components/issues/issue-detail/central-pane";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const AttachmentsAccordion: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Accordion
isOpen={isOpen}
handleToggle={() => setIsOpen((prev) => !prev)}
title={
<IssueAttachmentsAccordionTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<IssueAttachmentsAccordionContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Accordion>
);
};

View File

@@ -0,0 +1,48 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import { AccordionButton, IssueAttachmentActionButton } from "@/components/issues/issue-detail/central-pane";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueAttachmentsAccordionTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
const attachmentCount = issue?.attachment_count ?? 0;
const indicatorElement = (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
</span>
);
return (
<AccordionButton
isOpen={isOpen}
title="Attachments"
indicatorElement={indicatorElement}
actionItemElement={
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});

View File

@@ -0,0 +1,144 @@
import React, { FC, useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
import { Plus } from "lucide-react";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components
import { TAttachmentOperations } from "@/components/issues/attachment";
// constants
import { MAX_FILE_SIZE } from "@/constants/common";
// helper
import { generateFileName } from "@/helpers/attachment.helper";
// hooks
import { useEventTracker, useInstance, useIssueDetail } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { createAttachment, removeAttachment } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const { config } = useInstance();
// handler
const handleAttachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (data: FormData) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
success: {
title: "Attachment uploaded",
message: () => "The attachment has been successfully uploaded",
},
error: {
title: "Attachment not uploaded",
message: () => "The attachment could not be uploaded",
},
});
const res = await attachmentUploadPromise;
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: res.id,
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
});
}
},
remove: async (attachmentId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
setToast({
message: "The attachment has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Attachment removed",
});
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
setToast({
message: "The Attachment could not be removed",
type: TOAST_TYPE.ERROR,
title: "Attachment not removed",
});
}
},
}),
[captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
type: currentFile.type,
});
const formData = new FormData();
formData.append("asset", uploadedFile);
formData.append(
"attributes",
JSON.stringify({
name: uploadedFile.name,
size: uploadedFile.size,
})
);
setIsLoading(true);
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
},
[handleAttachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
disabled: isLoading || disabled,
});
return (
<>
<button {...getRootProps()} type="button" disabled={disabled}>
<input {...getInputProps()} />
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
</>
);
});

View File

@@ -0,0 +1,35 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Paperclip } from "lucide-react";
// components
import {
CentralPaneHeaderActionButton,
IssueAttachmentActionButton,
} from "@/components/issues/issue-detail/central-pane";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const AttachmentsHeader: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
return (
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
customButton={
<CentralPaneHeaderActionButton
title="Attach"
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" />}
/>
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./header";

View File

@@ -0,0 +1,3 @@
export * from "./accordion";
export * from "./action-item-button";
export * from "./header";

View File

@@ -0,0 +1,34 @@
import React, { FC } from "react";
import { DropdownIcon } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
isOpen: boolean;
title: string;
hideChevron?: boolean;
indicatorElement?: React.ReactNode;
actionItemElement?: React.ReactNode;
};
export const AccordionButton: FC<Props> = (props) => {
const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props;
return (
<div className="flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-100">
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-3">
{!hideChevron && (
<DropdownIcon
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
"-rotate-90": isOpen,
})}
/>
)}
<span className="text-base text-custom-text-100 font-medium">{title}</span>
</div>
{indicatorElement && indicatorElement}
</div>
{actionItemElement && isOpen && actionItemElement}
</div>
);
};

View File

@@ -0,0 +1,16 @@
import React, { FC } from "react";
type Props = {
icon: JSX.Element;
title: string;
};
export const CentralPaneHeaderActionButton: FC<Props> = (props) => {
const { icon, title } = props;
return (
<div className="h-full w-min whitespace-nowrap flex items-center gap-2 border border-custom-border-200 hover:bg-custom-background-80 rounded px-3 py-1.5">
{icon && icon}
<span className="text-sm font-medium text-custom-text-300">{title}</span>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./accordion-button";
export * from "./header-action-button";

View File

@@ -0,0 +1,7 @@
export * from "./common";
export * from "./root";
export * from "./sub-issues";
export * from "./relations";
export * from "./links";
export * from "./attachments";
export * from "./relations";

View File

@@ -0,0 +1,84 @@
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { TIssueLink } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { LinkList, TLinkOperations } from "../../../links";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueLinksAccordionContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// hooks
const { createLink, updateLink, removeLink } = useIssueDetail();
// handler
const handleLinkOperations: TLinkOperations = useMemo(
() => ({
create: async (data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await createLink(workspaceSlug, projectId, issueId, data);
setToast({
message: "The link has been successfully created",
type: TOAST_TYPE.SUCCESS,
title: "Link created",
});
// toggleIssueLinkModal(false);
} catch (error) {
setToast({
message: "The link could not be created",
type: TOAST_TYPE.ERROR,
title: "Link not created",
});
}
},
update: async (linkId: string, data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
setToast({
message: "The link has been successfully updated",
type: TOAST_TYPE.SUCCESS,
title: "Link updated",
});
// toggleIssueLinkModal(false);
} catch (error) {
setToast({
message: "The link could not be updated",
type: TOAST_TYPE.ERROR,
title: "Link not updated",
});
}
},
remove: async (linkId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeLink(workspaceSlug, projectId, issueId, linkId);
setToast({
message: "The link has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Link removed",
});
// toggleIssueLinkModal(false);
} catch (error) {
setToast({
message: "The link could not be removed",
type: TOAST_TYPE.ERROR,
title: "Link not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink]
);
return <LinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />;
});

View File

@@ -0,0 +1,3 @@
export * from "./content";
export * from "./root";
export * from "./title";

View File

@@ -0,0 +1,40 @@
import React, { FC, useState } from "react";
import { Accordion } from "@plane/ui";
// components
import { IssueLinksAccordionContent, IssueLinksAccordionTitle } from "@/components/issues/issue-detail/central-pane";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const LinksAccordion: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Accordion
isOpen={isOpen}
handleToggle={() => setIsOpen((prev) => !prev)}
title={
<IssueLinksAccordionTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<IssueLinksAccordionContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Accordion>
);
};

View File

@@ -0,0 +1,48 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import { AccordionButton, IssueLinksActionButton } from "@/components/issues/issue-detail/central-pane";
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueLinksAccordionTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
const linksCount = issue?.link_count ?? 0;
const indicatorElement = (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
</span>
);
return (
<AccordionButton
isOpen={isOpen}
title="Links"
indicatorElement={indicatorElement}
actionItemElement={
<IssueLinksActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});

View File

@@ -0,0 +1,116 @@
import React, { FC, useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
import { TIssueLink } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TLinkOperations } from "../../links";
// components
import { IssueLinkCreateUpdateModal } from "../../links/create-update-link-modal";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
export const IssueLinksActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
// state
const [isIssueLinkModal, setIsIssueLinkModal] = useState(false);
// store hooks
const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail();
// handler
const toggleIssueLinkModal = useCallback(
(modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIsIssueLinkModal(modalToggle);
},
[toggleIssueLinkModalStore]
);
const handleLinkOperations: TLinkOperations = useMemo(
() => ({
create: async (data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await createLink(workspaceSlug, projectId, issueId, data);
setToast({
message: "The link has been successfully created",
type: TOAST_TYPE.SUCCESS,
title: "Link created",
});
toggleIssueLinkModal(false);
} catch (error) {
setToast({
message: "The link could not be created",
type: TOAST_TYPE.ERROR,
title: "Link not created",
});
}
},
update: async (linkId: string, data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
setToast({
message: "The link has been successfully updated",
type: TOAST_TYPE.SUCCESS,
title: "Link updated",
});
toggleIssueLinkModal(false);
} catch (error) {
setToast({
message: "The link could not be updated",
type: TOAST_TYPE.ERROR,
title: "Link not updated",
});
}
},
remove: async (linkId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeLink(workspaceSlug, projectId, issueId, linkId);
setToast({
message: "The link has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Link removed",
});
toggleIssueLinkModal(false);
} catch (error) {
setToast({
message: "The link could not be removed",
type: TOAST_TYPE.ERROR,
title: "Link not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, toggleIssueLinkModal]
);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
};
return (
<>
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModal}
handleModal={toggleIssueLinkModal}
linkOperations={handleLinkOperations}
/>
<button type="button" onClick={handleOnClick} disabled={disabled}>
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
</>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Link } from "lucide-react";
// components
import { CentralPaneHeaderActionButton, IssueLinksActionButton } from "@/components/issues/issue-detail/central-pane";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const LinksHeader: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
return (
<IssueLinksActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
customButton={
<CentralPaneHeaderActionButton
title="Add Links"
icon={<Link className="h-3.5 w-3.5 flex-shrink-0 text-lg text-custom-text-300" />}
/>
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./header";

View File

@@ -0,0 +1,3 @@
export * from "./accordion";
export * from "./action-item-button";
export * from "./header";

View File

@@ -0,0 +1,228 @@
import { FC, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { TIssue, TIssueRelationIdMap } from "@plane/types";
import { Accordion, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
import { RelationIssueList } from "@/components/issues/relations";
// constants
import { ISSUE_DELETED, ISSUE_UPDATED } from "@/constants/event-tracker";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// helpers
import { ISSUE_RELATION_OPTIONS, TRelationIssueOperations } from "../helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
export const RelationsAccordionContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// router
const pathname = usePathname();
// state
const [issueCrudState, setIssueCrudState] = useState<{
update: TIssueCrudState;
delete: TIssueCrudState;
}>({
update: {
toggle: false,
issueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
issueId: undefined,
issue: undefined,
},
});
// store hooks
const {
relation: { getRelationsByIssueId },
updateIssue,
removeIssue,
toggleDeleteIssueModal,
toggleCreateIssueModal,
} = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
// derived values
const relations = getRelationsByIssueId(issueId);
const issueOperations: TRelationIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
},
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: pathname,
});
} catch (error) {
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: pathname,
});
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Issue update failed",
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await removeIssue(workspaceSlug, projectId, issueId);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Issue deleted successfully",
});
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
} catch (error) {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Issue delete failed",
});
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
}
},
}),
[pathname, removeIssue, updateIssue]
);
const handleIssueCrudState = (key: "update" | "delete", _issueId: string | null, issue: TIssue | null = null) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
issueId: _issueId,
issue: issue,
},
});
};
// if relations are not available, return null
if (!relations) return null;
// map relations to array
const relationsArray = Object.keys(relations).map((relationKey) => {
const issueIds = relations[relationKey as keyof TIssueRelationIdMap];
const issueRelationOption = ISSUE_RELATION_OPTIONS.find((option) => option.key === relationKey);
return {
relationKey: relationKey as keyof TIssueRelationIdMap,
issueIds: issueIds,
icon: issueRelationOption?.icon,
label: issueRelationOption?.label,
className: issueRelationOption?.className,
};
});
// filter out relations with no issues
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);
return (
<>
<div className="flex flex-col gap-">
{filteredRelationsArray.map((relation) => (
<div key={relation.relationKey}>
<Accordion
buttonClassName="w-full"
title={
<div className={`flex items-center gap-1 px-3 py-1 h-9 w-full pl-9 ${relation.className}`}>
<span>{relation.icon ? relation.icon(14) : null}</span>
<span className="text-sm font-medium leading-5">{relation.label}</span>
</div>
}
defaultOpen
>
<RelationIssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey={relation.relationKey}
issueIds={relation.issueIds}
disabled={disabled}
issueOperations={issueOperations}
handleIssueCrudState={handleIssueCrudState}
/>
</Accordion>
</div>
))}
</div>
{issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.issueId &&
issueCrudState.delete.issue.id && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await issueOperations.remove(workspaceSlug, projectId, issueCrudState?.delete?.issue?.id as string)
}
isSubIssue
/>
)}
{issueCrudState?.update?.toggle && issueCrudState?.update?.issue && (
<>
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await issueOperations.update(workspaceSlug, projectId, _issue.id, _issue);
}}
/>
</>
)}
</>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./content";
export * from "./root";
export * from "./title";

View File

@@ -0,0 +1,40 @@
import React, { FC, useState } from "react";
import { Accordion } from "@plane/ui";
// components
import { RelationsAccordionContent, RelationsAccordionTitle } from "@/components/issues/issue-detail/central-pane";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const RelationsAccordion: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Accordion
isOpen={isOpen}
handleToggle={() => setIsOpen((prev) => !prev)}
title={
<RelationsAccordionTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<RelationsAccordionContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Accordion>
);
};

View File

@@ -0,0 +1,46 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import { AccordionButton, RelationActionButton } from "@/components/issues/issue-detail/central-pane";
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const RelationsAccordionTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hook
const {
relation: { getRelationsByIssueId },
} = useIssueDetail();
// derived values
const issueRelations = getRelationsByIssueId(issueId);
const relationsCount = Object.values(issueRelations ?? {}).reduce((acc, curr) => acc + curr.length, 0);
const indicatorElement = (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
</span>
);
return (
<AccordionButton
isOpen={isOpen}
title="Relations"
indicatorElement={indicatorElement}
actionItemElement={
<RelationActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});

View File

@@ -0,0 +1,95 @@
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
import { ISearchIssueResponse, TIssueRelationTypes } from "@plane/types";
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
import { ISSUE_RELATION_OPTIONS } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
export const RelationActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, customButton, issueId, disabled = false } = props;
// state
const [relationKey, setRelationKey] = useState<TIssueRelationTypes | null>(null);
const { createRelation, isRelationModalOpen, toggleRelationModal } = useIssueDetail();
// handlers
const handleOnClick = (relationKey: TIssueRelationTypes) => {
setRelationKey(relationKey);
toggleRelationModal(issueId, relationKey);
};
// submit handler
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (!relationKey) return;
if (data.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
await createRelation(
workspaceSlug,
projectId,
issueId,
relationKey,
data.map((i) => i.id)
);
toggleRelationModal(null, null);
};
const handleOnClose = () => {
setRelationKey(null);
toggleRelationModal(null, null);
};
// button element
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<>
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
{ISSUE_RELATION_OPTIONS.map((item, index) => (
<CustomMenu.MenuItem
key={index}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleOnClick(item.key as TIssueRelationTypes);
}}
>
<div className="flex items-center gap-2">
{item.icon(12)}
<span>{item.label}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={isRelationModalOpen?.issueId === issueId && isRelationModalOpen?.relationType === relationKey}
handleClose={handleOnClose}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
</>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { RelationsIcon } from "@plane/ui";
// components
import { CentralPaneHeaderActionButton, RelationActionButton } from "@/components/issues/issue-detail/central-pane";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const RelationsHeader: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
return (
<RelationActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
customButton={
<CentralPaneHeaderActionButton
title="Add Relation"
icon={<RelationsIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" />}
/>
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./header";

View File

@@ -0,0 +1,37 @@
"use client";
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { TIssue } from "@plane/types";
import { RelatedIcon } from "@plane/ui";
export const ISSUE_RELATION_OPTIONS = [
{
key: "blocked_by",
label: "Blocked by",
icon: (size: number) => <CircleDot size={size} />,
className: "bg-red-500/20 text-red-700",
},
{
key: "blocking",
label: "Blocking",
icon: (size: number) => <XCircle size={size} />,
className: "bg-yellow-500/20 text-yellow-700",
},
{
key: "relates_to",
label: "Relates to",
icon: (size: number) => <RelatedIcon height={size} width={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
{
key: "duplicate",
label: "Duplicate of",
icon: (size: number) => <CopyPlus size={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
];
export type TRelationIssueOperations = {
copyText: (text: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};

View File

@@ -0,0 +1,4 @@
export * from "./accordion";
export * from "./action-item-button";
export * from "./header";
export * from "./helper";

View File

@@ -0,0 +1,96 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import {
SubIssuesHeader,
SubIssuesAccordion,
RelationsHeader,
AttachmentsHeader,
LinksHeader,
RelationsAccordion,
LinksAccordion,
AttachmentsAccordion,
} from "@/components/issues/issue-detail/central-pane";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const CentralPane: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId },
relation: { getRelationsByIssueId },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const subIssues = subIssuesByIssueId(issueId);
const issueRelations = getRelationsByIssueId(issueId);
// render conditions
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0;
const shouldRenderRelations = Object.values(issueRelations ?? {}).some((relation) => relation.length > 0);
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0;
const shouldRenderAttachments = !!issue?.attachment_count && issue?.attachment_count > 0;
const headerComponents = [SubIssuesHeader, RelationsHeader, LinksHeader, AttachmentsHeader];
const accordionComponents = [
{
shouldRender: shouldRenderSubIssues,
component: SubIssuesAccordion,
},
{
shouldRender: shouldRenderRelations,
component: RelationsAccordion,
},
{
shouldRender: shouldRenderLinks,
component: LinksAccordion,
},
{
shouldRender: shouldRenderAttachments,
component: AttachmentsAccordion,
},
];
return (
<div className="flex flex-col gap-5">
<div className="flex items-center flex-wrap gap-2">
{headerComponents.map((HeaderComponent, index) => (
<HeaderComponent
key={index}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
))}
</div>
<div className="flex flex-col">
{accordionComponents.map(
({ shouldRender, component: AccordionComponent }, index) =>
shouldRender && (
<AccordionComponent
key={index}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,325 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { TIssue } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
import { TSubIssueOperations } from "@/components/issues/sub-issues";
import { IssueList } from "@/components/issues/sub-issues/issues-list";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
};
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
export const SubIssuesAccordionContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, disabled } = props;
// router
const pathname = usePathname();
// state
const [issueCrudState, setIssueCrudState] = useState<{
create: TIssueCrudState;
existing: TIssueCrudState;
update: TIssueCrudState;
delete: TIssueCrudState;
}>({
create: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
existing: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
update: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
});
// store hooks
const {
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
fetchSubIssues,
createSubIssues,
toggleCreateIssueModal,
toggleDeleteIssueModal,
updateSubIssue,
removeSubIssue,
deleteSubIssue,
} = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
// helpers
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
// operations
const handleIssueCrudState = (
key: "create" | "existing" | "update" | "delete",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
},
fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error fetching sub-issues",
});
}
},
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-issues added successfully",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error adding sub-issue",
});
}
},
updateSubIssue: async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue: Partial<TIssue> = {},
fromModal: boolean = false
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
captureIssueEvent({
eventName: "Sub-issue updated",
payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(issueData).join(","),
change_details: Object.values(issueData).join(","),
},
path: pathname,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-issue updated successfully",
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue updated",
payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(issueData).join(","),
change_details: Object.values(issueData).join(","),
},
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error updating sub-issue",
});
}
},
removeSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-issue removed successfully",
});
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "parent_id",
change_details: parentIssueId,
},
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "parent_id",
change_details: parentIssueId,
},
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error removing sub-issue",
});
}
},
deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
captureIssueEvent({
eventName: "Sub-issue deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error deleting issue",
});
}
},
}),
[fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, setSubIssueHelpers]
);
const handleFetchSubIssues = useCallback(async () => {
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
}
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
}, [
parentIssueId,
projectId,
setSubIssueHelpers,
subIssueHelpers.issue_visibility,
subIssueOperations,
workspaceSlug,
]);
useEffect(() => {
handleFetchSubIssues();
return () => {
handleFetchSubIssues();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentIssueId]);
return (
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<IssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={6}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
{issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.parentIssueId &&
issueCrudState.delete.issue.id && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await subIssueOperations.deleteSubIssue(
workspaceSlug,
projectId,
issueCrudState?.delete?.parentIssueId as string,
issueCrudState?.delete?.issue?.id as string
)
}
isSubIssue
/>
)}
{issueCrudState?.update?.toggle && issueCrudState?.update?.issue && (
<>
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await subIssueOperations.updateSubIssue(
workspaceSlug,
projectId,
parentIssueId,
_issue.id,
_issue,
issueCrudState?.update?.issue,
true
);
}}
/>
</>
)}
</>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./content";
export * from "./root";
export * from "./title";

View File

@@ -0,0 +1,40 @@
import React, { FC, useState } from "react";
import { Accordion } from "@plane/ui";
// components
import { SubIssuesAccordionContent, SubIssuesAccordionTitle } from "@/components/issues/issue-detail/central-pane";
type TSubIssuesAccordionProps = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const SubIssuesAccordion: FC<TSubIssuesAccordionProps> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Accordion
isOpen={isOpen}
handleToggle={() => setIsOpen((prev) => !prev)}
title={
<SubIssuesAccordionTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
/>
}
>
<SubIssuesAccordionContent
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
/>
</Accordion>
);
};

View File

@@ -0,0 +1,61 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { CircularProgressIndicator } from "@plane/ui";
import { useIssueDetail } from "@/hooks/store";
import { AccordionButton } from "../../common/accordion-button";
import { SubIssuesActionButton } from "../action-item-button";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
};
export const SubIssuesAccordionTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, parentIssueId, disabled } = props;
// store hooks
const {
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
} = useIssueDetail();
// derived data
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);
// if there are no sub-issues, return null
if (!subIssues) return null;
// calculate percentage of completed sub-issues
const completedCount = subIssuesDistribution?.completed?.length ?? 0;
const totalCount = subIssues.length;
const percentage = completedCount && totalCount ? (completedCount / totalCount) * 100 : 0;
// indicator element
const indicatorElement = (
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
<span>
{completedCount}/{totalCount} Done
</span>
</div>
);
return (
<AccordionButton
isOpen={isOpen}
title="Sub-issues"
indicatorElement={indicatorElement}
actionItemElement={
<SubIssuesActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
disabled={disabled}
/>
}
/>
);
});

View File

@@ -0,0 +1,199 @@
"use client";
import React, { FC, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { LayersIcon, Plus } from "lucide-react";
import { ISearchIssueResponse, TIssue } from "@plane/types";
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
export const SubIssuesActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, customButton, disabled = false } = props;
// state
const [issueCrudState, setIssueCrudState] = useState<{
create: TIssueCrudState;
existing: TIssueCrudState;
}>({
create: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
existing: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
});
// store hooks
const {
issue: { getIssueById },
createSubIssues,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
} = useIssueDetail();
const { setTrackElement } = useEventTracker();
// operations
const subIssueOperations = useMemo(
() => ({
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-issues added successfully",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error adding sub-issue",
});
}
},
}),
[createSubIssues]
);
const handleIssueCrudState = (
key: "create" | "existing",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
// derived values
const issue = getIssueById(parentIssueId);
if (!issue) return <></>;
const handleCreateNew = () => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
};
const handleAddExisting = () => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(issue.id);
};
const optionItems = [
{
label: "Create new",
icon: <Plus className="h-3 w-3" />,
onClick: handleCreateNew,
},
{
label: "Add existing",
icon: <LayersIcon className="h-3 w-3" />,
onClick: handleAddExisting,
},
];
// create update modal
const shouldRenderCreateUpdateModal =
issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen;
const createUpdateModalData = { parent_id: issueCrudState?.create?.parentIssueId };
const handleCreateUpdateModalClose = () => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
};
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
if (_issue.parent_id) {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, parentIssueId, [_issue.id]);
}
};
// existing issues modal
const shouldRenderExistingIssuesModal =
issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen;
const existingIssuesModalSearchParams = { sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId };
const handleExistingIssuesModalClose = () => {
handleIssueCrudState("existing", null, null);
toggleSubIssuesModal(null);
};
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
subIssueOperations.addSubIssue(
workspaceSlug,
projectId,
parentIssueId,
_issue.map((issue) => issue.id)
);
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<>
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
{optionItems.map((item, index) => (
<CustomMenu.MenuItem
key={index}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.onClick();
}}
>
<div className="flex items-center gap-2">
{item.icon}
<span>{item.label}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
{shouldRenderCreateUpdateModal && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.create?.toggle}
data={createUpdateModalData}
onClose={handleCreateUpdateModalClose}
onSubmit={handleCreateUpdateModalOnSubmit}
/>
)}
{shouldRenderExistingIssuesModal && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={issueCrudState?.existing?.toggle}
handleClose={handleExistingIssuesModalClose}
searchParams={existingIssuesModalSearchParams}
handleOnSubmit={handleExistingIssuesModalOnSubmit}
workspaceLevelToggle
/>
)}
</>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Layers } from "lucide-react";
// components
import { SubIssuesActionButton, CentralPaneHeaderActionButton } from "@/components/issues/issue-detail/central-pane";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const SubIssuesHeader: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
return (
<SubIssuesActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
customButton={
<CentralPaneHeaderActionButton
title="Add sub-issues"
icon={<Layers className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./header";

View File

@@ -0,0 +1,3 @@
export * from "./accordion";
export * from "./action-item-button";
export * from "./header";

View File

@@ -12,3 +12,4 @@ export * from "./root";
export * from "./sidebar";
export * from "./subscription";
export * from "./issue-detail-quick-actions";
export * from "./central-pane";

View File

@@ -2,3 +2,5 @@ export * from "./root";
export * from "./links";
export * from "./link-detail";
export * from "./link-item";
export * from "./link-list";

View File

@@ -0,0 +1,116 @@
"use client";
import { FC, useState } from "react";
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
// ui
import { Tooltip, TOAST_TYPE, setToast, CustomMenu } from "@plane/ui";
// helpers
import { calculateTimeAgoShort } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal";
type TIssueLinkItem = {
linkId: string;
linkOperations: TLinkOperationsModal;
isNotAllowed: boolean;
};
export const IssueLinkItem: FC<TIssueLinkItem> = (props) => {
// props
const { linkId, linkOperations, isNotAllowed } = props;
// hooks
const {
toggleIssueLinkModal: toggleIssueLinkModalStore,
link: { getLinkById },
} = useIssueDetail();
// state
const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false);
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIsIssueLinkModalOpen(modalToggle);
};
const { isMobile } = usePlatformOS();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
return (
<>
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModalOpen}
handleModal={toggleIssueLinkModal}
linkOperations={linkOperations}
preloadedData={linkDetail}
/>
<div
key={linkId}
className="col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-8 flex-shrink-0 px-3 bg-custom-background-90 border-[0.5px] border-custom-border-200 rounded"
>
<div className="flex items-center gap-2.5 truncate">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
<span
className="truncate text-xs cursor-pointer"
onClick={() => {
copyTextToClipboard(linkDetail.url);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied!",
message: "Link copied to clipboard",
});
}}
>
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-1">
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-300">
{calculateTimeAgoShort(linkDetail.created_at)}
</p>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="relative grid place-items-center rounded p-1 text-custom-text-300 outline-none hover:text-custom-text-200 cursor-pointer hover:bg-custom-background-80"
>
<ExternalLink className="h-3.5 w-3.5 stroke-[1.5]" />
</a>
<CustomMenu
ellipsis
buttonClassName="text-custom-text-300 hover:text-custom-text-200"
placement="bottom-end"
closeOnSelect
disabled={isNotAllowed}
>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
linkOperations.remove(linkDetail.id);
}}
>
<Trash2 className="h-3 w-3" />
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,38 @@
import { FC } from "react";
import { observer } from "mobx-react";
// computed
import { useIssueDetail } from "@/hooks/store";
import { IssueLinkItem } from "./link-item";
// hooks
import { TLinkOperations } from "./root";
type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
type TLinkList = {
issueId: string;
linkOperations: TLinkOperationsModal;
disabled?: boolean;
};
export const LinkList: FC<TLinkList> = observer((props) => {
// props
const { issueId, linkOperations, disabled = false } = props;
// hooks
const {
link: { getLinksByIssueId },
} = useIssueDetail();
const issueLinks = getLinksByIssueId(issueId);
if (!issueLinks) return <></>;
return (
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
{issueLinks &&
issueLinks.length > 0 &&
issueLinks.map((linkId) => (
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
))}
</div>
);
});

View File

@@ -1,7 +1,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
// computed
import { useIssueDetail, useUser } from "@/hooks/store";
import { useIssueDetail } from "@/hooks/store";
import { IssueLinkDetail } from "./link-detail";
// hooks
import { TLinkOperations } from "./root";
@@ -11,34 +11,27 @@ export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
export type TIssueLinkList = {
issueId: string;
linkOperations: TLinkOperationsModal;
disabled?: boolean;
};
export const IssueLinkList: FC<TIssueLinkList> = observer((props) => {
// props
const { issueId, linkOperations } = props;
const { issueId, linkOperations, disabled = false } = props;
// hooks
const {
link: { getLinksByIssueId },
} = useIssueDetail();
const {
membership: { currentProjectRole },
} = useUser();
const issueLinks = getLinksByIssueId(issueId);
if (!issueLinks) return <></>;
return (
<div className="space-y-2">
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
{issueLinks &&
issueLinks.length > 0 &&
issueLinks.map((linkId) => (
<IssueLinkDetail
key={linkId}
linkId={linkId}
linkOperations={linkOperations}
isNotAllowed={currentProjectRole === 5 || currentProjectRole === 10}
/>
<IssueLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
))}
</div>
);

View File

@@ -126,7 +126,7 @@ export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
</div>
<div>
<IssueLinkList issueId={issueId} linkOperations={handleLinkOperations} />
<IssueLinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />
</div>
</div>
</>

View File

@@ -7,18 +7,19 @@ import { TIssue } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
// components
import { IssueAttachmentRoot, IssueUpdateStatus } from "@/components/issues";
import {
CentralPane,
IssueActivity,
IssueDescriptionInput,
IssueParentDetail,
IssueReaction,
IssueTitleInput,
IssueUpdateStatus,
TIssueOperations,
} from "@/components/issues";
// hooks
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// components
import { IssueDescriptionInput } from "../description-input";
import { SubIssuesRoot } from "../sub-issues";
import { IssueTitleInput } from "../title-input";
import { IssueActivity } from "./issue-activity";
import { IssueParentDetail } from "./parent";
import { IssueReaction } from "./reactions";
import { TIssueOperations } from "./root";
type Props = {
workspaceSlug: string;
@@ -113,25 +114,9 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
disabled={isArchived}
/>
)}
{currentUser && (
<SubIssuesRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
currentUser={currentUser}
disabled={!isEditable}
/>
)}
</div>
<div className="pl-3">
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isEditable}
/>
<CentralPane workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={!isEditable} />
</div>
<div className="pl-3">

View File

@@ -380,7 +380,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/>
) : (
<div className="flex h-full w-full overflow-hidden">
<div className="max-w-2/3 h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto px-6 py-5">
<div className="max-w-2/3 h-full w-full space-y-8 overflow-y-auto px-6 py-5">
<IssueMainContent
workspaceSlug={workspaceSlug}
swrIssueDetails={swrIssueDetails}

View File

@@ -2,22 +2,10 @@
import React from "react";
import { observer } from "mobx-react";
import {
CalendarCheck2,
CalendarClock,
CircleDot,
CopyPlus,
LayoutPanelTop,
Signal,
Tag,
Triangle,
UserCircle2,
Users,
XCircle,
} from "lucide-react";
import { CalendarCheck2, CalendarClock, LayoutPanelTop, Signal, Tag, Triangle, UserCircle2, Users } from "lucide-react";
// hooks
// components
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip } from "@plane/ui";
import { ContrastIcon, DiceIcon, DoubleCircleIcon, Tooltip } from "@plane/ui";
import {
DateDropdown,
EstimateDropdown,
@@ -28,14 +16,7 @@ import {
// ui
// helpers
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import {
IssueCycleSelect,
IssueLabel,
IssueLinkRoot,
IssueModuleSelect,
IssueParentSelect,
IssueRelationSelect,
} from "@/components/issues";
import { IssueCycleSelect, IssueLabel, IssueModuleSelect, IssueParentSelect } from "@/components/issues";
// helpers
// types
import { cn } from "@/helpers/common.helper";
@@ -294,66 +275,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
/>
</div>
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
<RelatedIcon className="h-4 w-4 flex-shrink-0" />
<span>Relates to</span>
</div>
<IssueRelationSelect
className="h-full min-h-8 w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="relates_to"
disabled={!isEditable}
/>
</div>
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
<XCircle className="h-4 w-4 flex-shrink-0" />
<span>Blocking</span>
</div>
<IssueRelationSelect
className="h-full min-h-8 w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="blocking"
disabled={!isEditable}
/>
</div>
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
<CircleDot className="h-4 w-4 flex-shrink-0" />
<span>Blocked by</span>
</div>
<IssueRelationSelect
className="h-full min-h-8 w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="blocked_by"
disabled={!isEditable}
/>
</div>
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
<CopyPlus className="h-4 w-4 flex-shrink-0" />
<span>Duplicate of</span>
</div>
<IssueRelationSelect
className="h-full min-h-8 w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="duplicate"
disabled={!isEditable}
/>
</div>
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
<Tag className="h-4 w-4 flex-shrink-0" />
@@ -369,8 +290,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
</div>
</div>
<IssueLinkRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={!isEditable} />
</div>
</div>
</>

View File

@@ -0,0 +1,3 @@
export * from "./issue-list";
export * from "./issue-list-item";
export * from "./properties";

View File

@@ -0,0 +1,166 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { X, Pencil, Trash, Link as LinkIcon } from "lucide-react";
import { TIssue, TIssueRelationTypes } from "@plane/types";
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
// components
import { RelationIssueProperty } from "@/components/issues/relations";
// hooks
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import { TRelationIssueOperations } from "../issue-detail";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
relationKey: TIssueRelationTypes;
relationIssueId: string;
disabled: boolean;
issueOperations: TRelationIssueOperations;
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
};
export const RelationIssueListItem: FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
relationKey,
relationIssueId,
disabled = false,
issueOperations,
handleIssueCrudState,
} = props;
// store hooks
const {
issue: { getIssueById },
getIsIssuePeeked,
setPeekIssue,
removeRelation,
toggleCreateIssueModal,
toggleDeleteIssueModal,
} = useIssueDetail();
const project = useProject();
const { getProjectStates } = useProjectState();
const { isMobile } = usePlatformOS();
// derived values
const issue = getIssueById(relationIssueId);
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
const currentIssueStateDetail =
(issue?.project_id && getProjectStates(issue?.project_id)?.find((state) => issue?.state_id == state.id)) ||
undefined;
if (!issue) return <></>;
// handlers
const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug &&
issue &&
issue.project_id &&
issue.id &&
!getIsIssuePeeked(issue.id) &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
const handleEditIssue = () => {
handleIssueCrudState("update", relationIssueId, { ...issue });
toggleCreateIssueModal(true);
};
const handleDeleteIssue = () => {
handleIssueCrudState("delete", relationIssueId, issue);
toggleDeleteIssueModal(relationIssueId);
};
const handleCopyIssueLink = () =>
issueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`);
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId);
};
return (
<div key={relationIssueId}>
{issue && (
<div className="group relative flex min-h-11 h-full w-full items-center gap-3 px-1.5 py-1 transition-all hover:bg-custom-background-90">
<span className="size-5 flex-shrink-0" />
<div className="flex w-full cursor-pointer items-center gap-2">
<div
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
}}
/>
<div className="flex-shrink-0 text-xs text-custom-text-200">
{projectDetail?.identifier}-{issue?.sequence_id}
</div>
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
>
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span>{issue.name}</span>
</Tooltip>
</ControlLink>
</div>
<div className="flex-shrink-0 text-sm">
<RelationIssueProperty
workspaceSlug={workspaceSlug}
issueId={relationIssueId}
disabled={disabled}
issueOperations={issueOperations}
/>
</div>
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
{!disabled && (
<CustomMenu.MenuItem onClick={handleEditIssue}>
<div className="flex items-center gap-2">
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>Copy issue link</span>
</div>
</CustomMenu.MenuItem>
{!disabled && (
<CustomMenu.MenuItem onClick={handleRemoveRelation}>
<div className="flex items-center gap-2">
<X className="h-3.5 w-3.5" strokeWidth={2} />
<span>Remove relation</span>
</div>
</CustomMenu.MenuItem>
)}
<hr className="border-custom-border-300" />
{!disabled && (
<CustomMenu.MenuItem onClick={handleDeleteIssue}>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,53 @@
"use client";
import React, { FC, Fragment } from "react";
import { observer } from "mobx-react";
import { TIssue, TIssueRelationTypes } from "@plane/types";
// components
import { RelationIssueListItem } from "@/components/issues/relations";
// types
import { TRelationIssueOperations } from "../issue-detail";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueIds: string[];
relationKey: TIssueRelationTypes;
issueOperations: TRelationIssueOperations;
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
disabled?: boolean;
};
export const RelationIssueList: FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
issueIds,
relationKey,
disabled = false,
issueOperations,
handleIssueCrudState,
} = props;
return (
<div className="relative">
{issueIds &&
issueIds.length > 0 &&
issueIds.map((relationIssueId) => (
<Fragment key={relationIssueId}>
<RelationIssueListItem
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey={relationKey}
relationIssueId={relationIssueId}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
issueOperations={issueOperations}
/>
</Fragment>
))}
</div>
);
});

View File

@@ -0,0 +1,86 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import { TIssuePriorities } from "@plane/types";
import { PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TRelationIssueOperations } from "../issue-detail";
type Props = {
workspaceSlug: string;
issueId: string;
disabled: boolean;
issueOperations: TRelationIssueOperations;
};
export const RelationIssueProperty: FC<Props> = observer((props) => {
const { workspaceSlug, issueId, disabled, issueOperations } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived value
const issue = getIssueById(issueId);
// if issue is not found, return empty
if (!issue) return <></>;
// handlers
const handleStateChange = (val: string) =>
issue.project_id &&
issueOperations.update(workspaceSlug, issue.project_id, issueId, {
state_id: val,
});
const handlePriorityChange = (val: TIssuePriorities) =>
issue.project_id &&
issueOperations.update(workspaceSlug, issue.project_id, issueId, {
priority: val,
});
const handleAssigneeChange = (val: string[]) =>
issue.project_id &&
issueOperations.update(workspaceSlug, issue.project_id, issueId, {
assignee_ids: val,
});
return (
<div className="relative flex items-center gap-2">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={handleStateChange}
disabled={disabled}
buttonVariant="border-with-text"
/>
</div>
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={handlePriorityChange}
disabled={disabled}
buttonVariant="border-without-text"
buttonClassName="border"
/>
</div>
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
projectId={issue.project_id ?? undefined}
onChange={handleAssigneeChange}
disabled={disabled}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</div>
);
});

View File

@@ -82,10 +82,10 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
<div key={issueId}>
{issue && (
<div
className="group relative flex h-full w-full items-center gap-2 border-b border-custom-border-100 px-2 py-1 transition-all hover:bg-custom-background-90"
className="group relative flex min-h-11 h-full w-full items-center gap-3 pr-2 py-1 transition-all hover:bg-custom-background-90"
style={{ paddingLeft: `${spacingLeft}px` }}
>
<div className="h-[22px] w-[22px] flex-shrink-0">
<div className="flex size-5 items-center justify-center flex-shrink-0">
{/* disable the chevron when current issue is also the root issue*/}
{subIssueCount > 0 && !isCurrentIssueRoot && (
<>
@@ -95,7 +95,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
</div>
) : (
<div
className="flex h-full w-full cursor-pointer items-center justify-center rounded-sm transition-all hover:bg-custom-background-80"
className="flex h-full w-full cursor-pointer items-center justify-center text-custom-text-400 hover:text-custom-text-300"
onClick={async () => {
if (!subIssueHelpers.issue_visibility.includes(issueId)) {
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
@@ -106,10 +106,10 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
}}
>
<ChevronRight
className={cn("h-3 w-3 transition-all", {
className={cn("size-3.5 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
})}
strokeWidth={2}
strokeWidth={2.5}
/>
</div>
)}
@@ -119,9 +119,9 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
<div className="flex w-full cursor-pointer items-center gap-2">
<div
className="h-[6px] w-[6px] flex-shrink-0 rounded-full"
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: currentIssueStateDetail?.color,
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
}}
/>
<div className="flex-shrink-0 text-xs text-custom-text-200">
@@ -166,6 +166,33 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() =>
subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`)
}
>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>Copy issue link</span>
</div>
</CustomMenu.MenuItem>
{disabled && (
<CustomMenu.MenuItem
onClick={() => {
issue.project_id &&
subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id);
}}
>
<div className="flex items-center gap-2">
<X className="h-3.5 w-3.5" strokeWidth={2} />
<span>Remove parent issue</span>
</div>
</CustomMenu.MenuItem>
)}
<hr className="border-custom-border-300" />
{disabled && (
<CustomMenu.MenuItem
onClick={() => {
@@ -179,55 +206,27 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() =>
subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`)
}
>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>Copy issue link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
{disabled && (
<>
{subIssueHelpers.issue_loader.includes(issue.id) ? (
<div className="flex h-[22px] w-[22px] flex-shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm transition-all">
<Loader width={14} strokeWidth={2} className="animate-spin" />
</div>
) : (
<div
className="invisible flex h-[22px] w-[22px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80 group-hover:visible"
onClick={() => {
issue.project_id &&
subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id);
}}
>
<X width={14} strokeWidth={2} />
</div>
)}
</>
)}
</div>
)}
{/* should not expand the current issue if it is also the root issue*/}
{subIssueHelpers.issue_visibility.includes(issueId) && issue.project_id && subIssueCount > 0 && !isCurrentIssueRoot && (
<IssueList
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
parentIssueId={issue.id}
rootIssueId={rootIssueId}
spacingLeft={spacingLeft + 22}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
{subIssueHelpers.issue_visibility.includes(issueId) &&
issue.project_id &&
subIssueCount > 0 &&
!isCurrentIssueRoot && (
<IssueList
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
parentIssueId={issue.id}
rootIssueId={rootIssueId}
spacingLeft={spacingLeft + 22}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
</div>
);
});

View File

@@ -42,31 +42,24 @@ export const IssueList: FC<IIssueList> = observer((props) => {
const subIssueIds = subIssuesByIssueId(parentIssueId);
return (
<>
<div className="relative">
{subIssueIds &&
subIssueIds.length > 0 &&
subIssueIds.map((issueId) => (
<Fragment key={issueId}>
<IssueListItem
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={issueId}
spacingLeft={spacingLeft}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
</Fragment>
))}
<div
className={`absolute bottom-0 top-0 ${spacingLeft > 10 ? `border-l border-custom-border-100` : ``}`}
style={{ left: `${spacingLeft - 12}px` }}
/>
</div>
</>
<div className="relative">
{subIssueIds &&
subIssueIds.length > 0 &&
subIssueIds.map((issueId) => (
<Fragment key={issueId}>
<IssueListItem
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={issueId}
spacingLeft={spacingLeft}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
</Fragment>
))}
</div>
);
});

View File

@@ -395,18 +395,16 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
</div>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<div className="border border-b-0 border-custom-border-100">
<IssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={10}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
</div>
<IssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={10}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
</>
) : (

View File

@@ -104,10 +104,13 @@ export class IssueLinkStore implements IIssueLinkStore {
createLink = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssueLink>) => {
try {
const response = await this.issueService.createIssueLink(workspaceSlug, projectId, issueId, data);
const issueLinkCount = this.getLinksByIssueId(issueId)?.length ?? 0;
runInAction(() => {
this.links[issueId].push(response.id);
set(this.linkMap, response.id, response);
this.rootIssueDetailStore.rootIssueStore.issues.updateIssue(issueId, {
link_count: issueLinkCount + 1, // increment link count
});
});
// fetching activity
@@ -145,6 +148,7 @@ export class IssueLinkStore implements IIssueLinkStore {
removeLink = async (workspaceSlug: string, projectId: string, issueId: string, linkId: string) => {
try {
const issueLinkCount = this.getLinksByIssueId(issueId)?.length ?? 0;
await this.issueService.deleteIssueLink(workspaceSlug, projectId, issueId, linkId);
const linkIndex = this.links[issueId].findIndex((_comment) => _comment === linkId);
@@ -152,6 +156,9 @@ export class IssueLinkStore implements IIssueLinkStore {
runInAction(() => {
this.links[issueId].splice(linkIndex, 1);
delete this.linkMap[linkId];
this.rootIssueDetailStore.rootIssueStore.issues.updateIssue(issueId, {
link_count: issueLinkCount - 1, // decrement link count
});
});
// fetching activity

View File

@@ -143,6 +143,43 @@ export const calculateTimeAgo = (time: string | number | Date | null): string =>
return distance;
};
export function calculateTimeAgoShort(date: string | number | Date | null): string {
if (!date) {
return "";
}
const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date);
const now = new Date();
const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000;
if (diffInSeconds < 60) {
return `${Math.floor(diffInSeconds)}s`;
}
const diffInMinutes = diffInSeconds / 60;
if (diffInMinutes < 60) {
return `${Math.floor(diffInMinutes)}m`;
}
const diffInHours = diffInMinutes / 60;
if (diffInHours < 24) {
return `${Math.floor(diffInHours)}h`;
}
const diffInDays = diffInHours / 24;
if (diffInDays < 30) {
return `${Math.floor(diffInDays)}d`;
}
const diffInMonths = diffInDays / 30;
if (diffInMonths < 12) {
return `${Math.floor(diffInMonths)}mo`;
}
const diffInYears = diffInMonths / 12;
return `${Math.floor(diffInYears)}y`;
}
// Date Validation Helpers
/**
* @returns {string} boolean value depending on whether the date is greater than today