mirror of
https://github.com/makeplane/plane.git
synced 2026-04-24 17:18:57 -05:00
[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:
committed by
GitHub
parent
b7d792ed07
commit
387dbd89f5
@@ -459,10 +459,14 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
issue_id=instance.issue_id,
|
||||
).exclude(pk=instance.id).exists():
|
||||
if (
|
||||
IssueLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
issue_id=instance.issue_id,
|
||||
)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
@@ -509,7 +513,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
"attributes",
|
||||
"issue_id",
|
||||
"updated_at",
|
||||
"updated_by_id",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -20,6 +23,9 @@ from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
IssueRelation,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
@@ -61,56 +67,149 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# get all blocking issues
|
||||
blocking_issues = issue_relations.filter(
|
||||
relation_type="blocked_by", related_issue_id=issue_id
|
||||
)
|
||||
).values_list("issue_id", flat=True)
|
||||
|
||||
# get all blocked by issues
|
||||
blocked_by_issues = issue_relations.filter(
|
||||
relation_type="blocked_by", issue_id=issue_id
|
||||
)
|
||||
).values_list("related_issue_id", flat=True)
|
||||
|
||||
# get all duplicate issues
|
||||
duplicate_issues = issue_relations.filter(
|
||||
issue_id=issue_id, relation_type="duplicate"
|
||||
)
|
||||
).values_list("related_issue_id", flat=True)
|
||||
|
||||
# get all relates to issues
|
||||
duplicate_issues_related = issue_relations.filter(
|
||||
related_issue_id=issue_id, relation_type="duplicate"
|
||||
)
|
||||
).values_list("issue_id", flat=True)
|
||||
|
||||
# get all relates to issues
|
||||
relates_to_issues = issue_relations.filter(
|
||||
issue_id=issue_id, relation_type="relates_to"
|
||||
)
|
||||
).values_list("related_issue_id", flat=True)
|
||||
|
||||
# get all relates to issues
|
||||
relates_to_issues_related = issue_relations.filter(
|
||||
related_issue_id=issue_id, relation_type="relates_to"
|
||||
)
|
||||
).values_list("issue_id", flat=True)
|
||||
|
||||
blocked_by_issues_serialized = IssueRelationSerializer(
|
||||
blocked_by_issues, many=True
|
||||
).data
|
||||
duplicate_issues_serialized = IssueRelationSerializer(
|
||||
duplicate_issues, many=True
|
||||
).data
|
||||
relates_to_issues_serialized = IssueRelationSerializer(
|
||||
relates_to_issues, many=True
|
||||
).data
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=~Q(labels__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
# revere relation for blocked by issues
|
||||
blocking_issues_serialized = RelatedIssueSerializer(
|
||||
blocking_issues, many=True
|
||||
).data
|
||||
# reverse relation for duplicate issues
|
||||
duplicate_issues_related_serialized = RelatedIssueSerializer(
|
||||
duplicate_issues_related, many=True
|
||||
).data
|
||||
# reverse relation for related issues
|
||||
relates_to_issues_related_serialized = RelatedIssueSerializer(
|
||||
relates_to_issues_related, many=True
|
||||
).data
|
||||
# Fields
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"priority",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"relation_type",
|
||||
]
|
||||
|
||||
response_data = {
|
||||
"blocking": blocking_issues_serialized,
|
||||
"blocked_by": blocked_by_issues_serialized,
|
||||
"duplicate": duplicate_issues_serialized
|
||||
+ duplicate_issues_related_serialized,
|
||||
"relates_to": relates_to_issues_serialized
|
||||
+ relates_to_issues_related_serialized,
|
||||
"blocking": queryset.filter(pk__in=blocking_issues)
|
||||
.annotate(
|
||||
relation_type=Value("blocking", output_field=CharField())
|
||||
)
|
||||
.values(*fields),
|
||||
"blocked_by": queryset.filter(pk__in=blocked_by_issues)
|
||||
.annotate(
|
||||
relation_type=Value("blocked_by", output_field=CharField())
|
||||
)
|
||||
.values(*fields),
|
||||
"duplicate": queryset.filter(pk__in=duplicate_issues)
|
||||
.annotate(
|
||||
relation_type=Value(
|
||||
"duplicate",
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values(*fields)
|
||||
| queryset.filter(pk__in=duplicate_issues_related)
|
||||
.annotate(
|
||||
relation_type=Value(
|
||||
"duplicate",
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values(*fields),
|
||||
"relates_to": queryset.filter(pk__in=relates_to_issues)
|
||||
.annotate(
|
||||
relation_type=Value(
|
||||
"relates_to",
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values(*fields)
|
||||
| queryset.filter(pk__in=relates_to_issues_related)
|
||||
.annotate(
|
||||
relation_type=Value(
|
||||
"relates_to",
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values(*fields),
|
||||
}
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { FC } from "react";
|
||||
import { DropdownIcon } from "../icons";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
hideChevron?: boolean;
|
||||
indicatorElement?: React.ReactNode;
|
||||
actionItemElement?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CollapsibleButton: FC<Props> = (props) => {
|
||||
const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-100">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideChevron && (
|
||||
<DropdownIcon
|
||||
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
|
||||
"-rotate-90": !isOpen,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<span className="text-base text-custom-text-100 font-medium">{title}</span>
|
||||
</div>
|
||||
{indicatorElement && indicatorElement}
|
||||
</div>
|
||||
{actionItemElement && isOpen && actionItemElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./collapsible";
|
||||
export * from "./collapsible-button";
|
||||
|
||||
@@ -21,5 +21,4 @@ export * from "./related-icon";
|
||||
export * from "./side-panel-icon";
|
||||
export * from "./transfer-icon";
|
||||
export * from "./info-icon";
|
||||
export * from "./relations-icon";
|
||||
export * from "./dropdown-icon";
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const RelationsIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`${className}`}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...rest}
|
||||
>
|
||||
<path d="M7.99998 4.66536C8.92045 4.66536 9.66665 3.91917 9.66665 2.9987C9.66665 2.07822 8.92045 1.33203 7.99998 1.33203C7.07951 1.33203 6.33331 2.07822 6.33331 2.9987C6.33331 3.91917 7.07951 4.66536 7.99998 4.66536Z" />
|
||||
<path d="M6.80001 4.19922L4.20001 6.79922" />
|
||||
<path d="M2.99998 9.66536C3.92045 9.66536 4.66665 8.91917 4.66665 7.9987C4.66665 7.07822 3.92045 6.33203 2.99998 6.33203C2.07951 6.33203 1.33331 7.07822 1.33331 7.9987C1.33331 8.91917 2.07951 9.66536 2.99998 9.66536Z" />
|
||||
<path d="M4.66669 8H11.3334" />
|
||||
<path d="M13 9.66536C13.9205 9.66536 14.6666 8.91917 14.6666 7.9987C14.6666 7.07822 13.9205 6.33203 13 6.33203C12.0795 6.33203 11.3333 7.07822 11.3333 7.9987C11.3333 8.91917 12.0795 9.66536 13 9.66536Z" />
|
||||
<path d="M9.20001 11.7992L11.8 9.19922" />
|
||||
<path d="M7.99998 14.6654C8.92045 14.6654 9.66665 13.9192 9.66665 12.9987C9.66665 12.0782 8.92045 11.332 7.99998 11.332C7.07951 11.332 6.33331 12.0782 6.33331 12.9987C6.33331 13.9192 7.07951 14.6654 7.99998 14.6654Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -90,7 +90,7 @@ export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer(
|
||||
</>
|
||||
)}
|
||||
|
||||
<CustomMenu ellipsis closeOnSelect placement="bottom-end" openOnHover disabled={disabled}>
|
||||
<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -10,6 +10,8 @@ export * from "./label";
|
||||
export * from "./confirm-issue-discard";
|
||||
export * from "./issue-update-status";
|
||||
export * from "./create-issue-toast-action-items";
|
||||
export * from "./relations";
|
||||
export * from "./issue-detail-widgets";
|
||||
|
||||
// issue details
|
||||
export * from "./issue-detail";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { Layers, Link, Paperclip, Waypoints } from "lucide-react";
|
||||
// components
|
||||
import {
|
||||
IssueAttachmentActionButton,
|
||||
IssueLinksActionButton,
|
||||
RelationActionButton,
|
||||
SubIssuesActionButton,
|
||||
IssueDetailWidgetButton,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled } = props;
|
||||
return (
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<SubIssuesActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title="Add sub-issues"
|
||||
icon={<Layers className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RelationActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title="Add Relation"
|
||||
icon={<Waypoints className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<IssueLinksActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title="Add Links"
|
||||
icon={<Link className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<IssueAttachmentActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title="Attach"
|
||||
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
// components
|
||||
import { IssueAttachmentItemList } from "@/components/issues/attachment";
|
||||
// helper
|
||||
import { useAttachmentOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsCollapsibleContent: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled } = props;
|
||||
// helper
|
||||
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId);
|
||||
return (
|
||||
<IssueAttachmentItemList
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
handleAttachmentOperations={handleAttachmentOperations}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
|
||||
// type
|
||||
import { TAttachmentOperations } from "@/components/issues/attachment";
|
||||
// hooks
|
||||
import { useEventTracker, useIssueDetail } from "@/hooks/store";
|
||||
|
||||
export const useAttachmentOperations = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string
|
||||
): TAttachmentOperations => {
|
||||
const { createAttachment, removeAttachment } = useIssueDetail();
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
|
||||
const handleAttachmentOperations: TAttachmentOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: FormData) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
|
||||
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data);
|
||||
setPromiseToast(attachmentUploadPromise, {
|
||||
loading: "Uploading attachment...",
|
||||
success: {
|
||||
title: "Attachment uploaded",
|
||||
message: () => "The attachment has been successfully uploaded",
|
||||
},
|
||||
error: {
|
||||
title: "Attachment not uploaded",
|
||||
message: () => "The attachment could not be uploaded",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await attachmentUploadPromise;
|
||||
captureIssueEvent({
|
||||
eventName: "Issue attachment added",
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "attachment",
|
||||
change_details: res.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue attachment added",
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (attachmentId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
|
||||
setToast({
|
||||
message: "The attachment has been successfully removed",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Attachment removed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue attachment deleted",
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "attachment",
|
||||
change_details: "",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue attachment deleted",
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "attachment",
|
||||
change_details: "",
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
message: "The Attachment could not be removed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Attachment not removed",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
|
||||
);
|
||||
|
||||
return handleAttachmentOperations;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
import React, { FC, useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Plus } from "lucide-react";
|
||||
// constants
|
||||
import { MAX_FILE_SIZE } from "@/constants/common";
|
||||
// helper
|
||||
import { generateFileName } from "@/helpers/attachment.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
import { useAttachmentOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
|
||||
// helper
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { config } = useInstance();
|
||||
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const currentFile: File = acceptedFiles[0];
|
||||
if (!currentFile || !workspaceSlug) return;
|
||||
|
||||
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
|
||||
type: currentFile.type,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("asset", uploadedFile);
|
||||
formData.append(
|
||||
"attributes",
|
||||
JSON.stringify({
|
||||
name: uploadedFile.name,
|
||||
size: uploadedFile.size,
|
||||
})
|
||||
);
|
||||
setIsLoading(true);
|
||||
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
|
||||
},
|
||||
[handleAttachmentOperations, workspaceSlug]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<button {...getRootProps()} type="button" disabled={disabled}>
|
||||
<input {...getInputProps()} />
|
||||
{customButton ? customButton : <Plus className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
import React, { FC, useState } from "react";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
IssueAttachmentsCollapsibleContent,
|
||||
IssueAttachmentsCollapsibleTitle,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const AttachmentsCollapsible: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen((prev) => !prev)}
|
||||
title={
|
||||
<IssueAttachmentsCollapsibleTitle
|
||||
isOpen={isOpen}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IssueAttachmentsCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// components
|
||||
import { IssueAttachmentActionButton } from "@/components/issues/issue-detail-widgets";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const attachmentCount = issue?.attachment_count ?? 0;
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
|
||||
</span>
|
||||
),
|
||||
[attachmentCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title="Attachments"
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
<IssueAttachmentActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./attachments";
|
||||
export * from "./links";
|
||||
export * from "./relations";
|
||||
export * from "./root";
|
||||
export * from "./sub-issues";
|
||||
export * from "./widget-button";
|
||||
export * from "./issue-detail-widget-collapsibles";
|
||||
export * from "./action-buttons";
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import {
|
||||
AttachmentsCollapsible,
|
||||
LinksCollapsible,
|
||||
RelationsCollapsible,
|
||||
SubIssuesCollapsible,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { subIssuesByIssueId },
|
||||
relation: { getRelationsByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const subIssues = subIssuesByIssueId(issueId);
|
||||
const issueRelations = getRelationsByIssueId(issueId);
|
||||
|
||||
// render conditions
|
||||
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0;
|
||||
const shouldRenderRelations = Object.values(issueRelations ?? {}).some((relation) => relation.length > 0);
|
||||
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0;
|
||||
const shouldRenderAttachments = !!issue?.attachment_count && issue?.attachment_count > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{shouldRenderSubIssues && (
|
||||
<SubIssuesCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderRelations && (
|
||||
<RelationsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderLinks && (
|
||||
<LinksCollapsible workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
|
||||
)}
|
||||
{shouldRenderAttachments && (
|
||||
<AttachmentsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
// components
|
||||
import { LinkList } from "../../issue-detail/links";
|
||||
// helper
|
||||
import { useLinkOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled } = props;
|
||||
|
||||
// helper
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
return <LinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { TIssueLink } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// types
|
||||
import { TLinkOperations } from "../../issue-detail/links";
|
||||
|
||||
export const useLinkOperations = (workspaceSlug: string, projectId: string, issueId: string): TLinkOperations => {
|
||||
const { createLink, updateLink, removeLink } = useIssueDetail();
|
||||
|
||||
const handleLinkOperations: TLinkOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, projectId, issueId, data);
|
||||
setToast({
|
||||
message: "The link has been successfully created",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link created",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
message: "The link could not be created",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Link not created",
|
||||
});
|
||||
}
|
||||
},
|
||||
update: async (linkId: string, data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
|
||||
setToast({
|
||||
message: "The link has been successfully updated",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link updated",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
message: "The link could not be updated",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Link not updated",
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (linkId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, projectId, issueId, linkId);
|
||||
setToast({
|
||||
message: "The link has been successfully removed",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link removed",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
message: "The link could not be removed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Link not removed",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink]
|
||||
);
|
||||
|
||||
return handleLinkOperations;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import React, { FC, useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// components
|
||||
import { IssueLinkCreateUpdateModal } from "../../issue-detail/links/create-update-link-modal";
|
||||
// helper
|
||||
import { useLinkOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinksActionButton: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
|
||||
// state
|
||||
const [isIssueLinkModal, setIsIssueLinkModal] = useState(false);
|
||||
|
||||
// store hooks
|
||||
const { toggleIssueLinkModal: toggleIssueLinkModalStore } = useIssueDetail();
|
||||
|
||||
// helper
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
// handler
|
||||
const toggleIssueLinkModal = useCallback(
|
||||
(modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIsIssueLinkModal(modalToggle);
|
||||
},
|
||||
[toggleIssueLinkModalStore]
|
||||
);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIssueLinkModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueLinkCreateUpdateModal
|
||||
isModalOpen={isIssueLinkModal}
|
||||
handleModal={toggleIssueLinkModal}
|
||||
linkOperations={handleLinkOperations}
|
||||
/>
|
||||
<button type="button" onClick={handleOnClick} disabled={disabled}>
|
||||
{customButton ? customButton : <Plus className="h-4 w-4" />}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
import React, { FC, useState } from "react";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import { IssueLinksCollapsibleContent, IssueLinksCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const LinksCollapsible: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen((prev) => !prev)}
|
||||
title={
|
||||
<IssueLinksCollapsibleTitle
|
||||
isOpen={isOpen}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IssueLinksCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// components
|
||||
import { IssueLinksActionButton } from "@/components/issues/issue-detail-widgets";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinksCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
const linksCount = issue?.link_count ?? 0;
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
|
||||
</span>
|
||||
),
|
||||
[linksCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title="Links"
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
<IssueLinksActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
|
||||
import { TIssue, TIssueRelationIdMap } from "@plane/types";
|
||||
import { Collapsible, RelatedIcon } from "@plane/ui";
|
||||
// components
|
||||
import { RelationIssueList } from "@/components/issues";
|
||||
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// helper
|
||||
import { useRelationOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const ISSUE_RELATION_OPTIONS = [
|
||||
{
|
||||
key: "blocked_by",
|
||||
label: "Blocked by",
|
||||
icon: (size: number) => <CircleDot size={size} />,
|
||||
className: "bg-red-500/20 text-red-700",
|
||||
},
|
||||
{
|
||||
key: "blocking",
|
||||
label: "Blocking",
|
||||
icon: (size: number) => <XCircle size={size} />,
|
||||
className: "bg-yellow-500/20 text-yellow-700",
|
||||
},
|
||||
{
|
||||
key: "relates_to",
|
||||
label: "Relates to",
|
||||
icon: (size: number) => <RelatedIcon height={size} width={size} />,
|
||||
className: "bg-custom-background-80 text-custom-text-200",
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate of",
|
||||
icon: (size: number) => <CopyPlus size={size} />,
|
||||
className: "bg-custom-background-80 text-custom-text-200",
|
||||
},
|
||||
];
|
||||
|
||||
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
update: TIssueCrudState;
|
||||
delete: TIssueCrudState;
|
||||
}>({
|
||||
update: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
relation: { getRelationsByIssueId },
|
||||
toggleDeleteIssueModal,
|
||||
toggleCreateIssueModal,
|
||||
} = useIssueDetail();
|
||||
|
||||
// helper
|
||||
const issueOperations = useRelationOperations();
|
||||
|
||||
// derived values
|
||||
const relations = getRelationsByIssueId(issueId);
|
||||
|
||||
const handleIssueCrudState = (key: "update" | "delete", _issueId: string | null, issue: TIssue | null = null) => {
|
||||
setIssueCrudState({
|
||||
...issueCrudState,
|
||||
[key]: {
|
||||
toggle: !issueCrudState[key].toggle,
|
||||
issueId: _issueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// if relations are not available, return null
|
||||
if (!relations) return null;
|
||||
|
||||
// map relations to array
|
||||
const relationsArray = Object.keys(relations).map((relationKey) => {
|
||||
const issueIds = relations[relationKey as keyof TIssueRelationIdMap];
|
||||
const issueRelationOption = ISSUE_RELATION_OPTIONS.find((option) => option.key === relationKey);
|
||||
return {
|
||||
relationKey: relationKey as keyof TIssueRelationIdMap,
|
||||
issueIds: issueIds,
|
||||
icon: issueRelationOption?.icon,
|
||||
label: issueRelationOption?.label,
|
||||
className: issueRelationOption?.className,
|
||||
};
|
||||
});
|
||||
|
||||
// filter out relations with no issues
|
||||
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);
|
||||
|
||||
const shouldRenderIssueDeleteModal =
|
||||
issueCrudState?.delete?.toggle &&
|
||||
issueCrudState?.delete?.issue &&
|
||||
issueCrudState.delete.issueId &&
|
||||
issueCrudState.delete.issue.id;
|
||||
|
||||
const shouldRenderIssueUpdateModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-">
|
||||
{filteredRelationsArray.map((relation) => (
|
||||
<div key={relation.relationKey}>
|
||||
<Collapsible
|
||||
buttonClassName="w-full"
|
||||
title={
|
||||
<div className={`flex items-center gap-1 px-3 py-1 h-9 w-full pl-9 ${relation.className}`}>
|
||||
<span>{relation.icon ? relation.icon(14) : null}</span>
|
||||
<span className="text-sm font-medium leading-5">{relation.label}</span>
|
||||
</div>
|
||||
}
|
||||
defaultOpen
|
||||
>
|
||||
<RelationIssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey={relation.relationKey}
|
||||
issueIds={relation.issueIds}
|
||||
disabled={disabled}
|
||||
issueOperations={issueOperations}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
/>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shouldRenderIssueDeleteModal && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudState?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
handleIssueCrudState("delete", null, null);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () =>
|
||||
await issueOperations.remove(workspaceSlug, projectId, issueCrudState?.delete?.issue?.id as string)
|
||||
}
|
||||
isSubIssue
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderIssueUpdateModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
await issueOperations.update(workspaceSlug, projectId, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
|
||||
import { TIssue } from "@plane/types";
|
||||
import { RelatedIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { ISSUE_DELETED, ISSUE_UPDATED } from "@/constants/event-tracker";
|
||||
// helper
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssueDetail } from "@/hooks/store";
|
||||
|
||||
export type TRelationIssueOperations = {
|
||||
copyText: (text: string) => void;
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useRelationOperations = (): TRelationIssueOperations => {
|
||||
const { updateIssue, removeIssue } = useIssueDetail();
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const pathname = usePathname();
|
||||
|
||||
const issueOperations: TRelationIssueOperations = useMemo(
|
||||
() => ({
|
||||
copyText: (text: string) => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${text}`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Issue updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Issue update failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await removeIssue(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Issue delete failed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[pathname, removeIssue, updateIssue]
|
||||
);
|
||||
|
||||
return issueOperations;
|
||||
};
|
||||
|
||||
export const ISSUE_RELATION_OPTIONS = [
|
||||
{
|
||||
key: "blocked_by",
|
||||
label: "Blocked by",
|
||||
icon: (size: number) => <CircleDot size={size} />,
|
||||
className: "bg-red-500/20 text-red-700",
|
||||
},
|
||||
{
|
||||
key: "blocking",
|
||||
label: "Blocking",
|
||||
icon: (size: number) => <XCircle size={size} />,
|
||||
className: "bg-yellow-500/20 text-yellow-700",
|
||||
},
|
||||
{
|
||||
key: "relates_to",
|
||||
label: "Relates to",
|
||||
icon: (size: number) => <RelatedIcon height={size} width={size} />,
|
||||
className: "bg-custom-background-80 text-custom-text-200",
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate of",
|
||||
icon: (size: number) => <CopyPlus size={size} />,
|
||||
className: "bg-custom-background-80 text-custom-text-200",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
import React, { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { ISearchIssueResponse, TIssueRelationTypes } from "@plane/types";
|
||||
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// helper
|
||||
import { ISSUE_RELATION_OPTIONS } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RelationActionButton: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, customButton, issueId, disabled = false } = props;
|
||||
// state
|
||||
const [relationKey, setRelationKey] = useState<TIssueRelationTypes | null>(null);
|
||||
const { createRelation, isRelationModalOpen, toggleRelationModal } = useIssueDetail();
|
||||
|
||||
// handlers
|
||||
const handleOnClick = (relationKey: TIssueRelationTypes) => {
|
||||
setRelationKey(relationKey);
|
||||
toggleRelationModal(issueId, relationKey);
|
||||
};
|
||||
|
||||
// submit handler
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (!relationKey) return;
|
||||
if (data.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createRelation(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
relationKey,
|
||||
data.map((i) => i.id)
|
||||
);
|
||||
|
||||
toggleRelationModal(null, null);
|
||||
};
|
||||
|
||||
const handleOnClose = () => {
|
||||
setRelationKey(null);
|
||||
toggleRelationModal(null, null);
|
||||
};
|
||||
|
||||
// button element
|
||||
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
|
||||
{ISSUE_RELATION_OPTIONS.map((item, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOnClick(item.key as TIssueRelationTypes);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon(12)}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={isRelationModalOpen?.issueId === issueId && isRelationModalOpen?.relationType === relationKey}
|
||||
handleClose={handleOnClose}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
import React, { FC, useState } from "react";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import { RelationsCollapsibleContent, RelationsCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RelationsCollapsible: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen((prev) => !prev)}
|
||||
title={
|
||||
<RelationsCollapsibleTitle
|
||||
isOpen={isOpen}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RelationsCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// components
|
||||
import { RelationActionButton } from "@/components/issues/issue-detail-widgets";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const RelationsCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
|
||||
// store hook
|
||||
const {
|
||||
relation: { getRelationsByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
// derived values
|
||||
const issueRelations = getRelationsByIssueId(issueId);
|
||||
const relationsCount = Object.values(issueRelations ?? {}).reduce((acc, curr) => acc + curr.length, 0);
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
|
||||
</span>
|
||||
),
|
||||
[relationsCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title="Relations"
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
<RelationActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
// components
|
||||
import {
|
||||
IssueDetailWidgetActionButtons,
|
||||
IssueDetailWidgetCollapsibles,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailWidgets: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<IssueDetailWidgetActionButtons
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<IssueDetailWidgetCollapsibles
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
import React, { FC, useCallback, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TIssue } from "@plane/types";
|
||||
// components
|
||||
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
|
||||
import { IssueList } from "@/components/issues/sub-issues/issues-list";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// helper
|
||||
import { useSubIssueOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, parentIssueId, disabled } = props;
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
create: TIssueCrudState;
|
||||
existing: TIssueCrudState;
|
||||
update: TIssueCrudState;
|
||||
delete: TIssueCrudState;
|
||||
}>({
|
||||
create: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
existing: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
update: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
});
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
toggleCreateIssueModal,
|
||||
toggleDeleteIssueModal,
|
||||
} = useIssueDetail();
|
||||
|
||||
// helpers
|
||||
const subIssueOperations = useSubIssueOperations();
|
||||
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
|
||||
|
||||
// handler
|
||||
const handleIssueCrudState = (
|
||||
key: "create" | "existing" | "update" | "delete",
|
||||
_parentIssueId: string | null,
|
||||
issue: TIssue | null = null
|
||||
) => {
|
||||
setIssueCrudState({
|
||||
...issueCrudState,
|
||||
[key]: {
|
||||
toggle: !issueCrudState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleFetchSubIssues = useCallback(async () => {
|
||||
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
|
||||
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
|
||||
}
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
|
||||
}, [
|
||||
parentIssueId,
|
||||
projectId,
|
||||
setSubIssueHelpers,
|
||||
subIssueHelpers.issue_visibility,
|
||||
subIssueOperations,
|
||||
workspaceSlug,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
handleFetchSubIssues();
|
||||
|
||||
return () => {
|
||||
handleFetchSubIssues();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentIssueId]);
|
||||
|
||||
// render conditions
|
||||
const shouldRenderDeleteIssueModal =
|
||||
issueCrudState?.delete?.toggle &&
|
||||
issueCrudState?.delete?.issue &&
|
||||
issueCrudState.delete.parentIssueId &&
|
||||
issueCrudState.delete.issue.id;
|
||||
|
||||
const shouldRenderUpdateIssueModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
|
||||
|
||||
return (
|
||||
<>
|
||||
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
|
||||
<IssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={parentIssueId}
|
||||
spacingLeft={6}
|
||||
disabled={!disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderDeleteIssueModal && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudState?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
handleIssueCrudState("delete", null, null);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () =>
|
||||
await subIssueOperations.deleteSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueCrudState?.delete?.parentIssueId as string,
|
||||
issueCrudState?.delete?.issue?.id as string
|
||||
)
|
||||
}
|
||||
isSubIssue
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderUpdateIssueModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
await subIssueOperations.updateSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
_issue.id,
|
||||
_issue,
|
||||
issueCrudState?.update?.issue,
|
||||
true
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TIssue } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helper
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssueDetail } from "@/hooks/store";
|
||||
// type
|
||||
import { TSubIssueOperations } from "../../sub-issues";
|
||||
|
||||
export type TRelationIssueOperations = {
|
||||
copyText: (text: string) => void;
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useSubIssueOperations = (): TSubIssueOperations => {
|
||||
const {
|
||||
subIssues: { setSubIssueHelpers },
|
||||
fetchSubIssues,
|
||||
createSubIssues,
|
||||
updateSubIssue,
|
||||
removeSubIssue,
|
||||
deleteSubIssue,
|
||||
} = useIssueDetail();
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const pathname = usePathname();
|
||||
|
||||
const subIssueOperations: TSubIssueOperations = useMemo(
|
||||
() => ({
|
||||
copyText: (text: string) => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${text}`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
|
||||
try {
|
||||
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Error fetching sub-issues",
|
||||
});
|
||||
}
|
||||
},
|
||||
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
|
||||
try {
|
||||
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Sub-issues added successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Error adding sub-issue",
|
||||
});
|
||||
}
|
||||
},
|
||||
updateSubIssue: async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueId: string,
|
||||
issueData: Partial<TIssue>,
|
||||
oldIssue: Partial<TIssue> = {},
|
||||
fromModal: boolean = false
|
||||
) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
|
||||
captureIssueEvent({
|
||||
eventName: "Sub-issue updated",
|
||||
payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(issueData).join(","),
|
||||
change_details: Object.values(issueData).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Sub-issue updated successfully",
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Sub-issue updated",
|
||||
payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(issueData).join(","),
|
||||
change_details: Object.values(issueData).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Error updating sub-issue",
|
||||
});
|
||||
}
|
||||
},
|
||||
removeSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Sub-issue removed successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Sub-issue removed",
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "parent_id",
|
||||
change_details: parentIssueId,
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Sub-issue removed",
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "parent_id",
|
||||
change_details: parentIssueId,
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Error removing sub-issue",
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
|
||||
captureIssueEvent({
|
||||
eventName: "Sub-issue deleted",
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Sub-issue removed",
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Error deleting issue",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, setSubIssueHelpers]
|
||||
);
|
||||
|
||||
return subIssueOperations;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
import React, { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { LayersIcon, Plus } from "lucide-react";
|
||||
import { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core";
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
|
||||
// hooks
|
||||
import { useEventTracker, useIssueDetail } from "@/hooks/store";
|
||||
// helper
|
||||
import { useSubIssueOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export const SubIssuesActionButton: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
create: TIssueCrudState;
|
||||
existing: TIssueCrudState;
|
||||
}>({
|
||||
create: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
existing: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
});
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
isCreateIssueModalOpen,
|
||||
toggleCreateIssueModal,
|
||||
isSubIssuesModalOpen,
|
||||
toggleSubIssuesModal,
|
||||
} = useIssueDetail();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
// helper
|
||||
const subIssueOperations = useSubIssueOperations();
|
||||
|
||||
// handlers
|
||||
const handleIssueCrudState = (
|
||||
key: "create" | "existing",
|
||||
_parentIssueId: string | null,
|
||||
issue: TIssue | null = null
|
||||
) => {
|
||||
setIssueCrudState({
|
||||
...issueCrudState,
|
||||
[key]: {
|
||||
toggle: !issueCrudState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setTrackElement("Issue detail nested sub-issue");
|
||||
handleIssueCrudState("create", issueId, null);
|
||||
toggleCreateIssueModal(true);
|
||||
};
|
||||
|
||||
const handleAddExisting = () => {
|
||||
setTrackElement("Issue detail nested sub-issue");
|
||||
handleIssueCrudState("existing", issueId, null);
|
||||
toggleSubIssuesModal(issue.id);
|
||||
};
|
||||
|
||||
const handleExistingIssuesModalClose = () => {
|
||||
handleIssueCrudState("existing", null, null);
|
||||
toggleSubIssuesModal(null);
|
||||
};
|
||||
|
||||
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
|
||||
subIssueOperations.addSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
_issue.map((issue) => issue.id)
|
||||
);
|
||||
|
||||
const handleCreateUpdateModalClose = () => {
|
||||
handleIssueCrudState("create", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
};
|
||||
|
||||
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
|
||||
if (_issue.parent_id) {
|
||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, issueId, [_issue.id]);
|
||||
}
|
||||
};
|
||||
|
||||
// options
|
||||
const optionItems = [
|
||||
{
|
||||
label: "Create new",
|
||||
icon: <Plus className="h-3 w-3" />,
|
||||
onClick: handleCreateNew,
|
||||
},
|
||||
{
|
||||
label: "Add existing",
|
||||
icon: <LayersIcon className="h-3 w-3" />,
|
||||
onClick: handleAddExisting,
|
||||
},
|
||||
];
|
||||
|
||||
// create update modal
|
||||
const shouldRenderCreateUpdateModal =
|
||||
issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen;
|
||||
|
||||
const createUpdateModalData = { parent_id: issueCrudState?.create?.parentIssueId };
|
||||
|
||||
// existing issues modal
|
||||
const shouldRenderExistingIssuesModal =
|
||||
issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen;
|
||||
|
||||
const existingIssuesModalSearchParams = { sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId };
|
||||
|
||||
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
|
||||
return (
|
||||
<>
|
||||
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
|
||||
{optionItems.map((item, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
|
||||
{shouldRenderCreateUpdateModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.create?.toggle}
|
||||
data={createUpdateModalData}
|
||||
onClose={handleCreateUpdateModalClose}
|
||||
onSubmit={handleCreateUpdateModalOnSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderExistingIssuesModal && (
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={issueCrudState?.existing?.toggle}
|
||||
handleClose={handleExistingIssuesModalClose}
|
||||
searchParams={existingIssuesModalSearchParams}
|
||||
handleOnSubmit={handleExistingIssuesModalOnSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
import React, { FC, useState } from "react";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import { SubIssuesCollapsibleContent, SubIssuesCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SubIssuesCollapsible: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen((prev) => !prev)}
|
||||
title={
|
||||
<SubIssuesCollapsibleTitle
|
||||
isOpen={isOpen}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SubIssuesCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
|
||||
// components
|
||||
import { SubIssuesActionButton } from "@/components/issues/issue-detail-widgets";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, workspaceSlug, projectId, parentIssueId, disabled } = props;
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
// derived data
|
||||
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
|
||||
const subIssues = subIssuesByIssueId(parentIssueId);
|
||||
|
||||
// if there are no sub-issues, return null
|
||||
if (!subIssues) return null;
|
||||
|
||||
// calculate percentage of completed sub-issues
|
||||
const completedCount = subIssuesDistribution?.completed?.length ?? 0;
|
||||
const totalCount = subIssues.length;
|
||||
const percentage = completedCount && totalCount ? (completedCount / totalCount) * 100 : 0;
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
|
||||
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
|
||||
<span>
|
||||
{completedCount}/{totalCount} Done
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
[completedCount, totalCount, percentage]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title="Sub-issues"
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
<SubIssuesActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={parentIssueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetButton: FC<Props> = (props) => {
|
||||
const { icon, title } = props;
|
||||
return (
|
||||
<div className="h-full w-min whitespace-nowrap flex items-center gap-2 border border-custom-border-200 hover:bg-custom-background-80 rounded px-3 py-1.5">
|
||||
{icon && icon}
|
||||
<span className="text-sm font-medium text-custom-text-300">{title}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,17 +7,19 @@ import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// components
|
||||
import { IssueAttachmentRoot, IssueUpdateStatus } from "@/components/issues";
|
||||
import {
|
||||
IssueActivity,
|
||||
IssueUpdateStatus,
|
||||
IssueReaction,
|
||||
IssueParentDetail,
|
||||
IssueTitleInput,
|
||||
IssueDescriptionInput,
|
||||
IssueDetailWidgets,
|
||||
} from "@/components/issues";
|
||||
// hooks
|
||||
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// components
|
||||
import { IssueDescriptionInput } from "../description-input";
|
||||
import { SubIssuesRoot } from "../sub-issues";
|
||||
import { IssueTitleInput } from "../title-input";
|
||||
import { IssueActivity } from "./issue-activity";
|
||||
import { IssueParentDetail } from "./parent";
|
||||
import { IssueReaction } from "./reactions";
|
||||
// types
|
||||
import { TIssueOperations } from "./root";
|
||||
|
||||
type Props = {
|
||||
@@ -113,20 +115,10 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
disabled={isArchived}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentUser && (
|
||||
<SubIssuesRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={issueId}
|
||||
currentUser={currentUser}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pl-3">
|
||||
<IssueAttachmentRoot
|
||||
<IssueDetailWidgets
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
|
||||
@@ -348,7 +348,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<div className="max-w-2/3 h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto px-6 py-5">
|
||||
<div className="max-w-2/3 h-full w-full space-y-8 overflow-y-auto px-6 py-5">
|
||||
<IssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
swrIssueDetails={swrIssueDetails}
|
||||
|
||||
@@ -2,22 +2,10 @@
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CalendarCheck2,
|
||||
CalendarClock,
|
||||
CircleDot,
|
||||
CopyPlus,
|
||||
LayoutPanelTop,
|
||||
Signal,
|
||||
Tag,
|
||||
Triangle,
|
||||
UserCircle2,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
// hooks
|
||||
import { CalendarCheck2, CalendarClock, Signal, Tag, Triangle, UserCircle2, Users } from "lucide-react";
|
||||
// ui
|
||||
import { ContrastIcon, DiceIcon, DoubleCircleIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip } from "@plane/ui";
|
||||
import {
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
@@ -25,30 +13,17 @@ import {
|
||||
PriorityDropdown,
|
||||
StateDropdown,
|
||||
} from "@/components/dropdowns";
|
||||
// ui
|
||||
// helpers
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import {
|
||||
IssueCycleSelect,
|
||||
IssueLabel,
|
||||
IssueLinkRoot,
|
||||
IssueModuleSelect,
|
||||
IssueParentSelect,
|
||||
IssueRelationSelect,
|
||||
} from "@/components/issues";
|
||||
import { IssueCycleSelect, IssueLabel, IssueModuleSelect } from "@/components/issues";
|
||||
// helpers
|
||||
// types
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
// types
|
||||
// hooks
|
||||
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import type { TIssueOperations } from "./root";
|
||||
// icons
|
||||
// helpers
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -279,81 +254,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<LayoutPanelTop className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Parent</span>
|
||||
</div>
|
||||
<IssueParentSelect
|
||||
className="h-full w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<RelatedIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Relates to</span>
|
||||
</div>
|
||||
<IssueRelationSelect
|
||||
className="h-full min-h-8 w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="relates_to"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<XCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Blocking</span>
|
||||
</div>
|
||||
<IssueRelationSelect
|
||||
className="h-full min-h-8 w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="blocking"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<CircleDot className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Blocked by</span>
|
||||
</div>
|
||||
<IssueRelationSelect
|
||||
className="h-full min-h-8 w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="blocked_by"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<CopyPlus className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Duplicate of</span>
|
||||
</div>
|
||||
<IssueRelationSelect
|
||||
className="h-full min-h-8 w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="duplicate"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<Tag className="h-4 w-4 flex-shrink-0" />
|
||||
@@ -369,8 +269,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueLinkRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={!isEditable} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./issue-list";
|
||||
export * from "./issue-list-item";
|
||||
export * from "./properties";
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X, Pencil, Trash, Link as LinkIcon } from "lucide-react";
|
||||
import { TIssue, TIssueRelationTypes } from "@plane/types";
|
||||
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { RelationIssueProperty } from "@/components/issues/relations";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
relationKey: TIssueRelationTypes;
|
||||
relationIssueId: string;
|
||||
disabled: boolean;
|
||||
issueOperations: TRelationIssueOperations;
|
||||
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
|
||||
};
|
||||
|
||||
export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
relationKey,
|
||||
relationIssueId,
|
||||
disabled = false,
|
||||
issueOperations,
|
||||
handleIssueCrudState,
|
||||
} = props;
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
getIsIssuePeeked,
|
||||
setPeekIssue,
|
||||
removeRelation,
|
||||
toggleCreateIssueModal,
|
||||
toggleDeleteIssueModal,
|
||||
} = useIssueDetail();
|
||||
const project = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(relationIssueId);
|
||||
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
|
||||
const currentIssueStateDetail =
|
||||
(issue?.project_id && getProjectStates(issue?.project_id)?.find((state) => issue?.state_id == state.id)) ||
|
||||
undefined;
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
// handlers
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
workspaceSlug &&
|
||||
issue &&
|
||||
issue.project_id &&
|
||||
issue.id &&
|
||||
!getIsIssuePeeked(issue.id) &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
const handleEditIssue = () => {
|
||||
handleIssueCrudState("update", relationIssueId, { ...issue });
|
||||
toggleCreateIssueModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteIssue = () => {
|
||||
handleIssueCrudState("delete", relationIssueId, issue);
|
||||
toggleDeleteIssueModal(relationIssueId);
|
||||
};
|
||||
|
||||
const handleCopyIssueLink = () =>
|
||||
issueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`);
|
||||
|
||||
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={relationIssueId}>
|
||||
{issue && (
|
||||
<div className="group relative flex min-h-11 h-full w-full items-center gap-3 px-1.5 py-1 transition-all hover:bg-custom-background-90">
|
||||
<span className="size-5 flex-shrink-0" />
|
||||
<div className="flex w-full cursor-pointer items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{projectDetail?.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
|
||||
<ControlLink
|
||||
id={`issue-${issue.id}`}
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-sm">
|
||||
<RelationIssueProperty
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={relationIssueId}
|
||||
disabled={disabled}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-sm">
|
||||
<CustomMenu placement="bottom-end" ellipsis>
|
||||
{!disabled && (
|
||||
<CustomMenu.MenuItem onClick={handleEditIssue}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Edit issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Copy issue link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
{!disabled && (
|
||||
<CustomMenu.MenuItem onClick={handleRemoveRelation}>
|
||||
<div className="flex items-center gap-2">
|
||||
<X className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Remove relation</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{!disabled && (
|
||||
<CustomMenu.MenuItem onClick={handleDeleteIssue}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Delete issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TIssue, TIssueRelationTypes } from "@plane/types";
|
||||
// components
|
||||
import { RelationIssueListItem } from "@/components/issues/relations";
|
||||
// types
|
||||
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueIds: string[];
|
||||
relationKey: TIssueRelationTypes;
|
||||
issueOperations: TRelationIssueOperations;
|
||||
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RelationIssueList: FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
issueIds,
|
||||
relationKey,
|
||||
disabled = false,
|
||||
issueOperations,
|
||||
handleIssueCrudState,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{issueIds &&
|
||||
issueIds.length > 0 &&
|
||||
issueIds.map((relationIssueId) => (
|
||||
<RelationIssueListItem
|
||||
key={relationIssueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey={relationKey}
|
||||
relationIssueId={relationIssueId}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { TIssuePriorities } from "@plane/types";
|
||||
import { PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// types
|
||||
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueOperations: TRelationIssueOperations;
|
||||
};
|
||||
|
||||
export const RelationIssueProperty: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, issueId, disabled, issueOperations } = props;
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
// derived value
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
// if issue is not found, return empty
|
||||
if (!issue) return <></>;
|
||||
|
||||
// handlers
|
||||
const handleStateChange = (val: string) =>
|
||||
issue.project_id &&
|
||||
issueOperations.update(workspaceSlug, issue.project_id, issueId, {
|
||||
state_id: val,
|
||||
});
|
||||
|
||||
const handlePriorityChange = (val: TIssuePriorities) =>
|
||||
issue.project_id &&
|
||||
issueOperations.update(workspaceSlug, issue.project_id, issueId, {
|
||||
priority: val,
|
||||
});
|
||||
|
||||
const handleAssigneeChange = (val: string[]) =>
|
||||
issue.project_id &&
|
||||
issueOperations.update(workspaceSlug, issue.project_id, issueId, {
|
||||
assignee_ids: val,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id}
|
||||
projectId={issue.project_id ?? undefined}
|
||||
onChange={handleStateChange}
|
||||
disabled={disabled}
|
||||
buttonVariant="border-with-text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<PriorityDropdown
|
||||
value={issue.priority}
|
||||
onChange={handlePriorityChange}
|
||||
disabled={disabled}
|
||||
buttonVariant="border-without-text"
|
||||
buttonClassName="border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<MemberDropdown
|
||||
value={issue.assignee_ids}
|
||||
projectId={issue.project_id ?? undefined}
|
||||
onChange={handleAssigneeChange}
|
||||
disabled={disabled}
|
||||
multiple
|
||||
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
|
||||
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -191,8 +191,6 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<hr className="border-custom-border-300" />
|
||||
|
||||
{disabled && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
|
||||
Reference in New Issue
Block a user