forked from github/plane
[WEB-699] chore: implement sub-issues and attachments in the peek overview (#3956)
* chore: implement sub-issues and attachments in the peek overview * chore: add the same to full-screen view
This commit is contained in:
parent
92a077dce1
commit
13bbb9cde4
@ -21,23 +21,21 @@ export const IssueAttachmentsList: FC<TIssueAttachmentsList> = observer((props)
|
|||||||
const {
|
const {
|
||||||
attachment: { getAttachmentsByIssueId },
|
attachment: { getAttachmentsByIssueId },
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
|
// derived values
|
||||||
const issueAttachments = getAttachmentsByIssueId(issueId);
|
const issueAttachments = getAttachmentsByIssueId(issueId);
|
||||||
|
|
||||||
if (!issueAttachments) return <></>;
|
if (!issueAttachments) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{issueAttachments &&
|
{issueAttachments?.map((attachmentId) => (
|
||||||
issueAttachments.length > 0 &&
|
<IssueAttachmentsDetail
|
||||||
issueAttachments.map((attachmentId) => (
|
key={attachmentId}
|
||||||
<IssueAttachmentsDetail
|
attachmentId={attachmentId}
|
||||||
key={attachmentId}
|
disabled={disabled}
|
||||||
attachmentId={attachmentId}
|
handleAttachmentOperations={handleAttachmentOperations}
|
||||||
disabled={disabled}
|
/>
|
||||||
handleAttachmentOperations={handleAttachmentOperations}
|
))}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -69,7 +69,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||||
Delete Attachment
|
Delete attachment
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
@ -94,7 +94,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
|
|||||||
}}
|
}}
|
||||||
disabled={loader}
|
disabled={loader}
|
||||||
>
|
>
|
||||||
{loader ? "Deleting..." : "Delete"}
|
{loader ? "Deleting" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
@ -101,7 +101,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div className="relative py-3 space-y-3">
|
<div className="relative py-3 space-y-3">
|
||||||
<h3 className="text-lg">Attachments</h3>
|
<h3 className="text-lg">Attachments</h3>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
<IssueAttachmentUpload
|
<IssueAttachmentUpload
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
|
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
|
||||||
// ui
|
// ui
|
||||||
import {
|
import {
|
||||||
@ -74,8 +74,6 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
|||||||
handleRestoreIssue,
|
handleRestoreIssue,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
} = props;
|
} = props;
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentUser } = useUser();
|
const { currentUser } = useUser();
|
||||||
const {
|
const {
|
||||||
@ -101,10 +99,6 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const redirectToIssueDetail = () => {
|
|
||||||
router.push({ pathname: `/${issueLink}` });
|
|
||||||
removeRoutePeekId();
|
|
||||||
};
|
|
||||||
// auth
|
// auth
|
||||||
const isArchivingAllowed = !isArchived && !disabled;
|
const isArchivingAllowed = !isArchived && !disabled;
|
||||||
const isInArchivableGroup =
|
const isInArchivableGroup =
|
||||||
@ -122,9 +116,9 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
|||||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={redirectToIssueDetail}>
|
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
|
||||||
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||||
</button>
|
</Link>
|
||||||
{currentMode && (
|
{currentMode && (
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
export * from "./header";
|
||||||
|
export * from "./issue-attachments";
|
||||||
export * from "./issue-detail";
|
export * from "./issue-detail";
|
||||||
export * from "./properties";
|
export * from "./properties";
|
||||||
export * from "./root";
|
export * from "./root";
|
||||||
export * from "./view";
|
export * from "./view";
|
||||||
export * from "./header";
|
|
||||||
|
111
web/components/issues/peek-overview/issue-attachments.tsx
Normal file
111
web/components/issues/peek-overview/issue-attachments.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
// hooks
|
||||||
|
import { useEventTracker, useIssueDetail } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { IssueAttachmentUpload, IssueAttachmentsList, TAttachmentOperations } from "components/issues";
|
||||||
|
// ui
|
||||||
|
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
disabled: boolean;
|
||||||
|
issueId: string;
|
||||||
|
projectId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PeekOverviewIssueAttachments: React.FC<Props> = (props) => {
|
||||||
|
const { disabled, issueId, projectId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { captureIssueEvent } = useEventTracker();
|
||||||
|
const {
|
||||||
|
attachment: { createAttachment, removeAttachment },
|
||||||
|
} = useIssueDetail();
|
||||||
|
|
||||||
|
const handleAttachmentOperations: TAttachmentOperations = useMemo(
|
||||||
|
() => ({
|
||||||
|
create: async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
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, captureIssueEvent, createAttachment, removeAttachment]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h6 className="text-sm font-medium">Attachments</h6>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2 mt-3">
|
||||||
|
<IssueAttachmentUpload
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
disabled={disabled}
|
||||||
|
handleAttachmentOperations={handleAttachmentOperations}
|
||||||
|
/>
|
||||||
|
<IssueAttachmentsList
|
||||||
|
issueId={issueId}
|
||||||
|
disabled={disabled}
|
||||||
|
handleAttachmentOperations={handleAttachmentOperations}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -55,7 +55,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
<span className="text-base font-medium text-custom-text-400">
|
<span className="text-base font-medium text-custom-text-400">
|
||||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
@ -89,6 +89,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
|||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -61,7 +61,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-1">
|
<div>
|
||||||
<h6 className="text-sm font-medium">Properties</h6>
|
<h6 className="text-sm font-medium">Properties</h6>
|
||||||
{/* TODO: render properties using a common component */}
|
{/* TODO: render properties using a common component */}
|
||||||
<div className={`w-full space-y-2 mt-3 ${disabled ? "opacity-60" : ""}`}>
|
<div className={`w-full space-y-2 mt-3 ${disabled ? "opacity-60" : ""}`}>
|
||||||
|
@ -11,13 +11,15 @@ import {
|
|||||||
PeekOverviewProperties,
|
PeekOverviewProperties,
|
||||||
TIssueOperations,
|
TIssueOperations,
|
||||||
ArchiveIssueModal,
|
ArchiveIssueModal,
|
||||||
|
PeekOverviewIssueAttachments,
|
||||||
} from "components/issues";
|
} from "components/issues";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "hooks/store";
|
import { useIssueDetail, useUser } from "hooks/store";
|
||||||
import useKeypress from "hooks/use-keypress";
|
import useKeypress from "hooks/use-keypress";
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// store hooks
|
// store hooks
|
||||||
import { IssueActivity } from "../issue-detail/issue-activity";
|
import { IssueActivity } from "../issue-detail/issue-activity";
|
||||||
|
import { SubIssuesRoot } from "../sub-issues";
|
||||||
|
|
||||||
interface IIssueView {
|
interface IIssueView {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -37,6 +39,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
// ref
|
// ref
|
||||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||||
// store hooks
|
// store hooks
|
||||||
|
const { currentUser } = useUser();
|
||||||
const {
|
const {
|
||||||
setPeekIssue,
|
setPeekIssue,
|
||||||
isAnyModalOpen,
|
isAnyModalOpen,
|
||||||
@ -147,7 +150,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
issue && (
|
issue && (
|
||||||
<>
|
<>
|
||||||
{["side-peek", "modal"].includes(peekMode) ? (
|
{["side-peek", "modal"].includes(peekMode) ? (
|
||||||
<div className="relative flex flex-col gap-3 px-8 py-5">
|
<div className="relative flex flex-col gap-3 px-8 py-5 space-y-3">
|
||||||
<PeekOverviewIssueDetails
|
<PeekOverviewIssueDetails
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@ -158,6 +161,23 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<SubIssuesRoot
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
parentIssueId={issueId}
|
||||||
|
currentUser={currentUser}
|
||||||
|
disabled={disabled || is_archived}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PeekOverviewIssueAttachments
|
||||||
|
disabled={disabled || is_archived}
|
||||||
|
issueId={issueId}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
|
||||||
<PeekOverviewProperties
|
<PeekOverviewProperties
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@ -169,9 +189,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`vertical-scrollbar flex h-full w-full overflow-auto`}>
|
<div className="vertical-scrollbar flex h-full w-full overflow-auto">
|
||||||
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
|
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<PeekOverviewIssueDetails
|
<PeekOverviewIssueDetails
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@ -182,6 +202,23 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<SubIssuesRoot
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
parentIssueId={issueId}
|
||||||
|
currentUser={currentUser}
|
||||||
|
disabled={disabled || is_archived}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PeekOverviewIssueAttachments
|
||||||
|
disabled={disabled || is_archived}
|
||||||
|
issueId={issueId}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
|
||||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ChevronDown, ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
|
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
|
||||||
// components
|
// components
|
||||||
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
|
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
|
||||||
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||||
@ -11,6 +11,7 @@ import { IssueProperty } from "./properties";
|
|||||||
// ui
|
// ui
|
||||||
// types
|
// types
|
||||||
import { TSubIssueOperations } from "./root";
|
import { TSubIssueOperations } from "./root";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||||
|
|
||||||
export interface ISubIssues {
|
export interface ISubIssues {
|
||||||
@ -90,11 +91,12 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
|||||||
setSubIssueHelpers(parentIssueId, "issue_visibility", issueId);
|
setSubIssueHelpers(parentIssueId, "issue_visibility", issueId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{subIssueHelpers.issue_visibility.includes(issue.id) ? (
|
<ChevronRight
|
||||||
<ChevronDown width={14} strokeWidth={2} />
|
className={cn("h-3 w-3 transition-all", {
|
||||||
) : (
|
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
|
||||||
<ChevronRight width={14} strokeWidth={2} />
|
})}
|
||||||
)}
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { FC, useCallback, useEffect, useMemo, useState } from "react";
|
import { FC, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Plus, ChevronRight, ChevronDown, Loader } from "lucide-react";
|
import { Plus, ChevronRight, Loader, Pencil } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
import { CircularProgressIndicator, CustomMenu, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
import { ExistingIssuesListModal } from "components/core";
|
import { ExistingIssuesListModal } from "components/core";
|
||||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
import { copyTextToClipboard } from "helpers/string.helper";
|
||||||
@ -11,7 +11,7 @@ import { useEventTracker, useIssueDetail } from "hooks/store";
|
|||||||
// components
|
// components
|
||||||
import { IUser, TIssue } from "@plane/types";
|
import { IUser, TIssue } from "@plane/types";
|
||||||
import { IssueList } from "./issues-list";
|
import { IssueList } from "./issues-list";
|
||||||
import { ProgressBar } from "./progressbar";
|
import { cn } from "helpers/common.helper";
|
||||||
// ui
|
// ui
|
||||||
// helpers
|
// helpers
|
||||||
// types
|
// types
|
||||||
@ -53,6 +53,10 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
|
|||||||
updateSubIssue,
|
updateSubIssue,
|
||||||
removeSubIssue,
|
removeSubIssue,
|
||||||
deleteSubIssue,
|
deleteSubIssue,
|
||||||
|
isCreateIssueModalOpen,
|
||||||
|
toggleCreateIssueModal,
|
||||||
|
isSubIssuesModalOpen,
|
||||||
|
toggleSubIssuesModal,
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
const { setTrackElement, captureIssueEvent } = useEventTracker();
|
const { setTrackElement, captureIssueEvent } = useEventTracker();
|
||||||
// state
|
// state
|
||||||
@ -310,55 +314,81 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
|
|||||||
<>
|
<>
|
||||||
{subIssues && subIssues?.length > 0 ? (
|
{subIssues && subIssues?.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex items-center gap-4 text-xs">
|
<div className="relative flex items-center justify-between gap-2 text-xs">
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex cursor-pointer select-none items-center gap-1 rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
|
<button
|
||||||
onClick={handleFetchSubIssues}
|
type="button"
|
||||||
>
|
className="flex items-center gap-1 rounded py-1 px-2 transition-all hover:bg-custom-background-80 font-medium"
|
||||||
<div className="flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center">
|
onClick={handleFetchSubIssues}
|
||||||
{subIssueHelpers.preview_loader.includes(parentIssueId) ? (
|
>
|
||||||
<Loader width={14} strokeWidth={2} className="animate-spin" />
|
<div className="flex flex-shrink-0 items-center justify-center">
|
||||||
) : subIssueHelpers.issue_visibility.includes(parentIssueId) ? (
|
{subIssueHelpers.preview_loader.includes(parentIssueId) ? (
|
||||||
<ChevronDown width={16} strokeWidth={2} />
|
<Loader strokeWidth={2} className="h-3 w-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight width={14} strokeWidth={2} />
|
<ChevronRight
|
||||||
)}
|
className={cn("h-3 w-3 transition-all", {
|
||||||
|
"rotate-90": subIssueHelpers.issue_visibility.includes(parentIssueId),
|
||||||
|
})}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>Sub-issues</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-custom-text-300">
|
||||||
|
<CircularProgressIndicator
|
||||||
|
size={16}
|
||||||
|
percentage={
|
||||||
|
subIssuesDistribution?.completed?.length && subIssues.length
|
||||||
|
? (subIssuesDistribution?.completed?.length / subIssues.length) * 100
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{subIssuesDistribution?.completed?.length ?? 0}/{subIssues.length} Done
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>Sub-issues</div>
|
|
||||||
<div>({subIssues?.length || 0})</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full max-w-[250px] select-none">
|
|
||||||
<ProgressBar
|
|
||||||
total={subIssues?.length || 0}
|
|
||||||
done={
|
|
||||||
((subIssuesDistribution?.cancelled ?? []).length || 0) +
|
|
||||||
((subIssuesDistribution?.completed ?? []).length || 0)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<div className="ml-auto flex flex-shrink-0 select-none items-center gap-2">
|
<CustomMenu
|
||||||
<div
|
label={
|
||||||
className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
|
<>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
Add sub-issue
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
buttonClassName="whitespace-nowrap"
|
||||||
|
placement="bottom-end"
|
||||||
|
noBorder
|
||||||
|
noChevron
|
||||||
|
>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement("Issue detail add sub-issue");
|
setTrackElement("Issue detail nested sub-issue");
|
||||||
handleIssueCrudState("create", parentIssueId, null);
|
handleIssueCrudState("create", parentIssueId, null);
|
||||||
|
toggleCreateIssueModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add sub-issue
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Pencil className="h-3 w-3" />
|
||||||
<div
|
<span>Create new</span>
|
||||||
className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement("Issue detail add sub-issue");
|
setTrackElement("Issue detail nested sub-issue");
|
||||||
handleIssueCrudState("existing", parentIssueId, null);
|
handleIssueCrudState("existing", parentIssueId, null);
|
||||||
|
toggleSubIssuesModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add an existing issue
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<LayersIcon className="h-3 w-3" />
|
||||||
</div>
|
<span>Add existing</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -379,62 +409,74 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
|
|||||||
) : (
|
) : (
|
||||||
!disabled && (
|
!disabled && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="py-2 text-xs italic text-custom-text-300">No Sub-Issues yet</div>
|
<div className="text-xs italic text-custom-text-300">No sub-issues yet</div>
|
||||||
<div>
|
<CustomMenu
|
||||||
<CustomMenu
|
label={
|
||||||
label={
|
<>
|
||||||
<>
|
<Plus className="h-3 w-3" />
|
||||||
<Plus className="h-3 w-3" />
|
Add sub-issue
|
||||||
Add sub-issue
|
</>
|
||||||
</>
|
}
|
||||||
}
|
buttonClassName="whitespace-nowrap"
|
||||||
buttonClassName="whitespace-nowrap"
|
placement="bottom-end"
|
||||||
placement="bottom-end"
|
noBorder
|
||||||
noBorder
|
noChevron
|
||||||
noChevron
|
>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setTrackElement("Issue detail nested sub-issue");
|
||||||
|
handleIssueCrudState("create", parentIssueId, null);
|
||||||
|
toggleCreateIssueModal(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => {
|
<Pencil className="h-3 w-3" />
|
||||||
setTrackElement("Issue detail nested sub-issue");
|
<span>Create new</span>
|
||||||
handleIssueCrudState("create", parentIssueId, null);
|
</div>
|
||||||
}}
|
</CustomMenu.MenuItem>
|
||||||
>
|
<CustomMenu.MenuItem
|
||||||
Create new
|
onClick={() => {
|
||||||
</CustomMenu.MenuItem>
|
setTrackElement("Issue detail nested sub-issue");
|
||||||
<CustomMenu.MenuItem
|
handleIssueCrudState("existing", parentIssueId, null);
|
||||||
onClick={() => {
|
toggleSubIssuesModal(true);
|
||||||
setTrackElement("Issue detail nested sub-issue");
|
}}
|
||||||
handleIssueCrudState("existing", parentIssueId, null);
|
>
|
||||||
}}
|
<div className="flex items-center gap-2">
|
||||||
>
|
<LayersIcon className="h-3 w-3" />
|
||||||
Add an existing issue
|
<span>Add existing</span>
|
||||||
</CustomMenu.MenuItem>
|
</div>
|
||||||
</CustomMenu>
|
</CustomMenu.MenuItem>
|
||||||
</div>
|
</CustomMenu>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* issue create, add from existing , update and delete modals */}
|
{/* issue create, add from existing , update and delete modals */}
|
||||||
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && (
|
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen && (
|
||||||
<CreateUpdateIssueModal
|
<CreateUpdateIssueModal
|
||||||
isOpen={issueCrudState?.create?.toggle}
|
isOpen={issueCrudState?.create?.toggle}
|
||||||
data={{
|
data={{
|
||||||
parent_id: issueCrudState?.create?.parentIssueId,
|
parent_id: issueCrudState?.create?.parentIssueId,
|
||||||
}}
|
}}
|
||||||
onClose={() => handleIssueCrudState("create", null, null)}
|
onClose={() => {
|
||||||
|
handleIssueCrudState("create", null, null);
|
||||||
|
toggleCreateIssueModal(false);
|
||||||
|
}}
|
||||||
onSubmit={async (_issue: TIssue) => {
|
onSubmit={async (_issue: TIssue) => {
|
||||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, parentIssueId, [_issue.id]);
|
await subIssueOperations.addSubIssue(workspaceSlug, projectId, parentIssueId, [_issue.id]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && (
|
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen && (
|
||||||
<ExistingIssuesListModal
|
<ExistingIssuesListModal
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
isOpen={issueCrudState?.existing?.toggle}
|
isOpen={issueCrudState?.existing?.toggle}
|
||||||
handleClose={() => handleIssueCrudState("existing", null, null)}
|
handleClose={() => {
|
||||||
|
handleIssueCrudState("existing", null, null);
|
||||||
|
toggleSubIssuesModal(false);
|
||||||
|
}}
|
||||||
searchParams={{ sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId }}
|
searchParams={{ sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId }}
|
||||||
handleOnSubmit={(_issue) =>
|
handleOnSubmit={(_issue) =>
|
||||||
subIssueOperations.addSubIssue(
|
subIssueOperations.addSubIssue(
|
||||||
|
@ -44,20 +44,24 @@ export interface IIssueDetail
|
|||||||
IIssueCommentReactionStoreActions {
|
IIssueCommentReactionStoreActions {
|
||||||
// observables
|
// observables
|
||||||
peekIssue: TPeekIssue | undefined;
|
peekIssue: TPeekIssue | undefined;
|
||||||
|
isCreateIssueModalOpen: boolean;
|
||||||
isIssueLinkModalOpen: boolean;
|
isIssueLinkModalOpen: boolean;
|
||||||
isParentIssueModalOpen: boolean;
|
isParentIssueModalOpen: boolean;
|
||||||
isDeleteIssueModalOpen: boolean;
|
isDeleteIssueModalOpen: boolean;
|
||||||
isArchiveIssueModalOpen: boolean;
|
isArchiveIssueModalOpen: boolean;
|
||||||
isRelationModalOpen: TIssueRelationTypes | null;
|
isRelationModalOpen: TIssueRelationTypes | null;
|
||||||
|
isSubIssuesModalOpen: boolean;
|
||||||
// computed
|
// computed
|
||||||
isAnyModalOpen: boolean;
|
isAnyModalOpen: boolean;
|
||||||
// actions
|
// actions
|
||||||
setPeekIssue: (peekIssue: TPeekIssue | undefined) => void;
|
setPeekIssue: (peekIssue: TPeekIssue | undefined) => void;
|
||||||
|
toggleCreateIssueModal: (value: boolean) => void;
|
||||||
toggleIssueLinkModal: (value: boolean) => void;
|
toggleIssueLinkModal: (value: boolean) => void;
|
||||||
toggleParentIssueModal: (value: boolean) => void;
|
toggleParentIssueModal: (value: boolean) => void;
|
||||||
toggleDeleteIssueModal: (value: boolean) => void;
|
toggleDeleteIssueModal: (value: boolean) => void;
|
||||||
toggleArchiveIssueModal: (value: boolean) => void;
|
toggleArchiveIssueModal: (value: boolean) => void;
|
||||||
toggleRelationModal: (value: TIssueRelationTypes | null) => void;
|
toggleRelationModal: (value: TIssueRelationTypes | null) => void;
|
||||||
|
toggleSubIssuesModal: (value: boolean) => void;
|
||||||
// store
|
// store
|
||||||
rootIssueStore: IIssueRootStore;
|
rootIssueStore: IIssueRootStore;
|
||||||
issue: IIssueStore;
|
issue: IIssueStore;
|
||||||
@ -75,11 +79,13 @@ export interface IIssueDetail
|
|||||||
export class IssueDetail implements IIssueDetail {
|
export class IssueDetail implements IIssueDetail {
|
||||||
// observables
|
// observables
|
||||||
peekIssue: TPeekIssue | undefined = undefined;
|
peekIssue: TPeekIssue | undefined = undefined;
|
||||||
|
isCreateIssueModalOpen: boolean = false;
|
||||||
isIssueLinkModalOpen: boolean = false;
|
isIssueLinkModalOpen: boolean = false;
|
||||||
isParentIssueModalOpen: boolean = false;
|
isParentIssueModalOpen: boolean = false;
|
||||||
isDeleteIssueModalOpen: boolean = false;
|
isDeleteIssueModalOpen: boolean = false;
|
||||||
isArchiveIssueModalOpen: boolean = false;
|
isArchiveIssueModalOpen: boolean = false;
|
||||||
isRelationModalOpen: TIssueRelationTypes | null = null;
|
isRelationModalOpen: TIssueRelationTypes | null = null;
|
||||||
|
isSubIssuesModalOpen: boolean = false;
|
||||||
// store
|
// store
|
||||||
rootIssueStore: IIssueRootStore;
|
rootIssueStore: IIssueRootStore;
|
||||||
issue: IIssueStore;
|
issue: IIssueStore;
|
||||||
@ -97,20 +103,24 @@ export class IssueDetail implements IIssueDetail {
|
|||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
// observables
|
// observables
|
||||||
peekIssue: observable,
|
peekIssue: observable,
|
||||||
|
isCreateIssueModalOpen: observable,
|
||||||
isIssueLinkModalOpen: observable.ref,
|
isIssueLinkModalOpen: observable.ref,
|
||||||
isParentIssueModalOpen: observable.ref,
|
isParentIssueModalOpen: observable.ref,
|
||||||
isDeleteIssueModalOpen: observable.ref,
|
isDeleteIssueModalOpen: observable.ref,
|
||||||
isArchiveIssueModalOpen: observable.ref,
|
isArchiveIssueModalOpen: observable.ref,
|
||||||
isRelationModalOpen: observable.ref,
|
isRelationModalOpen: observable.ref,
|
||||||
|
isSubIssuesModalOpen: observable.ref,
|
||||||
// computed
|
// computed
|
||||||
isAnyModalOpen: computed,
|
isAnyModalOpen: computed,
|
||||||
// action
|
// action
|
||||||
setPeekIssue: action,
|
setPeekIssue: action,
|
||||||
|
toggleCreateIssueModal: action,
|
||||||
toggleIssueLinkModal: action,
|
toggleIssueLinkModal: action,
|
||||||
toggleParentIssueModal: action,
|
toggleParentIssueModal: action,
|
||||||
toggleDeleteIssueModal: action,
|
toggleDeleteIssueModal: action,
|
||||||
toggleArchiveIssueModal: action,
|
toggleArchiveIssueModal: action,
|
||||||
toggleRelationModal: action,
|
toggleRelationModal: action,
|
||||||
|
toggleSubIssuesModal: action,
|
||||||
});
|
});
|
||||||
|
|
||||||
// store
|
// store
|
||||||
@ -130,21 +140,25 @@ export class IssueDetail implements IIssueDetail {
|
|||||||
// computed
|
// computed
|
||||||
get isAnyModalOpen() {
|
get isAnyModalOpen() {
|
||||||
return (
|
return (
|
||||||
|
this.isCreateIssueModalOpen ||
|
||||||
this.isIssueLinkModalOpen ||
|
this.isIssueLinkModalOpen ||
|
||||||
this.isParentIssueModalOpen ||
|
this.isParentIssueModalOpen ||
|
||||||
this.isDeleteIssueModalOpen ||
|
this.isDeleteIssueModalOpen ||
|
||||||
this.isArchiveIssueModalOpen ||
|
this.isArchiveIssueModalOpen ||
|
||||||
Boolean(this.isRelationModalOpen)
|
Boolean(this.isRelationModalOpen) ||
|
||||||
|
this.isSubIssuesModalOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
setPeekIssue = (peekIssue: TPeekIssue | undefined) => (this.peekIssue = peekIssue);
|
setPeekIssue = (peekIssue: TPeekIssue | undefined) => (this.peekIssue = peekIssue);
|
||||||
|
toggleCreateIssueModal = (value: boolean) => (this.isCreateIssueModalOpen = value);
|
||||||
toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value);
|
toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value);
|
||||||
toggleParentIssueModal = (value: boolean) => (this.isParentIssueModalOpen = value);
|
toggleParentIssueModal = (value: boolean) => (this.isParentIssueModalOpen = value);
|
||||||
toggleDeleteIssueModal = (value: boolean) => (this.isDeleteIssueModalOpen = value);
|
toggleDeleteIssueModal = (value: boolean) => (this.isDeleteIssueModalOpen = value);
|
||||||
toggleArchiveIssueModal = (value: boolean) => (this.isArchiveIssueModalOpen = value);
|
toggleArchiveIssueModal = (value: boolean) => (this.isArchiveIssueModalOpen = value);
|
||||||
toggleRelationModal = (value: TIssueRelationTypes | null) => (this.isRelationModalOpen = value);
|
toggleRelationModal = (value: TIssueRelationTypes | null) => (this.isRelationModalOpen = value);
|
||||||
|
toggleSubIssuesModal = (value: boolean) => (this.isSubIssuesModalOpen = value);
|
||||||
|
|
||||||
// issue
|
// issue
|
||||||
fetchIssue = async (
|
fetchIssue = async (
|
||||||
|
Loading…
Reference in New Issue
Block a user