Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@
* See the LICENSE file for details.
*/

import { useCallback, useState } from "react";
import { useCallback } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
Expand All @@ -19,6 +17,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useFileSize } from "@/hooks/use-file-size";
// types
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
import { useAttachmentDropHandler } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentsListItem } from "./attachment-list-item";
import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
Expand All @@ -44,8 +43,6 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
issueServiceType = EIssueServiceType.ISSUES,
} = props;
const { t } = useTranslation();
// states
const [isUploading, setIsUploading] = useState(false);
// store hooks
const {
attachment: { getAttachmentsByIssueId },
Expand All @@ -62,58 +59,27 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
const issueAttachments = getAttachmentsByIssueId(issueId);

// handlers
const handleFetchPropertyActivities = useCallback(() => {
const handleUploadSettled = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId);
}, [fetchActivities, workspaceSlug, projectId, issueId]);

const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;

if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;

setIsUploading(true);
createAttachment(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("attachment.error"),
});
})
.finally(() => {
handleFetchPropertyActivities();
setIsUploading(false);
});
return;
}

setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message:
totalAttachedFiles > 1
? t("attachment.only_one_file_allowed")
: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
});
return;
},
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
);
const { onDrop, isUploading } = useAttachmentDropHandler({
create: createAttachment,
maxFileSize,
onUploadSettled: handleUploadSettled,
});

const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isUploading || disabled,
multiple: true,
disabled: isUploading || disabled || !workspaceSlug,
});

return (
<>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
{uploadStatus?.map((status) => (
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
))}
{issueAttachments && (
<>
Expand Down
41 changes: 16 additions & 25 deletions apps/web/core/components/issues/attachment/attachment-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
* See the LICENSE file for details.
*/

import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
// plane web hooks
import { useFileSize } from "@/hooks/use-file-size";
// types
import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
import { useAttachmentDropHandler } from "../issue-detail-widgets/attachments/helper";

type TAttachmentOperationsModal = Pick<TAttachmentOperations, "create">;

Expand All @@ -22,32 +22,21 @@ type Props = {

export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(props: Props) {
const { workspaceSlug, disabled = false, attachmentOperations } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// file size
const { maxFileSize } = useFileSize();
// drop handler
const { onDrop, progress, isUploading } = useAttachmentDropHandler({
create: attachmentOperations.create,
maxFileSize,
});

const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;

setIsLoading(true);
attachmentOperations.create(currentFile).finally(() => setIsLoading(false));
},
[attachmentOperations, workspaceSlug]
);

const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
multiple: true,
disabled: isUploading || disabled || !workspaceSlug,
});

const fileError =
fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null;

return (
<div
{...getRootProps()}
Expand All @@ -59,12 +48,14 @@ export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(pro
<span className="flex items-center gap-2">
{isDragActive ? (
<p>Drop here...</p>
) : fileError ? (
<p className="text-center text-danger-primary">{fileError}</p>
) : isLoading ? (
<p className="text-center">Uploading...</p>
) : progress ? (
<p className="text-center">
{/* Files finished, not the file being worked on: uploads run concurrently, so
there is no single "current" file to point at. */}
{progress.total > 1 ? `Uploading ${progress.completed}/${progress.total}...` : "Uploading..."}
</p>
) : (
<p className="text-center">Click or drag a file here</p>
<p className="text-center">Click or drag files here</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,35 @@
* See the LICENSE file for details.
*/

import { useMemo } from "react";
import { setPromiseToast, TOAST_TYPE, setToast } from "@plane/propel/toast";
import { useCallback, useMemo, useState } from "react";
import type { FileRejection } from "react-dropzone";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";

/**
* Number of attachments uploaded in parallel. Dropping a folder of screenshots should not
* open one request per file, but a strict sequence makes a large batch needlessly slow.
*/
const UPLOAD_CONCURRENCY = 3;

type TAttachmentUploadSummary = {
uploadedCount: number;
failedFileNames: string[];
};

export type TAttachmentUploadProgress = {
completed: number;
total: number;
};

export type TAttachmentOperations = {
create: (file: File) => Promise<void>;
create: (files: File[], onProgress?: (progress: TAttachmentUploadProgress) => void) => Promise<void>;
remove: (attachmentId: string) => Promise<void>;
};

Expand All @@ -27,34 +45,90 @@ export type TAttachmentHelpers = {
snapshot: TAttachmentSnapshot;
};

/**
* Run `task` over every file, keeping at most `limit` uploads in flight. A rejected upload
* is recorded and never aborts the batch, so one bad file cannot discard the rest.
*/
const uploadWithConcurrency = async (
files: File[],
limit: number,
task: (file: File) => Promise<unknown>,
onSettled: (file: File, isSuccess: boolean) => void
): Promise<void> => {
let cursor = 0;
const worker = async () => {
while (cursor < files.length) {
const file = files[cursor];
cursor += 1;
if (!file) continue;
try {
// Sequential by design: each worker drains the queue one file at a time so that
// `limit` bounds the in-flight uploads. Promise.all here would be unbounded.
// eslint-disable-next-line no-await-in-loop
await task(file);
onSettled(file, true);
} catch {
// The store logs the underlying error; the caller turns this into a user-facing message.
onSettled(file, false);
}
}
};
await Promise.all(Array.from({ length: Math.min(limit, files.length) }, worker));
};

export const useAttachmentOperations = (
workspaceSlug: string,
projectId: string,
issueId: string,
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
): TAttachmentHelpers => {
const { t } = useTranslation();
const {
attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId },
} = useIssueDetail(issueServiceType);

const attachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (file) => {
create: async (files, onProgress) => {
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",
},
});
if (files.length === 0) return;

const summary: TAttachmentUploadSummary = { uploadedCount: 0, failedFileNames: [] };
let completed = 0;

await uploadWithConcurrency(
files,
UPLOAD_CONCURRENCY,
(file) => createAttachment(workspaceSlug, projectId, issueId, file),
(file, isSuccess) => {
if (isSuccess) summary.uploadedCount += 1;
else summary.failedFileNames.push(file.name);
completed += 1;
onProgress?.({ completed, total: files.length });
}
);

// A partially successful batch keeps the uploaded files and names only the ones that failed.
if (summary.failedFileNames.length > 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("attachment.upload_failed_title", { count: summary.failedFileNames.length }),
message:
summary.uploadedCount > 0
? t("attachment.upload_partial_failure", {
count: summary.uploadedCount,
files: summary.failedFileNames.join(", "),
})
: t("attachment.upload_failure", { files: summary.failedFileNames.join(", ") }),
});
return;
}

await attachmentUploadPromise;
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("attachment.upload_success_title", { count: summary.uploadedCount }),
message: t("attachment.upload_success", { count: summary.uploadedCount }),
});
},
remove: async (attachmentId) => {
try {
Expand All @@ -74,7 +148,7 @@ export const useAttachmentOperations = (
}
},
}),
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment, t]
);
const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId);

