[WEB-2717] chore: implemented issue attachment upload progress (#5901)

* chore: added attachment upload progress

* chore: add debounce while updating the upload status

* chore: update percentage calc logic

* chore: update debounce interval
This commit is contained in:
Aaryan Khandelwal 2024-10-29 19:22:29 +05:30 committed by GitHub
parent 538e78f135
commit c423d7d9df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 314 additions and 194 deletions

View file

@ -19,19 +19,19 @@ import { truncateText } from "@/helpers/string.helper";
import { useIssueDetail, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import { TAttachmentOperations } from "./root";
import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentHelpers, "create">;
type TIssueAttachmentsDetail = {
attachmentId: string;
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
attachmentHelpers: TAttachmentOperationsRemoveModal;
disabled?: boolean;
};
export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((props) => {
// props
const { attachmentId, handleAttachmentOperations, disabled } = props;
const { attachmentId, attachmentHelpers, disabled } = props;
// store hooks
const { getUserDetails } = useMember();
const {
@ -56,7 +56,7 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((pro
<IssueAttachmentDeleteModal
isOpen={isDeleteIssueAttachmentModalOpen}
onClose={() => setIsDeleteIssueAttachmentModalOpen(false)}
handleAttachmentOperations={handleAttachmentOperations}
attachmentOperations={attachmentHelpers.operations}
attachmentId={attachmentId}
/>
)}

View file

@ -7,26 +7,26 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
import { useIssueDetail } from "@/hooks/store";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// types
import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentsListItem } from "./attachment-list-item";
import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
// types
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
import { TAttachmentOperations } from "./root";
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
type TIssueAttachmentItemList = {
workspaceSlug: string;
projectId: string;
issueId: string;
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
};
export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((props) => {
const { workspaceSlug, projectId, issueId, handleAttachmentOperations, disabled } = props;
const { workspaceSlug, projectId, issueId, attachmentHelpers, disabled } = props;
// states
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
// store hooks
const {
attachment: { getAttachmentsByIssueId },
@ -34,6 +34,9 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p
toggleDeleteAttachmentModal,
fetchActivities,
} = useIssueDetail();
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
const { create: createAttachment } = attachmentOperations;
const { uploadStatus } = attachmentSnapshot;
// file size
const { maxFileSize } = useFileSize();
// derived values
@ -52,9 +55,8 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
handleAttachmentOperations
.create(currentFile)
setIsUploading(true);
createAttachment(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
@ -64,7 +66,7 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p
})
.finally(() => {
handleFetchPropertyActivities();
setIsLoading(false);
setIsUploading(false);
});
return;
}
@ -79,47 +81,52 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p
});
return;
},
[handleAttachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
disabled: isUploading || disabled,
});
if (!issueAttachments) return <></>;
return (
<>
{attachmentDeleteModalId && (
<IssueAttachmentDeleteModal
isOpen={Boolean(attachmentDeleteModalId)}
onClose={() => toggleDeleteAttachmentModal(null)}
handleAttachmentOperations={handleAttachmentOperations}
attachmentId={attachmentDeleteModalId}
/>
)}
<div
{...getRootProps()}
className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
{isDragActive && (
<div className="absolute flex items-center justify-center left-0 top-0 h-full w-full bg-custom-background-90/75 z-30 ">
<div className="flex items-center justify-center p-1 rounded-md bg-custom-background-100">
<div className="flex flex-col justify-center items-center px-5 py-6 rounded-md border border-dashed border-custom-border-300">
<UploadCloud className="size-7" />
<span className="text-sm text-custom-text-300">Drag and drop anywhere to upload</span>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{issueAttachments && (
<>
{attachmentDeleteModalId && (
<IssueAttachmentDeleteModal
isOpen={Boolean(attachmentDeleteModalId)}
onClose={() => toggleDeleteAttachmentModal(null)}
attachmentOperations={attachmentOperations}
attachmentId={attachmentDeleteModalId}
/>
)}
<div
{...getRootProps()}
className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
{isDragActive && (
<div className="absolute flex items-center justify-center left-0 top-0 h-full w-full bg-custom-background-90/75 z-30 ">
<div className="flex items-center justify-center p-1 rounded-md bg-custom-background-100">
<div className="flex flex-col justify-center items-center px-5 py-6 rounded-md border border-dashed border-custom-border-300">
<UploadCloud className="size-7" />
<span className="text-sm text-custom-text-300">Drag and drop anywhere to upload</span>
</div>
</div>
</div>
</div>
)}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsListItem key={attachmentId} attachmentId={attachmentId} disabled={disabled} />
))}
</div>
)}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsListItem key={attachmentId} attachmentId={attachmentId} disabled={disabled} />
))}
</div>
</>
)}
</>
);
});

