From d99c96407edae3f06a50cf01f805e3ff9285f3f7 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 23 Oct 2024 17:26:14 +0530 Subject: [PATCH 1/4] chore: added attachment upload progress --- .../issues/attachment/attachment-detail.tsx | 10 +- .../attachment/attachment-item-list.tsx | 85 ++++++++-------- .../attachment-list-upload-item.tsx | 45 +++++++++ .../attachment/attachment-upload-details.tsx | 54 ++++++++++ .../issues/attachment/attachment-upload.tsx | 12 +-- .../issues/attachment/attachments-list.tsx | 20 ++-- .../attachment/delete-attachment-modal.tsx | 10 +- .../components/issues/attachment/index.ts | 2 + .../components/issues/attachment/root.tsx | 99 ++----------------- .../attachments/content.tsx | 9 +- .../attachments/helper.tsx | 42 +++++--- .../attachments/quick-action-button.tsx | 6 +- .../issue-detail-widget-collapsibles.tsx | 5 +- web/core/services/api.service.ts | 12 +-- web/core/services/file-upload.service.ts | 9 +- .../issue/issue_attachment.service.ts | 11 ++- .../issue/issue-details/attachment.store.ts | 72 ++++++++++++-- 17 files changed, 309 insertions(+), 194 deletions(-) create mode 100644 web/core/components/issues/attachment/attachment-list-upload-item.tsx create mode 100644 web/core/components/issues/attachment/attachment-upload-details.tsx diff --git a/web/core/components/issues/attachment/attachment-detail.tsx b/web/core/components/issues/attachment/attachment-detail.tsx index 04d5641af76..315d579f5f2 100644 --- a/web/core/components/issues/attachment/attachment-detail.tsx +++ b/web/core/components/issues/attachment/attachment-detail.tsx @@ -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; +type TAttachmentOperationsRemoveModal = Exclude; type TIssueAttachmentsDetail = { attachmentId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentHelpers: TAttachmentOperationsRemoveModal; disabled?: boolean; }; export const IssueAttachmentsDetail: FC = 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 = observer((pro setIsDeleteIssueAttachmentModalOpen(false)} - handleAttachmentOperations={handleAttachmentOperations} + attachmentOperations={attachmentHelpers.operations} attachmentId={attachmentId} /> )} diff --git a/web/core/components/issues/attachment/attachment-item-list.tsx b/web/core/components/issues/attachment/attachment-item-list.tsx index f1af2884cdd..aad4a04546d 100644 --- a/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/web/core/components/issues/attachment/attachment-item-list.tsx @@ -7,31 +7,34 @@ 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; type TIssueAttachmentItemList = { workspaceSlug: string; issueId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentHelpers: TAttachmentHelpers; disabled?: boolean; }; export const IssueAttachmentItemList: FC = observer((props) => { - const { workspaceSlug, issueId, handleAttachmentOperations, disabled } = props; + const { workspaceSlug, issueId, attachmentHelpers, disabled } = props; // states - const [isLoading, setIsLoading] = useState(false); + const [isUploading, setIsUploading] = useState(false); // store hooks const { attachment: { getAttachmentsByIssueId }, attachmentDeleteModalId, toggleDeleteAttachmentModal, } = useIssueDetail(); + const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers; + const { create: createAttachment } = attachmentOperations; + const { uploadStatus } = attachmentSnapshot; // file size const { maxFileSize } = useFileSize(); // derived values @@ -45,9 +48,8 @@ export const IssueAttachmentItemList: FC = 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, @@ -55,7 +57,7 @@ export const IssueAttachmentItemList: FC = observer((p message: "File could not be attached. Try uploading again.", }); }) - .finally(() => setIsLoading(false)); + .finally(() => setIsUploading(false)); return; } @@ -69,47 +71,52 @@ export const IssueAttachmentItemList: FC = observer((p }); return; }, - [handleAttachmentOperations, maxFileSize, workspaceSlug] + [createAttachment, maxFileSize, workspaceSlug] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, maxSize: maxFileSize, multiple: false, - disabled: isLoading || disabled, + disabled: isUploading || disabled, }); - if (!issueAttachments) return <>; - return ( <> - {attachmentDeleteModalId && ( - toggleDeleteAttachmentModal(null)} - handleAttachmentOperations={handleAttachmentOperations} - attachmentId={attachmentDeleteModalId} - /> - )} -
- - {isDragActive && ( -
-
-
- - Drag and drop anywhere to upload + {uploadStatus?.map((uploadStatus) => ( + + ))} + {issueAttachments && ( + <> + {attachmentDeleteModalId && ( + toggleDeleteAttachmentModal(null)} + attachmentOperations={attachmentOperations} + attachmentId={attachmentDeleteModalId} + /> + )} +
+ + {isDragActive && ( +
+
+
+ + Drag and drop anywhere to upload +
+
-
+ )} + {issueAttachments?.map((attachmentId) => ( + + ))}
- )} - {issueAttachments?.map((attachmentId) => ( - - ))} -
+ + )} ); }); diff --git a/web/core/components/issues/attachment/attachment-list-upload-item.tsx b/web/core/components/issues/attachment/attachment-list-upload-item.tsx new file mode 100644 index 00000000000..8ea9d3470e2 --- /dev/null +++ b/web/core/components/issues/attachment/attachment-list-upload-item.tsx @@ -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 = 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 ( +
+
+
{fileIcon}
+ +

{fileName}

+
+
+
+ + + +
{uploadStatus.progress}% done
+
+
+ ); +}); diff --git a/web/core/components/issues/attachment/attachment-upload-details.tsx b/web/core/components/issues/attachment/attachment-upload-details.tsx new file mode 100644 index 00000000000..6d4eca96e8b --- /dev/null +++ b/web/core/components/issues/attachment/attachment-upload-details.tsx @@ -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 = 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 ( +
+
+
{fileIcon}
+
+
+ + {truncateText(`${fileName}`, 10)} + +
+ +
+ {fileExtension.toUpperCase()} +
+
+
+
+ + + +
{uploadStatus.progress}% done
+
+
+ ); +}); diff --git a/web/core/components/issues/attachment/attachment-upload.tsx b/web/core/components/issues/attachment/attachment-upload.tsx index a2f52690094..2de55ac0079 100644 --- a/web/core/components/issues/attachment/attachment-upload.tsx +++ b/web/core/components/issues/attachment/attachment-upload.tsx @@ -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; +type TAttachmentOperationsModal = Pick; type Props = { workspaceSlug: string; disabled?: boolean; - handleAttachmentOperations: TAttachmentOperationsModal; + attachmentOperations: TAttachmentOperationsModal; }; export const IssueAttachmentUpload: React.FC = 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 = 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({ diff --git a/web/core/components/issues/attachment/attachments-list.tsx b/web/core/components/issues/attachment/attachments-list.tsx index bff10047ef4..a25ee258931 100644 --- a/web/core/components/issues/attachment/attachments-list.tsx +++ b/web/core/components/issues/attachment/attachments-list.tsx @@ -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; +import { IssueAttachmentsUploadDetails } from "./attachment-upload-details"; type TIssueAttachmentsList = { issueId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentHelpers: TAttachmentHelpers; disabled?: boolean; }; export const IssueAttachmentsList: FC = 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) => ( + + ))} {issueAttachments?.map((attachmentId) => ( ))} diff --git a/web/core/components/issues/attachment/delete-attachment-modal.tsx b/web/core/components/issues/attachment/delete-attachment-modal.tsx index 4c7984da2e8..925ff21c0c9 100644 --- a/web/core/components/issues/attachment/delete-attachment-modal.tsx +++ b/web/core/components/issues/attachment/delete-attachment-modal.tsx @@ -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; +export type TAttachmentOperationsRemoveModal = Pick; type Props = { isOpen: boolean; onClose: () => void; attachmentId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentOperations: TAttachmentOperationsRemoveModal; }; export const IssueAttachmentDeleteModal: FC = 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 = observer((props) => { const handleDeletion = async (assetId: string) => { setLoader(true); - handleAttachmentOperations.remove(assetId).finally(() => handleClose()); + attachmentOperations.remove(assetId).finally(() => handleClose()); }; if (!attachment) return <>; diff --git a/web/core/components/issues/attachment/index.ts b/web/core/components/issues/attachment/index.ts index 0f1c8a33249..af20960a86a 100644 --- a/web/core/components/issues/attachment/index.ts +++ b/web/core/components/issues/attachment/index.ts @@ -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"; diff --git a/web/core/components/issues/attachment/root.tsx b/web/core/components/issues/attachment/root.tsx index e7874cc6434..700b35bb8d6 100644 --- a/web/core/components/issues/attachment/root.tsx +++ b/web/core/components/issues/attachment/root.tsx @@ -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; - remove: (linkId: string) => Promise; -}; - -export const IssueAttachmentRoot: FC = (props) => { +export const IssueAttachmentRoot: FC = 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 (
@@ -107,14 +28,10 @@ export const IssueAttachmentRoot: FC = (props) => { - +
); -}; +}); diff --git a/web/core/components/issues/issue-detail-widgets/attachments/content.tsx b/web/core/components/issues/issue-detail-widgets/attachments/content.tsx index f792af28446..da7a3f4c741 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/content.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/content.tsx @@ -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,16 +13,16 @@ type Props = { disabled: boolean; }; -export const IssueAttachmentsCollapsibleContent: FC = (props) => { +export const IssueAttachmentsCollapsibleContent: FC = observer((props) => { const { workspaceSlug, projectId, issueId, disabled } = props; // helper - const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId); + const attachmentHelpers = 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 index b452dc3ad61..b00f0cb66e8 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -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; + remove: (linkId: string) => Promise; +}; + +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 }, + }; }; 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 index 105d7bd1336..616db64e2ef 100644 --- 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 @@ -30,7 +30,7 @@ export const IssueAttachmentActionButton: FC = observer((props) => { // file size const { maxFileSize } = useFileSize(); // operations - const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId); + const { operations: attachmentOperations } = useAttachmentOperations(workspaceSlug, projectId, issueId); // handlers const onDrop = useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { @@ -41,7 +41,7 @@ export const IssueAttachmentActionButton: FC = observer((props) => { if (!currentFile || !workspaceSlug) return; setIsLoading(true); - handleAttachmentOperations + attachmentOperations .create(currentFile) .catch(() => { setToast({ @@ -67,7 +67,7 @@ export const IssueAttachmentActionButton: FC = observer((props) => { }); return; }, - [handleAttachmentOperations, maxFileSize, workspaceSlug] + [attachmentOperations, maxFileSize, workspaceSlug] ); const { getRootProps, getInputProps } = useDropzone({ 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 index 58018c13b2d..854a146beb4 100644 --- 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 @@ -25,6 +25,7 @@ export const IssueDetailWidgetCollapsibles: FC = observer((props) => { issue: { getIssueById }, subIssues: { subIssuesByIssueId }, relation: { getRelationsByIssueId }, + attachment: { getAttachmentsUploadStatusByIssueId }, } = useIssueDetail(); // derived values @@ -36,7 +37,9 @@ export const IssueDetailWidgetCollapsibles: FC = observer((props) => { 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; + const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId); + const shouldRenderAttachments = + (!!issue?.attachment_count && issue?.attachment_count > 0) || (!!attachmentUploads && attachmentUploads.length > 0); return (
diff --git a/web/core/services/api.service.ts b/web/core/services/api.service.ts index 30c646f595c..944990a05c2 100644 --- a/web/core/services/api.service.ts +++ b/web/core/services/api.service.ts @@ -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 }); } diff --git a/web/core/services/file-upload.service.ts b/web/core/services/file-upload.service.ts index 09e95f3c0fa..c6421872a12 100644 --- a/web/core/services/file-upload.service.ts +++ b/web/core/services/file-upload.service.ts @@ -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 { + async uploadFile( + url: string, + data: FormData, + uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] + ): Promise { 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) => { diff --git a/web/core/services/issue/issue_attachment.service.ts b/web/core/services/issue/issue_attachment.service.ts index 776357e48ce..b550217cf27 100644 --- a/web/core/services/issue/issue_attachment.service.ts +++ b/web/core/services/issue/issue_attachment.service.ts @@ -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 { 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; }) diff --git a/web/core/store/issue/issue-details/attachment.store.ts b/web/core/store/issue/issue-details/attachment.store.ts index a3591e596f3..eabdc0b2533 100644 --- a/web/core/store/issue/issue-details/attachment.store.ts +++ b/web/core/store/issue/issue-details/attachment.store.ts @@ -4,6 +4,8 @@ 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 +13,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; createAttachment: ( @@ -32,9 +43,11 @@ export interface IIssueAttachmentStore extends IIssueAttachmentStoreActions { // observables attachments: TIssueAttachmentIdMap; attachmentMap: TIssueAttachmentMap; + attachmentsUploadStatusMap: Record>; // computed issueAttachments: string[] | undefined; // helper methods + getAttachmentsUploadStatusByIssueId: (issueId: string) => TAttachmentUploadStatus[] | undefined; getAttachmentsByIssueId: (issueId: string) => string[] | undefined; getAttachmentById: (attachmentId: string) => TIssueAttachment | undefined; } @@ -43,6 +56,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { // observables attachments: TIssueAttachmentIdMap = {}; attachmentMap: TIssueAttachmentMap = {}; + attachmentsUploadStatusMap: Record> = {}; // root store rootIssueStore: IIssueRootStore; rootIssueDetailStore: IIssueDetail; @@ -54,6 +68,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { // observables attachments: observable, attachmentMap: observable, + attachmentsUploadStatusMap: observable, // computed issueAttachments: computed, // actions @@ -77,6 +92,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; @@ -105,20 +126,51 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { }; 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; - - if (response && response.id) { + 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) => { + runInAction(() => { + const progressPercentage = Number(((progressEvent.progress ?? 0) * 100).toFixed(0)); + set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], 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) => { From 65af4de9b08b44b3a9c32f127dc48452c65da107 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 28 Oct 2024 13:49:16 +0530 Subject: [PATCH 2/4] chore: add debounce while updating the upload status --- .../store/issue/issue-details/attachment.store.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/core/store/issue/issue-details/attachment.store.ts b/web/core/store/issue/issue-details/attachment.store.ts index eabdc0b2533..6da8f785078 100644 --- a/web/core/store/issue/issue-details/attachment.store.ts +++ b/web/core/store/issue/issue-details/attachment.store.ts @@ -1,4 +1,5 @@ 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"; @@ -125,6 +126,12 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { return response; }; + debouncedUpdateProgress = debounce((issueId: string, tempId: string, progress: number) => { + runInAction(() => { + set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], progress); + }); + }, 100); + createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => { const tempId = uuidv4(); try { @@ -144,10 +151,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { issueId, file, (progressEvent) => { - runInAction(() => { - const progressPercentage = Number(((progressEvent.progress ?? 0) * 100).toFixed(0)); - set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], progressPercentage); - }); + const progressPercentage = Number(((progressEvent.progress ?? 0) * 100).toFixed(0)); + this.debouncedUpdateProgress(issueId, tempId, progressPercentage); } ); const issueAttachmentsCount = this.getAttachmentsByIssueId(issueId)?.length ?? 0; From 49693d052430511b1eabec52192395271c7246b4 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 28 Oct 2024 13:56:44 +0530 Subject: [PATCH 3/4] chore: update percentage calc logic --- web/core/store/issue/issue-details/attachment.store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/store/issue/issue-details/attachment.store.ts b/web/core/store/issue/issue-details/attachment.store.ts index 6da8f785078..78519d9a026 100644 --- a/web/core/store/issue/issue-details/attachment.store.ts +++ b/web/core/store/issue/issue-details/attachment.store.ts @@ -151,7 +151,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { issueId, file, (progressEvent) => { - const progressPercentage = Number(((progressEvent.progress ?? 0) * 100).toFixed(0)); + const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100); this.debouncedUpdateProgress(issueId, tempId, progressPercentage); } ); From 5f31921c6fd833511361d8073a9d74f077c6df17 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 28 Oct 2024 16:24:58 +0530 Subject: [PATCH 4/4] chore: update debounce interval --- web/core/store/issue/issue-details/attachment.store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/store/issue/issue-details/attachment.store.ts b/web/core/store/issue/issue-details/attachment.store.ts index 78519d9a026..ef2a356fcf1 100644 --- a/web/core/store/issue/issue-details/attachment.store.ts +++ b/web/core/store/issue/issue-details/attachment.store.ts @@ -130,7 +130,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { runInAction(() => { set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], progress); }); - }, 100); + }, 16); createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => { const tempId = uuidv4();