[WEB-1679] feat: issue detail widgets (#5034)

* chore: issue detail sidebar and main content improvement and code refactor

* dev: issue relation list component added

* chore: code refactor

* dev: issue detail widget implementation

* dev: update issue relation endpoint to return same response as sub issue

* chore: changed updated_by in issue attachment

* fix: peek view link ui

* chore: move collapsible button component to plane ui package

* chore: issue list component code refactor

* chore: relation icon updated

* chore: relation icon updated

* chore: issue quick action ui updated

* chore: wrap title indicatorElement component with useMemo

* chore: code refactor

* fix: build error
This commit is contained in:
Anmol Singh Bhatia
2024-07-05 16:51:58 +05:30
committed by GitHub
parent b7d792ed07
commit 387dbd89f5
45 changed files with 2385 additions and 196 deletions
+9 -5
View File
@@ -459,10 +459,14 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exclude(pk=instance.id).exists():
if (
IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
@@ -509,7 +513,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
"attributes",
"issue_id",
"updated_at",
"updated_by_id",
"updated_by",
]
read_only_fields = fields
+134 -35
View File
@@ -3,8 +3,11 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import Q
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third Party imports
from rest_framework.response import Response
@@ -20,6 +23,9 @@ from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Project,
IssueRelation,
Issue,
IssueAttachment,
IssueLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -61,56 +67,149 @@ class IssueRelationViewSet(BaseViewSet):
.order_by("-created_at")
.distinct()
)
# get all blocking issues
blocking_issues = issue_relations.filter(
relation_type="blocked_by", related_issue_id=issue_id
)
).values_list("issue_id", flat=True)
# get all blocked by issues
blocked_by_issues = issue_relations.filter(
relation_type="blocked_by", issue_id=issue_id
)
).values_list("related_issue_id", flat=True)
# get all duplicate issues
duplicate_issues = issue_relations.filter(
issue_id=issue_id, relation_type="duplicate"
)
).values_list("related_issue_id", flat=True)
# get all relates to issues
duplicate_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="duplicate"
)
).values_list("issue_id", flat=True)
# get all relates to issues
relates_to_issues = issue_relations.filter(
issue_id=issue_id, relation_type="relates_to"
)
).values_list("related_issue_id", flat=True)
# get all relates to issues
relates_to_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="relates_to"
)
).values_list("issue_id", flat=True)
blocked_by_issues_serialized = IssueRelationSerializer(
blocked_by_issues, many=True
).data
duplicate_issues_serialized = IssueRelationSerializer(
duplicate_issues, many=True
).data
relates_to_issues_serialized = IssueRelationSerializer(
relates_to_issues, many=True
).data
queryset = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
# revere relation for blocked by issues
blocking_issues_serialized = RelatedIssueSerializer(
blocking_issues, many=True
).data
# reverse relation for duplicate issues
duplicate_issues_related_serialized = RelatedIssueSerializer(
duplicate_issues_related, many=True
).data
# reverse relation for related issues
relates_to_issues_related_serialized = RelatedIssueSerializer(
relates_to_issues_related, many=True
).data
# Fields
fields = [
"id",
"name",
"state_id",
"sort_order",
"priority",
"sequence_id",
"project_id",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
"relation_type",
]
response_data = {
"blocking": blocking_issues_serialized,
"blocked_by": blocked_by_issues_serialized,
"duplicate": duplicate_issues_serialized
+ duplicate_issues_related_serialized,
"relates_to": relates_to_issues_serialized
+ relates_to_issues_related_serialized,
"blocking": queryset.filter(pk__in=blocking_issues)
.annotate(
relation_type=Value("blocking", output_field=CharField())
)
.values(*fields),
"blocked_by": queryset.filter(pk__in=blocked_by_issues)
.annotate(
relation_type=Value("blocked_by", output_field=CharField())
)
.values(*fields),
"duplicate": queryset.filter(pk__in=duplicate_issues)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=duplicate_issues_related)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields),
"relates_to": queryset.filter(pk__in=relates_to_issues)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=relates_to_issues_related)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields),
}
return Response(response_data, status=status.HTTP_200_OK)
@@ -0,0 +1,33 @@
import React, { FC } from "react";
import { DropdownIcon } from "../icons";
import { cn } from "../../helpers";
type Props = {
isOpen: boolean;
title: string;
hideChevron?: boolean;
indicatorElement?: React.ReactNode;
actionItemElement?: React.ReactNode;
};
export const CollapsibleButton: 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>
);
};
+1
View File
@@ -1 +1,2 @@
export * from "./collapsible";
export * from "./collapsible-button";
-1
View File
@@ -21,5 +21,4 @@ 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
View File
@@ -1,25 +0,0 @@
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>
);
@@ -90,7 +90,7 @@ export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer(
</>
)}
<CustomMenu ellipsis closeOnSelect placement="bottom-end" openOnHover disabled={disabled}>
<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
+2
View File
@@ -10,6 +10,8 @@ export * from "./label";
export * from "./confirm-issue-discard";
export * from "./issue-update-status";
export * from "./create-issue-toast-action-items";
export * from "./relations";
export * from "./issue-detail-widgets";
// issue details
export * from "./issue-detail";
@@ -0,0 +1,71 @@
"use client";
import React, { FC } from "react";
import { Layers, Link, Paperclip, Waypoints } from "lucide-react";
// components
import {
IssueAttachmentActionButton,
IssueLinksActionButton,
RelationActionButton,
SubIssuesActionButton,
IssueDetailWidgetButton,
} from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
return (
<div className="flex items-center flex-wrap gap-2">
<SubIssuesActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
customButton={
<IssueDetailWidgetButton
title="Add sub-issues"
icon={<Layers className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
<RelationActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title="Add Relation"
icon={<Waypoints className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
<IssueLinksActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title="Add Links"
icon={<Link className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title="Attach"
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
</div>
);
};
@@ -0,0 +1,27 @@
"use client";
import React, { FC } from "react";
// components
import { IssueAttachmentItemList } from "@/components/issues/attachment";
// helper
import { useAttachmentOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueAttachmentsCollapsibleContent: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// helper
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId);
return (
<IssueAttachmentItemList
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
);
};
@@ -0,0 +1,90 @@
"use client";
import { useMemo } from "react";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// type
import { TAttachmentOperations } from "@/components/issues/attachment";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
export const useAttachmentOperations = (
workspaceSlug: string,
projectId: string,
issueId: string
): TAttachmentOperations => {
const { createAttachment, removeAttachment } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
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",
});
}
},
}),
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
return handleAttachmentOperations;
};
@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -0,0 +1,66 @@
"use client";
import React, { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
import { Plus } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "@/constants/common";
// helper
import { generateFileName } from "@/helpers/attachment.helper";
// hooks
import { useInstance } from "@/hooks/store";
import { useAttachmentOperations } from "./helper";
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;
// helper
const [isLoading, setIsLoading] = useState(false);
const { config } = useInstance();
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, 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 } = 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,43 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import {
IssueAttachmentsCollapsibleContent,
IssueAttachmentsCollapsibleTitle,
} from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const AttachmentsCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<IssueAttachmentsCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<IssueAttachmentsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -0,0 +1,54 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CollapsibleButton } from "@plane/ui";
// components
import { IssueAttachmentActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueAttachmentsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const attachmentCount = issue?.attachment_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
</span>
),
[attachmentCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Attachments"
indicatorElement={indicatorElement}
actionItemElement={
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});
@@ -0,0 +1,8 @@
export * from "./attachments";
export * from "./links";
export * from "./relations";
export * from "./root";
export * from "./sub-issues";
export * from "./widget-button";
export * from "./issue-detail-widget-collapsibles";
export * from "./action-buttons";
@@ -0,0 +1,72 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import {
AttachmentsCollapsible,
LinksCollapsible,
RelationsCollapsible,
SubIssuesCollapsible,
} from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueDetailWidgetCollapsibles: 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;
return (
<div className="flex flex-col">
{shouldRenderSubIssues && (
<SubIssuesCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
{shouldRenderRelations && (
<RelationsCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
{shouldRenderLinks && (
<LinksCollapsible workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
)}
{shouldRenderAttachments && (
<AttachmentsCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
</div>
);
});
@@ -0,0 +1,22 @@
"use client";
import React, { FC } from "react";
// components
import { LinkList } from "../../issue-detail/links";
// helper
import { useLinkOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// helper
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
return <LinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />;
};
@@ -0,0 +1,71 @@
"use client";
import { useMemo } from "react";
import { TIssueLink } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TLinkOperations } from "../../issue-detail/links";
export const useLinkOperations = (workspaceSlug: string, projectId: string, issueId: string): TLinkOperations => {
const { createLink, updateLink, removeLink } = useIssueDetail();
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",
});
} 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",
});
} 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",
});
} 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 handleLinkOperations;
};
@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -0,0 +1,58 @@
"use client";
import React, { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store";
// components
import { IssueLinkCreateUpdateModal } from "../../issue-detail/links/create-update-link-modal";
// helper
import { useLinkOperations } from "./helper";
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 } = useIssueDetail();
// helper
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
// handler
const toggleIssueLinkModal = useCallback(
(modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIsIssueLinkModal(modalToggle);
},
[toggleIssueLinkModalStore]
);
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,40 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import { IssueLinksCollapsibleContent, IssueLinksCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const LinksCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<IssueLinksCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<IssueLinksCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -0,0 +1,55 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CollapsibleButton } from "@plane/ui";
// components
import { IssueLinksActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueLinksCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const linksCount = issue?.link_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
</span>
),
[linksCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Links"
indicatorElement={indicatorElement}
actionItemElement={
<IssueLinksActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});
@@ -0,0 +1,182 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { TIssue, TIssueRelationIdMap } from "@plane/types";
import { Collapsible, RelatedIcon } from "@plane/ui";
// components
import { RelationIssueList } from "@/components/issues";
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
import { useRelationOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
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",
},
];
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// 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 },
toggleDeleteIssueModal,
toggleCreateIssueModal,
} = useIssueDetail();
// helper
const issueOperations = useRelationOperations();
// derived values
const relations = getRelationsByIssueId(issueId);
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);
const shouldRenderIssueDeleteModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.issueId &&
issueCrudState.delete.issue.id;
const shouldRenderIssueUpdateModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
<div className="flex flex-col gap-">
{filteredRelationsArray.map((relation) => (
<div key={relation.relationKey}>
<Collapsible
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}
/>
</Collapsible>
</div>
))}
</div>
{shouldRenderIssueDeleteModal && (
<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
/>
)}
{shouldRenderIssueUpdateModal && (
<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,129 @@
"use client";
import { useMemo } from "react";
import { usePathname } from "next/navigation";
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { TIssue } from "@plane/types";
import { RelatedIcon, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { ISSUE_DELETED, ISSUE_UPDATED } from "@/constants/event-tracker";
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
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>;
};
export const useRelationOperations = (): TRelationIssueOperations => {
const { updateIssue, removeIssue } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const pathname = usePathname();
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,
});
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Issue updated successfully",
});
} 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]
);
return issueOperations;
};
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",
},
];
@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -0,0 +1,96 @@
"use client";
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,40 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import { RelationsCollapsibleContent, RelationsCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const RelationsCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<RelationsCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<RelationsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -0,0 +1,54 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CollapsibleButton } from "@plane/ui";
// components
import { RelationActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const RelationsCollapsibleTitle: 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);
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
</span>
),
[relationsCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Relations"
indicatorElement={indicatorElement}
actionItemElement={
<RelationActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});
@@ -0,0 +1,34 @@
"use client";
import React, { FC } from "react";
// components
import {
IssueDetailWidgetActionButtons,
IssueDetailWidgetCollapsibles,
} from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueDetailWidgets: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
return (
<div className="flex flex-col gap-5">
<IssueDetailWidgetActionButtons
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
<IssueDetailWidgetCollapsibles
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</div>
);
};
@@ -0,0 +1,172 @@
"use client";
import React, { FC, useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { TIssue } from "@plane/types";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
import { IssueList } from "@/components/issues/sub-issues/issues-list";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
import { useSubIssueOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
};
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, disabled } = props;
// 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 },
toggleCreateIssueModal,
toggleDeleteIssueModal,
} = useIssueDetail();
// helpers
const subIssueOperations = useSubIssueOperations();
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
// handler
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 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]);
// render conditions
const shouldRenderDeleteIssueModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.parentIssueId &&
issueCrudState.delete.issue.id;
const shouldRenderUpdateIssueModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<IssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={6}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
{shouldRenderDeleteIssueModal && (
<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
/>
)}
{shouldRenderUpdateIssueModal && (
<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,178 @@
"use client";
import { useMemo } from "react";
import { usePathname } from "next/navigation";
import { TIssue } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// type
import { TSubIssueOperations } from "../../sub-issues";
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>;
};
export const useSubIssueOperations = (): TSubIssueOperations => {
const {
subIssues: { setSubIssueHelpers },
fetchSubIssues,
createSubIssues,
updateSubIssue,
removeSubIssue,
deleteSubIssue,
} = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const pathname = usePathname();
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]
);
return subIssueOperations;
};
@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -0,0 +1,182 @@
"use client";
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
import { LayersIcon, Plus } from "lucide-react";
import { ISearchIssueResponse, TIssue } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// helper
import { useSubIssueOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: 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, issueId, 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 },
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
} = useIssueDetail();
const { setTrackElement } = useEventTracker();
// helper
const subIssueOperations = useSubIssueOperations();
// handlers
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(issueId);
if (!issue) return <></>;
const handleCreateNew = () => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", issueId, null);
toggleCreateIssueModal(true);
};
const handleAddExisting = () => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", issueId, null);
toggleSubIssuesModal(issue.id);
};
const handleExistingIssuesModalClose = () => {
handleIssueCrudState("existing", null, null);
toggleSubIssuesModal(null);
};
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
subIssueOperations.addSubIssue(
workspaceSlug,
projectId,
issueId,
_issue.map((issue) => issue.id)
);
const handleCreateUpdateModalClose = () => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
};
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
if (_issue.parent_id) {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, issueId, [_issue.id]);
}
};
// options
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 };
// existing issues modal
const shouldRenderExistingIssuesModal =
issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen;
const existingIssuesModalSearchParams = { sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId };
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,40 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import { SubIssuesCollapsibleContent, SubIssuesCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const SubIssuesCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<SubIssuesCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
/>
}
>
<SubIssuesCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -0,0 +1,65 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
// components
import { SubIssuesActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
};
export const SubIssuesCollapsibleTitle: 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 = useMemo(
() => (
<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>
),
[completedCount, totalCount, percentage]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Sub-issues"
indicatorElement={indicatorElement}
actionItemElement={
<SubIssuesActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={parentIssueId}
disabled={disabled}
/>
}
/>
);
});
@@ -0,0 +1,17 @@
"use client";
import React, { FC } from "react";
type Props = {
icon: JSX.Element;
title: string;
};
export const IssueDetailWidgetButton: 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>
);
};
@@ -7,17 +7,19 @@ import { TIssue } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
// components
import { IssueAttachmentRoot, IssueUpdateStatus } from "@/components/issues";
import {
IssueActivity,
IssueUpdateStatus,
IssueReaction,
IssueParentDetail,
IssueTitleInput,
IssueDescriptionInput,
IssueDetailWidgets,
} 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";
// types
import { TIssueOperations } from "./root";
type Props = {
@@ -113,20 +115,10 @@ 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
<IssueDetailWidgets
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
@@ -348,7 +348,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";
// hooks
import { CalendarCheck2, CalendarClock, Signal, Tag, Triangle, UserCircle2, Users } from "lucide-react";
// ui
import { ContrastIcon, DiceIcon, DoubleCircleIcon, Tooltip } from "@plane/ui";
// components
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip } from "@plane/ui";
import {
DateDropdown,
EstimateDropdown,
@@ -25,30 +13,17 @@ import {
PriorityDropdown,
StateDropdown,
} from "@/components/dropdowns";
// ui
// helpers
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import {
IssueCycleSelect,
IssueLabel,
IssueLinkRoot,
IssueModuleSelect,
IssueParentSelect,
IssueRelationSelect,
} from "@/components/issues";
import { IssueCycleSelect, IssueLabel, IssueModuleSelect } from "@/components/issues";
// helpers
// types
import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// types
// hooks
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// components
import type { TIssueOperations } from "./root";
// icons
// helpers
// types
type Props = {
workspaceSlug: string;
@@ -279,81 +254,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
)}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<LayoutPanelTop className="h-4 w-4 flex-shrink-0" />
<span>Parent</span>
</div>
<IssueParentSelect
className="h-full w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
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">
<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 +269,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
</div>
</div>
<IssueLinkRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={!isEditable} />
</div>
</div>
</>
@@ -0,0 +1,3 @@
export * from "./issue-list";
export * from "./issue-list-item";
export * from "./properties";
@@ -0,0 +1,165 @@
"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-widgets/relations/helper";
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>
)}
{!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>
);
});
@@ -0,0 +1,52 @@
"use client";
import React, { FC } 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-widgets/relations/helper";
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) => (
<RelationIssueListItem
key={relationIssueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey={relationKey}
relationIssueId={relationIssueId}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
issueOperations={issueOperations}
/>
))}
</div>
);
});
@@ -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-widgets/relations/helper";
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>
);
});
@@ -191,8 +191,6 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
</CustomMenu.MenuItem>
)}
<hr className="border-custom-border-300" />
{disabled && (
<CustomMenu.MenuItem
onClick={() => {