View file

@ -0,0 +1,45 @@
"use client";
import { observer } from "mobx-react";
// ui
import { CircularProgressIndicator, Tooltip } from "@plane/ui";
// components
import { getFileIcon } from "@/components/icons";
// helpers
import { getFileExtension } from "@/helpers/attachment.helper";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
type Props = {
uploadStatus: TAttachmentUploadStatus;
};
export const IssueAttachmentsUploadItem: React.FC<Props> = observer((props) => {
// props
const { uploadStatus } = props;
// derived values
const fileName = uploadStatus.name;
const fileExtension = getFileExtension(uploadStatus.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="flex items-center justify-between gap-3 h-11 bg-custom-background-90 pl-9 pr-2 pointer-events-none">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex-shrink-0">{fileIcon}</div>
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<p className="text-custom-text-200 font-medium truncate">{fileName}</p>
</Tooltip>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</span>
<div className="flex-shrink-0 text-sm font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View file

@ -0,0 +1,54 @@
"use client";
import { observer } from "mobx-react";
// ui
import { CircularProgressIndicator, Tooltip } from "@plane/ui";
// icons
import { getFileIcon } from "@/components/icons";
// helpers
import { getFileExtension } from "@/helpers/attachment.helper";
import { truncateText } from "@/helpers/string.helper";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
type Props = {
uploadStatus: TAttachmentUploadStatus;
};
export const IssueAttachmentsUploadDetails: React.FC<Props> = observer((props) => {
// props
const { uploadStatus } = props;
// derived values
const fileName = uploadStatus.name;
const fileExtension = getFileExtension(uploadStatus.name ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 bg-custom-background-90 px-4 py-2 text-sm pointer-events-none">
<div className="flex-shrink-0 flex items-center gap-3">
<div className="h-7 w-7">{fileIcon}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<span className="text-sm">{truncateText(`${fileName}`, 10)}</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-xs text-custom-text-200">
<span>{fileExtension.toUpperCase()}</span>
</div>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</span>
<div className="flex-shrink-0 text-sm font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View file

@ -4,18 +4,18 @@ import { useDropzone } from "react-dropzone";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// types
import { TAttachmentOperations } from "./root";
import { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
type TAttachmentOperationsModal = Exclude<TAttachmentOperations, "remove">;
type TAttachmentOperationsModal = Pick<TAttachmentOperations, "create">;
type Props = {
workspaceSlug: string;
disabled?: boolean;
handleAttachmentOperations: TAttachmentOperationsModal;
attachmentOperations: TAttachmentOperationsModal;
};
export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
const { workspaceSlug, disabled = false, handleAttachmentOperations } = props;
const { workspaceSlug, disabled = false, attachmentOperations } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// file size
@ -27,9 +27,9 @@ export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
handleAttachmentOperations.create(currentFile).finally(() => setIsLoading(false));
attachmentOperations.create(currentFile).finally(() => setIsLoading(false));
},
[handleAttachmentOperations, workspaceSlug]
[attachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({

View file

@ -2,38 +2,40 @@ import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentsDetail } from "./attachment-detail";
// types
import { TAttachmentOperations } from "./root";
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
import { IssueAttachmentsUploadDetails } from "./attachment-upload-details";
type TIssueAttachmentsList = {
issueId: string;
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
};
export const IssueAttachmentsList: FC<TIssueAttachmentsList> = observer((props) => {
const { issueId, handleAttachmentOperations, disabled } = props;
const { issueId, attachmentHelpers, disabled } = props;
// store hooks
const {
attachment: { getAttachmentsByIssueId },
} = useIssueDetail();
// derived values
const { snapshot: attachmentSnapshot } = attachmentHelpers;
const { uploadStatus } = attachmentSnapshot;
const issueAttachments = getAttachmentsByIssueId(issueId);
if (!issueAttachments) return <></>;
return (
<>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadDetails key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
attachmentHelpers={attachmentHelpers}
/>
))}
</>

View file

@ -8,19 +8,19 @@ import { getFileName } from "@/helpers/attachment.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TAttachmentOperations } from "./root";
import { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
export type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
export type TAttachmentOperationsRemoveModal = Pick<TAttachmentOperations, "remove">;
type Props = {
isOpen: boolean;
onClose: () => void;
attachmentId: string;
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
attachmentOperations: TAttachmentOperationsRemoveModal;
};
export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => {
const { isOpen, onClose, attachmentId, handleAttachmentOperations } = props;
const { isOpen, onClose, attachmentId, attachmentOperations } = props;
// states
const [loader, setLoader] = useState(false);
@ -40,7 +40,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => {
const handleDeletion = async (assetId: string) => {
setLoader(true);
handleAttachmentOperations.remove(assetId).finally(() => handleClose());
attachmentOperations.remove(assetId).finally(() => handleClose());
};
if (!attachment) return <></>;

View file

@ -1,6 +1,8 @@
export * from "./attachment-detail";
export * from "./attachment-item-list";
export * from "./attachment-list-item";
export * from "./attachment-list-upload-item";
export * from "./attachment-upload-details";
export * from "./attachment-upload";
export * from "./attachments-list";
export * from "./delete-attachment-modal";

View file

@ -1,10 +1,9 @@
"use client";
import { FC, useMemo } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// ui
import { useAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentUpload } from "./attachment-upload";
import { IssueAttachmentsList } from "./attachments-list";
@ -16,89 +15,11 @@ export type TIssueAttachmentRoot = {
disabled?: boolean;
};
export type TAttachmentOperations = {
create: (file: File) => Promise<void>;
remove: (linkId: string) => Promise<void>;
};
export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = observer((props) => {
// props
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// hooks
const { createAttachment, removeAttachment } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const handleAttachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (file: File) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
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",
});
}
},
}),
[captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId);
return (
<div className="relative py-3 space-y-3">
@ -107,14 +28,10 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
<IssueAttachmentUpload
workspaceSlug={workspaceSlug}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
<IssueAttachmentsList
issueId={issueId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
attachmentOperations={attachmentHelpers.operations}
/>
<IssueAttachmentsList issueId={issueId} disabled={disabled} attachmentHelpers={attachmentHelpers} />
</div>
</div>
);
};
});

View file

@ -1,5 +1,6 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import { IssueAttachmentItemList } from "@/components/issues/attachment";
// helper
@ -12,17 +13,17 @@ type Props = {
disabled: boolean;
};
export const IssueAttachmentsCollapsibleContent: FC<Props> = (props) => {
export const IssueAttachmentsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// helper
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId);
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId);
return (
<IssueAttachmentItemList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
attachmentHelpers={attachmentHelpers}
/>
);
};
});

View file

@ -1,26 +1,41 @@
"use client";
import { useMemo } from "react";
// plane ui
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// type
import { TAttachmentOperations } from "@/components/issues/attachment";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// types
import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
export type TAttachmentOperations = {
create: (file: File) => Promise<void>;
remove: (attachmentId: string) => Promise<void>;
};
export type TAttachmentSnapshot = {
uploadStatus: TAttachmentUploadStatus[] | undefined;
};
export type TAttachmentHelpers = {
operations: TAttachmentOperations;
snapshot: TAttachmentSnapshot;
};
export const useAttachmentOperations = (
workspaceSlug: string,
projectId: string,
issueId: string
): TAttachmentOperations => {
const { createAttachment, removeAttachment } = useIssueDetail();
): TAttachmentHelpers => {
const {
attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId },
} = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const handleAttachmentOperations: TAttachmentOperations = useMemo(
const attachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (file: File) => {
console.log("creating attachment...", file);
create: async (file) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
@ -48,9 +63,10 @@ export const useAttachmentOperations = (
eventName: "Issue attachment added",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
});
throw error;
}
},
remove: async (attachmentId: string) => {
remove: async (attachmentId) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
@ -84,8 +100,12 @@ export const useAttachmentOperations = (
}
},
}),
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
[captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId);
return handleAttachmentOperations;
return {
operations: attachmentOperations,
snapshot: { uploadStatus: attachmentsUploadStatus },
};
};

