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 b24cff30bbd..d377bd90a16 100644 --- a/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/web/core/components/issues/attachment/attachment-item-list.tsx @@ -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; type TIssueAttachmentItemList = { workspaceSlug: string; projectId: string; issueId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentHelpers: TAttachmentHelpers; disabled?: boolean; }; export const IssueAttachmentItemList: FC = 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 = 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 = 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 = observer((p }) .finally(() => { handleFetchPropertyActivities(); - setIsLoading(false); + setIsUploading(false); }); return; } @@ -79,47 +81,52 @@ export const IssueAttachmentItemList: FC = 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 && ( - 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 5df62819dcc..9c5b2a49157 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,17 +13,17 @@ 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..43b4812e6a0 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: (attachmentId: 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 67ab86fe2b9..c2d88a9541c 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 handleFetchPropertyActivities = useCallback(() => { fetchActivities(workspaceSlug, projectId, issueId); @@ -45,7 +45,7 @@ export const IssueAttachmentActionButton: FC = observer((props) => { if (!currentFile || !workspaceSlug) return; setIsLoading(true); - handleAttachmentOperations + attachmentOperations .create(currentFile) .catch(() => { setToast({ @@ -72,7 +72,7 @@ export const IssueAttachmentActionButton: FC = observer((props) => { }); return; }, - [maxFileSize, workspaceSlug, handleAttachmentOperations, handleFetchPropertyActivities, setLastWidgetAction] + [attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction] ); 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 22b1827da74..124c1738090 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 @@ -24,6 +24,7 @@ export const IssueDetailWidgetCollapsibles: FC = observer((props) => { const { issue: { getIssueById }, subIssues: { subIssuesByIssueId }, + attachment: { getAttachmentsUploadStatusByIssueId }, relation: { getRelationCountByIssueId }, } = useIssueDetail(); @@ -36,7 +37,9 @@ export const IssueDetailWidgetCollapsibles: FC = 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 (
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..ef2a356fcf1 100644 --- a/web/core/store/issue/issue-details/attachment.store.ts +++ b/web/core/store/issue/issue-details/attachment.store.ts @@ -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; createAttachment: ( @@ -32,9 +44,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 +57,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { // observables attachments: TIssueAttachmentIdMap = {}; attachmentMap: TIssueAttachmentMap = {}; + attachmentsUploadStatusMap: Record> = {}; // 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) => {