[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:
parent
538e78f135
commit
c423d7d9df
17 changed files with 314 additions and 194 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 <></>;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue