[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:
parent
b7d792ed07
commit
387dbd89f5
45 changed files with 2385 additions and 196 deletions
|
|
@ -459,10 +459,14 @@ class IssueLinkSerializer(BaseSerializer):
|
||||||
return IssueLink.objects.create(**validated_data)
|
return IssueLink.objects.create(**validated_data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
if IssueLink.objects.filter(
|
if (
|
||||||
|
IssueLink.objects.filter(
|
||||||
url=validated_data.get("url"),
|
url=validated_data.get("url"),
|
||||||
issue_id=instance.issue_id,
|
issue_id=instance.issue_id,
|
||||||
).exclude(pk=instance.id).exists():
|
)
|
||||||
|
.exclude(pk=instance.id)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"error": "URL already exists for this Issue"}
|
{"error": "URL already exists for this Issue"}
|
||||||
)
|
)
|
||||||
|
|
@ -509,7 +513,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||||
"attributes",
|
"attributes",
|
||||||
"issue_id",
|
"issue_id",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"updated_by_id",
|
"updated_by",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import json
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
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.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
|
# Third Party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
@ -20,6 +23,9 @@ from plane.app.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Project,
|
Project,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
|
Issue,
|
||||||
|
IssueAttachment,
|
||||||
|
IssueLink,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
|
|
||||||
|
|
@ -61,56 +67,149 @@ class IssueRelationViewSet(BaseViewSet):
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
# get all blocking issues
|
||||||
blocking_issues = issue_relations.filter(
|
blocking_issues = issue_relations.filter(
|
||||||
relation_type="blocked_by", related_issue_id=issue_id
|
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(
|
blocked_by_issues = issue_relations.filter(
|
||||||
relation_type="blocked_by", issue_id=issue_id
|
relation_type="blocked_by", issue_id=issue_id
|
||||||
)
|
).values_list("related_issue_id", flat=True)
|
||||||
|
|
||||||
|
# get all duplicate issues
|
||||||
duplicate_issues = issue_relations.filter(
|
duplicate_issues = issue_relations.filter(
|
||||||
issue_id=issue_id, relation_type="duplicate"
|
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(
|
duplicate_issues_related = issue_relations.filter(
|
||||||
related_issue_id=issue_id, relation_type="duplicate"
|
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(
|
relates_to_issues = issue_relations.filter(
|
||||||
issue_id=issue_id, relation_type="relates_to"
|
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(
|
relates_to_issues_related = issue_relations.filter(
|
||||||
related_issue_id=issue_id, relation_type="relates_to"
|
related_issue_id=issue_id, relation_type="relates_to"
|
||||||
|
).values_list("issue_id", flat=True)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
blocked_by_issues_serialized = IssueRelationSerializer(
|
# Fields
|
||||||
blocked_by_issues, many=True
|
fields = [
|
||||||
).data
|
"id",
|
||||||
duplicate_issues_serialized = IssueRelationSerializer(
|
"name",
|
||||||
duplicate_issues, many=True
|
"state_id",
|
||||||
).data
|
"sort_order",
|
||||||
relates_to_issues_serialized = IssueRelationSerializer(
|
"priority",
|
||||||
relates_to_issues, many=True
|
"sequence_id",
|
||||||
).data
|
"project_id",
|
||||||
|
"label_ids",
|
||||||
# revere relation for blocked by issues
|
"assignee_ids",
|
||||||
blocking_issues_serialized = RelatedIssueSerializer(
|
"created_at",
|
||||||
blocking_issues, many=True
|
"updated_at",
|
||||||
).data
|
"created_by",
|
||||||
# reverse relation for duplicate issues
|
"updated_by",
|
||||||
duplicate_issues_related_serialized = RelatedIssueSerializer(
|
"relation_type",
|
||||||
duplicate_issues_related, many=True
|
]
|
||||||
).data
|
|
||||||
# reverse relation for related issues
|
|
||||||
relates_to_issues_related_serialized = RelatedIssueSerializer(
|
|
||||||
relates_to_issues_related, many=True
|
|
||||||
).data
|
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"blocking": blocking_issues_serialized,
|
"blocking": queryset.filter(pk__in=blocking_issues)
|
||||||
"blocked_by": blocked_by_issues_serialized,
|
.annotate(
|
||||||
"duplicate": duplicate_issues_serialized
|
relation_type=Value("blocking", output_field=CharField())
|
||||||
+ duplicate_issues_related_serialized,
|
)
|
||||||
"relates_to": relates_to_issues_serialized
|
.values(*fields),
|
||||||
+ relates_to_issues_related_serialized,
|
"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)
|
return Response(response_data, status=status.HTTP_200_OK)
|
||||||
|
|
|
||||||
33
packages/ui/src/collapsible/collapsible-button.tsx
Normal file
33
packages/ui/src/collapsible/collapsible-button.tsx
Normal file
|
|
@ -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 +1,2 @@
|
||||||
export * from "./collapsible";
|
export * from "./collapsible";
|
||||||
|
export * from "./collapsible-button";
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,4 @@ export * from "./related-icon";
|
||||||
export * from "./side-panel-icon";
|
export * from "./side-panel-icon";
|
||||||
export * from "./transfer-icon";
|
export * from "./transfer-icon";
|
||||||
export * from "./info-icon";
|
export * from "./info-icon";
|
||||||
export * from "./relations-icon";
|
|
||||||
export * from "./dropdown-icon";
|
export * from "./dropdown-icon";
|
||||||
|
|
|
||||||
|
|
@ -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
|
<CustomMenu.MenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export * from "./label";
|
||||||
export * from "./confirm-issue-discard";
|
export * from "./confirm-issue-discard";
|
||||||
export * from "./issue-update-status";
|
export * from "./issue-update-status";
|
||||||
export * from "./create-issue-toast-action-items";
|
export * from "./create-issue-toast-action-items";
|
||||||
|
export * from "./relations";
|
||||||
|
export * from "./issue-detail-widgets";
|
||||||
|
|
||||||
// issue details
|
// issue details
|
||||||
export * from "./issue-detail";
|
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}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
8
web/core/components/issues/issue-detail-widgets/index.ts
Normal file
8
web/core/components/issues/issue-detail-widgets/index.ts
Normal file
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
34
web/core/components/issues/issue-detail-widgets/root.tsx
Normal file
34
web/core/components/issues/issue-detail-widgets/root.tsx
Normal file
|
|
@ -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
|
// ui
|
||||||
import { StateGroupIcon } from "@plane/ui";
|
import { StateGroupIcon } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { IssueAttachmentRoot, IssueUpdateStatus } from "@/components/issues";
|
import {
|
||||||
|
IssueActivity,
|
||||||
|
IssueUpdateStatus,
|
||||||
|
IssueReaction,
|
||||||
|
IssueParentDetail,
|
||||||
|
IssueTitleInput,
|
||||||
|
IssueDescriptionInput,
|
||||||
|
IssueDetailWidgets,
|
||||||
|
} from "@/components/issues";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
|
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
|
||||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||||
// components
|
// types
|
||||||
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";
|
import { TIssueOperations } from "./root";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -113,20 +115,10 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||||
disabled={isArchived}
|
disabled={isArchived}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentUser && (
|
|
||||||
<SubIssuesRoot
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
parentIssueId={issueId}
|
|
||||||
currentUser={currentUser}
|
|
||||||
disabled={!isEditable}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-3">
|
<div className="pl-3">
|
||||||
<IssueAttachmentRoot
|
<IssueDetailWidgets
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
|
|
|
||||||
|
|
@ -348,7 +348,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full overflow-hidden">
|
<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
|
<IssueMainContent
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
swrIssueDetails={swrIssueDetails}
|
swrIssueDetails={swrIssueDetails}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,10 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import {
|
import { CalendarCheck2, CalendarClock, Signal, Tag, Triangle, UserCircle2, Users } from "lucide-react";
|
||||||
CalendarCheck2,
|
// ui
|
||||||
CalendarClock,
|
import { ContrastIcon, DiceIcon, DoubleCircleIcon, Tooltip } from "@plane/ui";
|
||||||
CircleDot,
|
|
||||||
CopyPlus,
|
|
||||||
LayoutPanelTop,
|
|
||||||
Signal,
|
|
||||||
Tag,
|
|
||||||
Triangle,
|
|
||||||
UserCircle2,
|
|
||||||
Users,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
// components
|
// components
|
||||||
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip } from "@plane/ui";
|
|
||||||
import {
|
import {
|
||||||
DateDropdown,
|
DateDropdown,
|
||||||
EstimateDropdown,
|
EstimateDropdown,
|
||||||
|
|
@ -25,30 +13,17 @@ import {
|
||||||
PriorityDropdown,
|
PriorityDropdown,
|
||||||
StateDropdown,
|
StateDropdown,
|
||||||
} from "@/components/dropdowns";
|
} from "@/components/dropdowns";
|
||||||
// ui
|
|
||||||
// helpers
|
|
||||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||||
import {
|
import { IssueCycleSelect, IssueLabel, IssueModuleSelect } from "@/components/issues";
|
||||||
IssueCycleSelect,
|
|
||||||
IssueLabel,
|
|
||||||
IssueLinkRoot,
|
|
||||||
IssueModuleSelect,
|
|
||||||
IssueParentSelect,
|
|
||||||
IssueRelationSelect,
|
|
||||||
} from "@/components/issues";
|
|
||||||
// helpers
|
// helpers
|
||||||
// types
|
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||||
// types
|
// hooks
|
||||||
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
|
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
// components
|
// components
|
||||||
import type { TIssueOperations } from "./root";
|
import type { TIssueOperations } from "./root";
|
||||||
// icons
|
|
||||||
// helpers
|
|
||||||
// types
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -279,81 +254,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||||
</div>
|
</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 min-h-8 gap-2">
|
||||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
<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" />
|
<Tag className="h-4 w-4 flex-shrink-0" />
|
||||||
|
|
@ -369,8 +269,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IssueLinkRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={!isEditable} />
|
|
||||||
</div>
|
</div>
|
||||||
</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";
|
||||||
165
web/core/components/issues/relations/issue-list-item.tsx
Normal file
165
web/core/components/issues/relations/issue-list-item.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
52
web/core/components/issues/relations/issue-list.tsx
Normal file
52
web/core/components/issues/relations/issue-list.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
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-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>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<hr className="border-custom-border-300" />
|
|
||||||
|
|
||||||
{disabled && (
|
{disabled && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue