diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index e4a04fadf..318f136da 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -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 diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index eb5aff9af..1068da1e0 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -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) diff --git a/packages/ui/src/collapsible/collapsible-button.tsx b/packages/ui/src/collapsible/collapsible-button.tsx new file mode 100644 index 000000000..92b7ef90a --- /dev/null +++ b/packages/ui/src/collapsible/collapsible-button.tsx @@ -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) => { + const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props; + return ( +
+
+
+ {!hideChevron && ( + + )} + {title} +
+ {indicatorElement && indicatorElement} +
+ {actionItemElement && isOpen && actionItemElement} +
+ ); +}; diff --git a/packages/ui/src/collapsible/index.ts b/packages/ui/src/collapsible/index.ts index dbd926237..04441c4c3 100644 --- a/packages/ui/src/collapsible/index.ts +++ b/packages/ui/src/collapsible/index.ts @@ -1 +1,2 @@ export * from "./collapsible"; +export * from "./collapsible-button"; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 3d44e77f3..e522c25ae 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -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"; diff --git a/packages/ui/src/icons/relations-icon.tsx b/packages/ui/src/icons/relations-icon.tsx deleted file mode 100644 index 0f17da6dd..000000000 --- a/packages/ui/src/icons/relations-icon.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; - -import { ISvgIcons } from "./type"; - -export const RelationsIcon: React.FC = ({ className = "text-current", ...rest }) => ( - - - - - - - - - -); diff --git a/web/core/components/issues/attachment/attachment-list-item.tsx b/web/core/components/issues/attachment/attachment-list-item.tsx index 6f6543f8c..0e36027b1 100644 --- a/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/web/core/components/issues/attachment/attachment-list-item.tsx @@ -90,7 +90,7 @@ export const IssueAttachmentsListItem: FC = observer( )} - + { e.preventDefault(); diff --git a/web/core/components/issues/index.ts b/web/core/components/issues/index.ts index 8b8180a13..483d53da9 100644 --- a/web/core/components/issues/index.ts +++ b/web/core/components/issues/index.ts @@ -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"; diff --git a/web/core/components/issues/issue-detail-widgets/action-buttons.tsx b/web/core/components/issues/issue-detail-widgets/action-buttons.tsx new file mode 100644 index 000000000..270872350 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/action-buttons.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled } = props; + return ( +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+ ); +}; diff --git a/web/core/components/issues/issue-detail-widgets/attachments/content.tsx b/web/core/components/issues/issue-detail-widgets/attachments/content.tsx new file mode 100644 index 000000000..f792af284 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/attachments/content.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled } = props; + // helper + const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId); + return ( + + ); +}; diff --git a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx new file mode 100644 index 000000000..539c9ea18 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -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; +}; diff --git a/web/core/components/issues/issue-detail-widgets/attachments/index.ts b/web/core/components/issues/issue-detail-widgets/attachments/index.ts new file mode 100644 index 000000000..78eef9768 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/attachments/index.ts @@ -0,0 +1,4 @@ +export * from "./content"; +export * from "./title"; +export * from "./root"; +export * from "./quick-action-button"; diff --git a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx new file mode 100644 index 000000000..fe2cfde30 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx @@ -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 = 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 ( + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/attachments/root.tsx b/web/core/components/issues/issue-detail-widgets/attachments/root.tsx new file mode 100644 index 000000000..2c564f814 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/attachments/root.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // state + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen((prev) => !prev)} + title={ + + } + > + + + ); +}; diff --git a/web/core/components/issues/issue-detail-widgets/attachments/title.tsx b/web/core/components/issues/issue-detail-widgets/attachments/title.tsx new file mode 100644 index 000000000..077ed6131 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/attachments/title.tsx @@ -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 = 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( + () => ( + +

{attachmentCount}

+
+ ), + [attachmentCount] + ); + + return ( + + } + /> + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/index.ts b/web/core/components/issues/issue-detail-widgets/index.ts new file mode 100644 index 000000000..f3dffa97e --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/index.ts @@ -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"; diff --git a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx new file mode 100644 index 000000000..58018c13b --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx @@ -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 = 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 ( +
+ {shouldRenderSubIssues && ( + + )} + {shouldRenderRelations && ( + + )} + {shouldRenderLinks && ( + + )} + {shouldRenderAttachments && ( + + )} +
+ ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/links/content.tsx b/web/core/components/issues/issue-detail-widgets/links/content.tsx new file mode 100644 index 000000000..2d85270b0 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/links/content.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled } = props; + + // helper + const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId); + + return ; +}; diff --git a/web/core/components/issues/issue-detail-widgets/links/helper.tsx b/web/core/components/issues/issue-detail-widgets/links/helper.tsx new file mode 100644 index 000000000..48370d04c --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/links/helper.tsx @@ -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) => { + 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) => { + 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; +}; diff --git a/web/core/components/issues/issue-detail-widgets/links/index.ts b/web/core/components/issues/issue-detail-widgets/links/index.ts new file mode 100644 index 000000000..78eef9768 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/links/index.ts @@ -0,0 +1,4 @@ +export * from "./content"; +export * from "./title"; +export * from "./root"; +export * from "./quick-action-button"; diff --git a/web/core/components/issues/issue-detail-widgets/links/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/links/quick-action-button.tsx new file mode 100644 index 000000000..99c1ccf4d --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/links/quick-action-button.tsx @@ -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 = 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) => { + e.preventDefault(); + e.stopPropagation(); + toggleIssueLinkModal(true); + }; + + return ( + <> + + + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/links/root.tsx b/web/core/components/issues/issue-detail-widgets/links/root.tsx new file mode 100644 index 000000000..6be7c14af --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/links/root.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // state + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen((prev) => !prev)} + title={ + + } + > + + + ); +}; diff --git a/web/core/components/issues/issue-detail-widgets/links/title.tsx b/web/core/components/issues/issue-detail-widgets/links/title.tsx new file mode 100644 index 000000000..6625cdead --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/links/title.tsx @@ -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 = 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( + () => ( + +