View file

@ -30,7 +30,7 @@ export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
// file size
const { maxFileSize } = useFileSize();
// operations
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId);
const { operations: attachmentOperations } = useAttachmentOperations(workspaceSlug, projectId, issueId);
// handlers
const handleFetchPropertyActivities = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId);
@ -45,7 +45,7 @@ export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
handleAttachmentOperations
attachmentOperations
.create(currentFile)
.catch(() => {
setToast({
@ -72,7 +72,7 @@ export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
});
return;
},
[maxFileSize, workspaceSlug, handleAttachmentOperations, handleFetchPropertyActivities, setLastWidgetAction]
[attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
);
const { getRootProps, getInputProps } = useDropzone({

View file

@ -24,6 +24,7 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId },
attachment: { getAttachmentsUploadStatusByIssueId },
relation: { getRelationCountByIssueId },
} = useIssueDetail();
@ -36,7 +37,9 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0;
const shouldRenderRelations = issueRelationsCount > 0;
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0;
const shouldRenderAttachments = !!issue?.attachment_count && issue?.attachment_count > 0;
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
const shouldRenderAttachments =
(!!issue?.attachment_count && issue?.attachment_count > 0) || (!!attachmentUploads && attachmentUploads.length > 0);
return (
<div className="flex flex-col">

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosInstance } from "axios";
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
export abstract class APIService {
protected baseURL: string;
@ -28,26 +28,26 @@ export abstract class APIService {
);
}
get(url: string, params = {}, config = {}) {
get(url: string, params = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.get(url, {
...params,
...config,
});
}
post(url: string, data = {}, config = {}) {
post(url: string, data = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.post(url, data, config);
}
put(url: string, data = {}, config = {}) {
put(url: string, data = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.put(url, data, config);
}
patch(url: string, data = {}, config = {}) {
patch(url: string, data = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.patch(url, data, config);
}
delete(url: string, data?: any, config = {}) {
delete(url: string, data?: any, config: AxiosRequestConfig = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}

View file

@ -1,4 +1,4 @@
import axios from "axios";
import axios, { AxiosRequestConfig } from "axios";
// services
import { APIService } from "@/services/api.service";
@ -9,7 +9,11 @@ export class FileUploadService extends APIService {
super("");
}
async uploadFile(url: string, data: FormData): Promise<void> {
async uploadFile(
url: string,
data: FormData,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<void> {
this.cancelSource = axios.CancelToken.source();
return this.post(url, data, {
headers: {
@ -17,6 +21,7 @@ export class FileUploadService extends APIService {
},
cancelToken: this.cancelSource.token,
withCredentials: false,
onUploadProgress: uploadProgressHandler,
})
.then((response) => response?.data)
.catch((error) => {

View file

@ -1,3 +1,5 @@
import { AxiosRequestConfig } from "axios";
// plane types
import { TIssueAttachment, TIssueAttachmentUploadResponse } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
@ -34,7 +36,8 @@ export class IssueAttachmentService extends APIService {
workspaceSlug: string,
projectId: string,
issueId: string,
file: File
file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<TIssueAttachment> {
const fileMetaData = getFileMetaDataForUpload(file);
return this.post(
@ -44,7 +47,11 @@ export class IssueAttachmentService extends APIService {
.then(async (response) => {
const signedURLResponse: TIssueAttachmentUploadResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
await this.fileUploadService.uploadFile(
signedURLResponse.upload_data.url,
fileUploadPayload,
uploadProgressHandler
);
await this.updateIssueAttachmentUploadStatus(workspaceSlug, projectId, issueId, signedURLResponse.asset_id);
return signedURLResponse.attachment;
})

View file

@ -1,9 +1,12 @@
import concat from "lodash/concat";
import debounce from "lodash/debounce";
import pull from "lodash/pull";
import set from "lodash/set";
import uniq from "lodash/uniq";
import update from "lodash/update";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
// types
import { TIssueAttachment, TIssueAttachmentMap, TIssueAttachmentIdMap } from "@plane/types";
// services
@ -11,7 +14,16 @@ import { IssueAttachmentService } from "@/services/issue";
import { IIssueRootStore } from "../root.store";
import { IIssueDetail } from "./root.store";
export type TAttachmentUploadStatus = {
id: string;
name: string;
progress: number;
size: number;
type: string;
};
export interface IIssueAttachmentStoreActions {
// actions
addAttachments: (issueId: string, attachments: TIssueAttachment[]) => void;
fetchAttachments: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssueAttachment[]>;
createAttachment: (
@ -32,9 +44,11 @@ export interface IIssueAttachmentStore extends IIssueAttachmentStoreActions {
// observables
attachments: TIssueAttachmentIdMap;
attachmentMap: TIssueAttachmentMap;
attachmentsUploadStatusMap: Record<string, Record<string, TAttachmentUploadStatus>>;
// computed
issueAttachments: string[] | undefined;
// helper methods
getAttachmentsUploadStatusByIssueId: (issueId: string) => TAttachmentUploadStatus[] | undefined;
getAttachmentsByIssueId: (issueId: string) => string[] | undefined;
getAttachmentById: (attachmentId: string) => TIssueAttachment | undefined;
}
@ -43,6 +57,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
// observables
attachments: TIssueAttachmentIdMap = {};
attachmentMap: TIssueAttachmentMap = {};
attachmentsUploadStatusMap: Record<string, Record<string, TAttachmentUploadStatus>> = {};
// root store
rootIssueStore: IIssueRootStore;
rootIssueDetailStore: IIssueDetail;
@ -54,6 +69,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
// observables
attachments: observable,
attachmentMap: observable,
attachmentsUploadStatusMap: observable,
// computed
issueAttachments: computed,
// actions
@ -77,6 +93,12 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
}
// helper methods
getAttachmentsUploadStatusByIssueId = computedFn((issueId: string) => {
if (!issueId) return undefined;
const attachmentsUploadStatus = Object.values(this.attachmentsUploadStatusMap[issueId] ?? {});
return attachmentsUploadStatus ?? undefined;
});
getAttachmentsByIssueId = (issueId: string) => {
if (!issueId) return undefined;
return this.attachments[issueId] ?? undefined;
@ -104,21 +126,56 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
return response;
};
createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => {
const response = await this.issueAttachmentService.uploadIssueAttachment(workspaceSlug, projectId, issueId, file);
const issueAttachmentsCount = this.getAttachmentsByIssueId(issueId)?.length ?? 0;
debouncedUpdateProgress = debounce((issueId: string, tempId: string, progress: number) => {
runInAction(() => {
set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], progress);
});
}, 16);
if (response && response.id) {
createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => {
const tempId = uuidv4();
try {
// update attachment upload status
runInAction(() => {
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, [response.id])));
set(this.attachmentMap, response.id, response);
this.rootIssueStore.issues.updateIssue(issueId, {
attachment_count: issueAttachmentsCount + 1, // increment attachment count
set(this.attachmentsUploadStatusMap, [issueId, tempId], {
id: tempId,
name: file.name,
progress: 0,
size: file.size,
type: file.type,
});
});
}
const response = await this.issueAttachmentService.uploadIssueAttachment(
workspaceSlug,
projectId,
issueId,
file,
(progressEvent) => {
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
this.debouncedUpdateProgress(issueId, tempId, progressPercentage);
}
);
const issueAttachmentsCount = this.getAttachmentsByIssueId(issueId)?.length ?? 0;
return response;
if (response && response.id) {
runInAction(() => {
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, [response.id])));
set(this.attachmentMap, response.id, response);
this.rootIssueStore.issues.updateIssue(issueId, {
attachment_count: issueAttachmentsCount + 1, // increment attachment count
});
});
}
return response;
} catch (error) {
console.error("Error in uploading issue attachment:", error);
throw error;
} finally {
runInAction(() => {
delete this.attachmentsUploadStatusMap[issueId][tempId];
});
}
};
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {