mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
34 Commits
fix-api-er
...
chore/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d86be92754 | ||
|
|
c423b6d090 | ||
|
|
6e97178e1d | ||
|
|
88d5327334 | ||
|
|
e528b431be | ||
|
|
73824af1f8 | ||
|
|
58c503a4a0 | ||
|
|
bfd7820961 | ||
|
|
606d379563 | ||
|
|
6f0a2eda6c | ||
|
|
17a95f306b | ||
|
|
0f9ceaefa5 | ||
|
|
7e3cf69cd6 | ||
|
|
aaf9e180db | ||
|
|
07f2426574 | ||
|
|
4e71b78a19 | ||
|
|
f27b54dda8 | ||
|
|
87a0dde61d | ||
|
|
0843785330 | ||
|
|
0dee8a5248 | ||
|
|
60b5210106 | ||
|
|
5b744edd6d | ||
|
|
9796bdbf02 | ||
|
|
d67ee43670 | ||
|
|
e3b7f6776e | ||
|
|
424cbca9e6 | ||
|
|
68d30f502f | ||
|
|
74a8062dc4 | ||
|
|
a83801920f | ||
|
|
625380f544 | ||
|
|
ed720f8ff6 | ||
|
|
299e1a6561 | ||
|
|
aa8658ad8c | ||
|
|
715a52d327 |
@@ -509,7 +509,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
"attributes",
|
||||
"issue_id",
|
||||
"updated_at",
|
||||
"updated_by_id",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
packages/ui/src/accordion/accordion.tsx
Normal file
49
packages/ui/src/accordion/accordion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/ui/src/accordion/index.ts
Normal file
1
packages/ui/src/accordion/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./accordion";
|
||||
@@ -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 }) => (
|
||||
<>
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
onMenuClose?: () => void;
|
||||
closeOnSelect?: boolean;
|
||||
portalElement?: Element | null;
|
||||
openOnHover?: boolean;
|
||||
}
|
||||
|
||||
export interface ICustomSelectProps extends IDropdownProps {
|
||||
|
||||
15
packages/ui/src/icons/dropdown-icon.tsx
Normal file
15
packages/ui/src/icons/dropdown-icon.tsx
Normal 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>
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
25
packages/ui/src/icons/relations-icon.tsx
Normal file
25
packages/ui/src/icons/relations-icon.tsx
Normal 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>
|
||||
);
|
||||
@@ -20,3 +20,4 @@ export * from "./drag-handle";
|
||||
export * from "./drop-indicator";
|
||||
export * from "./favorite-star";
|
||||
export * from "./loader";
|
||||
export * from "./accordion";
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
111
web/core/components/issues/attachment/attachment-list-item.tsx
Normal file
111
web/core/components/issues/attachment/attachment-list-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./content";
|
||||
export * from "./root";
|
||||
export * from "./title";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./header";
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./accordion";
|
||||
export * from "./action-item-button";
|
||||
export * from "./header";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./accordion-button";
|
||||
export * from "./header-action-button";
|
||||
@@ -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";
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./content";
|
||||
export * from "./root";
|
||||
export * from "./title";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./header";
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./accordion";
|
||||
export * from "./action-item-button";
|
||||
export * from "./header";
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./content";
|
||||
export * from "./root";
|
||||
export * from "./title";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./header";
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./accordion";
|
||||
export * from "./action-item-button";
|
||||
export * from "./header";
|
||||
export * from "./helper";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./content";
|
||||
export * from "./root";
|
||||
export * from "./title";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./header";
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./accordion";
|
||||
export * from "./action-item-button";
|
||||
export * from "./header";
|
||||
@@ -12,3 +12,4 @@ export * from "./root";
|
||||
export * from "./sidebar";
|
||||
export * from "./subscription";
|
||||
export * from "./issue-detail-quick-actions";
|
||||
export * from "./central-pane";
|
||||
|
||||
@@ -2,3 +2,5 @@ export * from "./root";
|
||||
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
||||
export * from "./link-item";
|
||||
export * from "./link-list";
|
||||
|
||||
116
web/core/components/issues/issue-detail/links/link-item.tsx
Normal file
116
web/core/components/issues/issue-detail/links/link-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
38
web/core/components/issues/issue-detail/links/link-list.tsx
Normal file
38
web/core/components/issues/issue-detail/links/link-list.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
3
web/core/components/issues/relations/index.ts
Normal file
3
web/core/components/issues/relations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./issue-list";
|
||||
export * from "./issue-list-item";
|
||||
export * from "./properties";
|
||||
166
web/core/components/issues/relations/issue-list-item.tsx
Normal file
166
web/core/components/issues/relations/issue-list-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
53
web/core/components/issues/relations/issue-list.tsx
Normal file
53
web/core/components/issues/relations/issue-list.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
86
web/core/components/issues/relations/properties.tsx
Normal file
86
web/core/components/issues/relations/properties.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user