{linksCount}

+
+ ), + [linksCount] + ); + + return ( + + } + /> + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/relations/content.tsx b/web/core/components/issues/issue-detail-widgets/relations/content.tsx new file mode 100644 index 000000000..f4a58c7d1 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/relations/content.tsx @@ -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) => , + className: "bg-red-500/20 text-red-700", + }, + { + key: "blocking", + label: "Blocking", + icon: (size: number) => , + className: "bg-yellow-500/20 text-yellow-700", + }, + { + key: "relates_to", + label: "Relates to", + icon: (size: number) => , + className: "bg-custom-background-80 text-custom-text-200", + }, + { + key: "duplicate", + label: "Duplicate of", + icon: (size: number) => , + className: "bg-custom-background-80 text-custom-text-200", + }, +]; + +type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined }; + +export const RelationsCollapsibleContent: FC = 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 ( + <> +
+ {filteredRelationsArray.map((relation) => ( +
+ + {relation.icon ? relation.icon(14) : null} + {relation.label} +
+ } + defaultOpen + > + + +
+ ))} + + + {shouldRenderIssueDeleteModal && ( + { + 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 && ( + { + handleIssueCrudState("update", null, null); + toggleCreateIssueModal(false); + }} + data={issueCrudState?.update?.issue ?? undefined} + onSubmit={async (_issue: TIssue) => { + await issueOperations.update(workspaceSlug, projectId, _issue.id, _issue); + }} + /> + )} + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/relations/helper.tsx b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx new file mode 100644 index 000000000..940e88487 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx @@ -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) => Promise; + remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; +}; + +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) => { + 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) => , + className: "bg-red-500/20 text-red-700", + }, + { + key: "blocking", + label: "Blocking", + icon: (size: number) => , + className: "bg-yellow-500/20 text-yellow-700", + }, + { + key: "relates_to", + label: "Relates to", + icon: (size: number) => , + className: "bg-custom-background-80 text-custom-text-200", + }, + { + key: "duplicate", + label: "Duplicate of", + icon: (size: number) => , + className: "bg-custom-background-80 text-custom-text-200", + }, +]; diff --git a/web/core/components/issues/issue-detail-widgets/relations/index.ts b/web/core/components/issues/issue-detail-widgets/relations/index.ts new file mode 100644 index 000000000..78eef9768 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/relations/index.ts @@ -0,0 +1,4 @@ +export * from "./content"; +export * from "./title"; +export * from "./root"; +export * from "./quick-action-button"; diff --git a/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx new file mode 100644 index 000000000..a70e3a273 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx @@ -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 = observer((props) => { + const { workspaceSlug, projectId, customButton, issueId, disabled = false } = props; + // state + const [relationKey, setRelationKey] = useState(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} : ; + + return ( + <> + + {ISSUE_RELATION_OPTIONS.map((item, index) => ( + { + e.preventDefault(); + e.stopPropagation(); + handleOnClick(item.key as TIssueRelationTypes); + }} + > +
+ {item.icon(12)} + {item.label} +
+
+ ))} +
+ + + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/relations/root.tsx b/web/core/components/issues/issue-detail-widgets/relations/root.tsx new file mode 100644 index 000000000..e26861a79 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/relations/root.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // state + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen((prev) => !prev)} + title={ + + } + > + + + ); +}; diff --git a/web/core/components/issues/issue-detail-widgets/relations/title.tsx b/web/core/components/issues/issue-detail-widgets/relations/title.tsx new file mode 100644 index 000000000..a39d0866b --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/relations/title.tsx @@ -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 = 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( + () => ( + +

{relationsCount}

