[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:
Aaryan Khandelwal 2024-03-15 17:29:22 +05:30 committed by GitHub
parent 92a077dce1
commit 13bbb9cde4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 312 additions and 113 deletions

View File

@ -21,16 +21,14 @@ export const IssueAttachmentsList: FC<TIssueAttachmentsList> = observer((props)
const {
attachment: { getAttachmentsByIssueId },
} = useIssueDetail();
// derived values
const issueAttachments = getAttachmentsByIssueId(issueId);
if (!issueAttachments) return <></>;
return (
<>
{issueAttachments &&
issueAttachments.length > 0 &&
issueAttachments.map((attachmentId) => (
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}

View File

@ -69,7 +69,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
</div>
<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">
Delete Attachment
Delete attachment
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
@ -94,7 +94,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
}}
disabled={loader}
>
{loader ? "Deleting..." : "Delete"}
{loader ? "Deleting" : "Delete"}
</Button>
</div>
</Dialog.Panel>

View File

@ -1,6 +1,6 @@
import { FC } from "react";
import Link from "next/link";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
// ui
import {
@ -74,8 +74,6 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
handleRestoreIssue,
isSubmitting,
} = props;
// router
const router = useRouter();
// store hooks
const { currentUser } = useUser();
const {
@ -101,10 +99,6 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
});
});
};
const redirectToIssueDetail = () => {
router.push({ pathname: `/${issueLink}` });
removeRoutePeekId();
};
// auth
const isArchivingAllowed = !isArchived && !disabled;
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" />
</button>
<button onClick={redirectToIssueDetail}>
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
</Link>
{currentMode && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect

View File

@ -1,5 +1,6 @@
export * from "./header";
export * from "./issue-attachments";
export * from "./issue-detail";
export * from "./properties";
export * from "./root";
export * from "./view";
export * from "./header";

View 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>
);
};

View File

@ -55,7 +55,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
: undefined;
return (
<>
<div className="space-y-2">
<span className="text-base font-medium text-custom-text-400">
{projectDetails?.identifier}-{issue?.sequence_id}
</span>
@ -89,6 +89,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
currentUser={currentUser}
/>
)}
</>
</div>
);
});

View File