Expand All @@ -83,3 +157,63 @@ export const useAttachmentOperations = (
snapshot: { uploadStatus: attachmentsUploadStatus },
};
};

type TAttachmentDropHandlerArgs = {
create: TAttachmentOperations["create"];
maxFileSize: number;
/** Runs once per drop, after the whole batch settles. */
onUploadSettled?: () => void;
};

/**
* Shared `onDrop` for every work item attachment dropzone. Files rejected by the dropzone
* itself (over the size limit) are reported by name and the remaining ones still upload.
*/
export const useAttachmentDropHandler = (args: TAttachmentDropHandlerArgs) => {
const { create, maxFileSize, onUploadSettled } = args;
const { t } = useTranslation();
// states
const [progress, setProgress] = useState<TAttachmentUploadProgress | null>(null);

const onDrop = useCallback(
async (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (rejectedFiles.length > 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("attachment.files_too_large", {
count: rejectedFiles.length,
size: maxFileSize / 1024 / 1024,
files: rejectedFiles.map((rejection) => rejection.file.name).join(", "),
}),
});
}

if (acceptedFiles.length === 0) return;

setProgress({ completed: 0, total: acceptedFiles.length });
try {
await create(acceptedFiles, setProgress);
} catch (error) {
// Per-file failures are already reported by `create`; this only catches a batch that
// never started, such as a missing workspace or project id.
console.error("Error in uploading issue attachments:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("attachment.error"),
});
} finally {
setProgress(null);
onUploadSettled?.();
}
},
[create, maxFileSize, onUploadSettled, t]
);

return {
onDrop,
progress,
isUploading: progress !== null,
};
};
Loading