+
+ ), + [relationsCount] + ); + + return ( + + } + /> + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/root.tsx b/web/core/components/issues/issue-detail-widgets/root.tsx new file mode 100644 index 000000000..0ac8064d7 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/root.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled } = props; + return ( +
+ + +
+ ); +}; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx new file mode 100644 index 000000000..5432ee777 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx @@ -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 = 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) && ( + + )} + + {shouldRenderDeleteIssueModal && ( + { + 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 && ( + { + 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 + ); + }} + /> + )} + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx new file mode 100644 index 000000000..dbf295a00 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx @@ -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) => Promise; + remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; +}; + +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, + oldIssue: Partial = {}, + 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; +}; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts b/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts new file mode 100644 index 000000000..78eef9768 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts @@ -0,0 +1,4 @@ +export * from "./content"; +export * from "./title"; +export * from "./root"; +export * from "./quick-action-button"; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx new file mode 100644 index 000000000..e6bb81f59 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx @@ -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 = 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: , + onClick: handleCreateNew, + }, + { + label: "Add existing", + icon: , + 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} : ; + return ( + <> + + {optionItems.map((item, index) => ( + { + e.preventDefault(); + e.stopPropagation(); + item.onClick(); + }} + > +
+ {item.icon} + {item.label} +
+
+ ))} +
+ + {shouldRenderCreateUpdateModal && ( + + )} + + {shouldRenderExistingIssuesModal && ( + + )} + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx new file mode 100644 index 000000000..cc9c4ea83 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx @@ -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) => { + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // state + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen((prev) => !prev)} + title={ + + } + > + + + ); +}; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx new file mode 100644 index 000000000..5037c0ef6 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx @@ -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 = 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( + () => ( +
+ + + {completedCount}/{totalCount} Done + +
+ ), + [completedCount, totalCount, percentage] + ); + + return ( + + } + /> + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/widget-button.tsx b/web/core/components/issues/issue-detail-widgets/widget-button.tsx new file mode 100644 index 000000000..8c36140f0 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/widget-button.tsx @@ -0,0 +1,17 @@ +"use client"; +import React, { FC } from "react"; + +type Props = { + icon: JSX.Element; + title: string; +}; + +export const IssueDetailWidgetButton: FC = (props) => { + const { icon, title } = props; + return ( +
+ {icon && icon} + {title} +
+ ); +}; diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 3f6e97b54..c790486e8 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -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 = observer((props) => { disabled={isArchived} /> )} - - {currentUser && ( - - )}
- = observer((props) => { /> ) : (
-
+
= observer((props) => {
)} -
-
- - Parent -
- -
- -
-
- - Relates to -
- -
- -
-
- - Blocking -
- -
- -
-
- - Blocked by -
- -
- -
-
- - Duplicate of -
- -
-
@@ -369,8 +269,6 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
- -
diff --git a/web/core/components/issues/relations/index.ts b/web/core/components/issues/relations/index.ts new file mode 100644 index 000000000..a54c231f6 --- /dev/null +++ b/web/core/components/issues/relations/index.ts @@ -0,0 +1,3 @@ +export * from "./issue-list"; +export * from "./issue-list-item"; +export * from "./properties"; diff --git a/web/core/components/issues/relations/issue-list-item.tsx b/web/core/components/issues/relations/issue-list-item.tsx new file mode 100644 index 000000000..3f9386c65 --- /dev/null +++ b/web/core/components/issues/relations/issue-list-item.tsx @@ -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 = 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) => { + e.preventDefault(); + e.stopPropagation(); + removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId); + }; + + return ( +
+ {issue && ( +
+ +
+
+
+ {projectDetail?.identifier}-{issue?.sequence_id} +
+ + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > + + {issue.name} + + +
+
+ +
+
+ + {!disabled && ( + +
+ + Edit issue +
+
+ )} + + +
+ + Copy issue link +
+
+ + {!disabled && ( + +
+ + Remove relation +
+
+ )} + + {!disabled && ( + +
+ + Delete issue +
+
+ )} +
+
+
+ )} +
+ ); +}); diff --git a/web/core/components/issues/relations/issue-list.tsx b/web/core/components/issues/relations/issue-list.tsx new file mode 100644 index 000000000..5f63dd454 --- /dev/null +++ b/web/core/components/issues/relations/issue-list.tsx @@ -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 = observer((props) => { + const { + workspaceSlug, + projectId, + issueId, + issueIds, + relationKey, + disabled = false, + issueOperations, + handleIssueCrudState, + } = props; + + return ( +
+ {issueIds && + issueIds.length > 0 && + issueIds.map((relationIssueId) => ( + + ))} +
+ ); +}); diff --git a/web/core/components/issues/relations/properties.tsx b/web/core/components/issues/relations/properties.tsx new file mode 100644 index 000000000..543a88c4d --- /dev/null +++ b/web/core/components/issues/relations/properties.tsx @@ -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 = 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 ( +
+
+ +
+ +
+ +
+ +
+ 0 ? "transparent-without-text" : "border-without-text"} + buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""} + /> +
+
+ ); +}); diff --git a/web/core/components/issues/sub-issues/issue-list-item.tsx b/web/core/components/issues/sub-issues/issue-list-item.tsx index 881458243..edbd9a41e 100644 --- a/web/core/components/issues/sub-issues/issue-list-item.tsx +++ b/web/core/components/issues/sub-issues/issue-list-item.tsx @@ -191,8 +191,6 @@ export const IssueListItem: React.FC = observer((props) => { )} -
- {disabled && ( {