@ -61,7 +61,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
maxDate?.setDate(maxDate.getDate());
return (
<div className="mt-1">
<div>
<h6 className="text-sm font-medium">Properties</h6>
{/* TODO: render properties using a common component */}
<div className={`w-full space-y-2 mt-3 ${disabled ? "opacity-60" : ""}`}>

View File

@ -11,13 +11,15 @@ import {
PeekOverviewProperties,
TIssueOperations,
ArchiveIssueModal,
PeekOverviewIssueAttachments,
} from "components/issues";
// hooks
import { useIssueDetail } from "hooks/store";
import { useIssueDetail, useUser } from "hooks/store";
import useKeypress from "hooks/use-keypress";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// store hooks
import { IssueActivity } from "../issue-detail/issue-activity";
import { SubIssuesRoot } from "../sub-issues";
interface IIssueView {
workspaceSlug: string;
@ -37,6 +39,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
// store hooks
const { currentUser } = useUser();
const {
setPeekIssue,
isAnyModalOpen,
@ -147,7 +150,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
issue && (
<>
{["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
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -158,6 +161,23 @@ export const IssueView: FC<IIssueView> = observer((props) => {
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
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -169,9 +189,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</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>
<div className="space-y-3">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -182,6 +202,23 @@ export const IssueView: FC<IIssueView> = observer((props) => {
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} />
</div>
</div>

View File

@ -1,6 +1,6 @@
import React from "react";
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
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
@ -11,6 +11,7 @@ import { IssueProperty } from "./properties";
// ui
// types
import { TSubIssueOperations } from "./root";
import { cn } from "helpers/common.helper";
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
export interface ISubIssues {
@ -90,11 +91,12 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
setSubIssueHelpers(parentIssueId, "issue_visibility", issueId);
}}
>
{subIssueHelpers.issue_visibility.includes(issue.id) ? (
<ChevronDown width={14} strokeWidth={2} />
) : (
<ChevronRight width={14} strokeWidth={2} />
)}
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
})}
strokeWidth={2}
/>
</div>
)}
</>

View File

@ -1,9 +1,9 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Plus, ChevronRight, ChevronDown, Loader } from "lucide-react";
import { Plus, ChevronRight, Loader, Pencil } from "lucide-react";
// 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 { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { copyTextToClipboard } from "helpers/string.helper";
@ -11,7 +11,7 @@ import { useEventTracker, useIssueDetail } from "hooks/store";
// components
import { IUser, TIssue } from "@plane/types";
import { IssueList } from "./issues-list";
import { ProgressBar } from "./progressbar";
import { cn } from "helpers/common.helper";
// ui
// helpers
// types
@ -53,6 +53,10 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
updateSubIssue,
removeSubIssue,
deleteSubIssue,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
} = useIssueDetail();
const { setTrackElement, captureIssueEvent } = useEventTracker();
// state
@ -310,55 +314,81 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
<>
{subIssues && subIssues?.length > 0 ? (
<>
<div className="relative flex items-center gap-4 text-xs">
<div
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"
<div className="relative flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1 rounded py-1 px-2 transition-all hover:bg-custom-background-80 font-medium"
onClick={handleFetchSubIssues}
>
<div className="flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center">
<div className="flex flex-shrink-0 items-center justify-center">
{subIssueHelpers.preview_loader.includes(parentIssueId) ? (
<Loader width={14} strokeWidth={2} className="animate-spin" />
) : subIssueHelpers.issue_visibility.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>
<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)
</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>
{!disabled && (
<div className="ml-auto flex flex-shrink-0 select-none items-center gap-2">
<div
className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
onClick={() => {
setTrackElement("Issue detail add sub-issue");
handleIssueCrudState("create", parentIssueId, null);
}}
>
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-issue
</div>
<div
className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail add sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
Add an existing issue
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(true);
}}
>
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
@ -379,8 +409,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
) : (
!disabled && (
<div className="flex items-center justify-between">
<div className="py-2 text-xs italic text-custom-text-300">No Sub-Issues yet</div>
<div>
<div className="text-xs italic text-custom-text-300">No sub-issues yet</div>
<CustomMenu
label={
<>
@ -397,44 +426,57 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
Create new
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(true);
}}
>
Add an existing issue
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
)
)}
{/* issue create, add from existing , update and delete modals */}
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && (
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.create?.toggle}
data={{
parent_id: issueCrudState?.create?.parentIssueId,
}}
onClose={() => handleIssueCrudState("create", null, null)}
onClose={() => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
}}
onSubmit={async (_issue: TIssue) => {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, parentIssueId, [_issue.id]);
}}
/>
)}
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && (
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
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 }}
handleOnSubmit={(_issue) =>
subIssueOperations.addSubIssue(

View File

@ -44,20 +44,24 @@ export interface IIssueDetail
IIssueCommentReactionStoreActions {
// observables
peekIssue: TPeekIssue | undefined;
isCreateIssueModalOpen: boolean;
isIssueLinkModalOpen: boolean;
isParentIssueModalOpen: boolean;
isDeleteIssueModalOpen: boolean;
isArchiveIssueModalOpen: boolean;
isRelationModalOpen: TIssueRelationTypes | null;
isSubIssuesModalOpen: boolean;
// computed
isAnyModalOpen: boolean;
// actions
setPeekIssue: (peekIssue: TPeekIssue | undefined) => void;
toggleCreateIssueModal: (value: boolean) => void;
toggleIssueLinkModal: (value: boolean) => void;
toggleParentIssueModal: (value: boolean) => void;
toggleDeleteIssueModal: (value: boolean) => void;
toggleArchiveIssueModal: (value: boolean) => void;
toggleRelationModal: (value: TIssueRelationTypes | null) => void;
toggleSubIssuesModal: (value: boolean) => void;
// store
rootIssueStore: IIssueRootStore;
issue: IIssueStore;
@ -75,11 +79,13 @@ export interface IIssueDetail
export class IssueDetail implements IIssueDetail {
// observables
peekIssue: TPeekIssue | undefined = undefined;
isCreateIssueModalOpen: boolean = false;
isIssueLinkModalOpen: boolean = false;
isParentIssueModalOpen: boolean = false;
isDeleteIssueModalOpen: boolean = false;
isArchiveIssueModalOpen: boolean = false;
isRelationModalOpen: TIssueRelationTypes | null = null;
isSubIssuesModalOpen: boolean = false;
// store
rootIssueStore: IIssueRootStore;
issue: IIssueStore;
@ -97,20 +103,24 @@ export class IssueDetail implements IIssueDetail {
makeObservable(this, {
// observables
peekIssue: observable,
isCreateIssueModalOpen: observable,
isIssueLinkModalOpen: observable.ref,
isParentIssueModalOpen: observable.ref,
isDeleteIssueModalOpen: observable.ref,
isArchiveIssueModalOpen: observable.ref,
isRelationModalOpen: observable.ref,
isSubIssuesModalOpen: observable.ref,
// computed
isAnyModalOpen: computed,
// action
setPeekIssue: action,
toggleCreateIssueModal: action,
toggleIssueLinkModal: action,
toggleParentIssueModal: action,
toggleDeleteIssueModal: action,
toggleArchiveIssueModal: action,
toggleRelationModal: action,
toggleSubIssuesModal: action,
});
// store
@ -130,21 +140,25 @@ export class IssueDetail implements IIssueDetail {
// computed
get isAnyModalOpen() {
return (
this.isCreateIssueModalOpen ||
this.isIssueLinkModalOpen ||
this.isParentIssueModalOpen ||
this.isDeleteIssueModalOpen ||
this.isArchiveIssueModalOpen ||
Boolean(this.isRelationModalOpen)
Boolean(this.isRelationModalOpen) ||
this.isSubIssuesModalOpen
);
}
// actions
setPeekIssue = (peekIssue: TPeekIssue | undefined) => (this.peekIssue = peekIssue);
toggleCreateIssueModal = (value: boolean) => (this.isCreateIssueModalOpen = value);
toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value);
toggleParentIssueModal = (value: boolean) => (this.isParentIssueModalOpen = value);
toggleDeleteIssueModal = (value: boolean) => (this.isDeleteIssueModalOpen = value);
toggleArchiveIssueModal = (value: boolean) => (this.isArchiveIssueModalOpen = value);
toggleRelationModal = (value: TIssueRelationTypes | null) => (this.isRelationModalOpen = value);
toggleSubIssuesModal = (value: boolean) => (this.isSubIssuesModalOpen = value);
// issue
fetchIssue = async (