Files
AutoGPT/autogpt_platform/frontend/src/components/atoms/FileInput/FileInput.tsx
Abhimanyu Yadav f8afc6044e fix(frontend): prevent file upload buttons from triggering form submission (#11576)
<!-- Clearly explain the need for these changes: -->

In the File Widget, the upload button was incorrectly behaving like a
submit button. When users clicked it, the rjsf library immediately
triggered form validation and displayed validation errors, even though
the user was only trying to upload a file.

This happened because HTML buttons inside a form default to
`type="submit"`, which triggers form submission on click. By explicitly
setting `type="button"` on all file-related buttons, we prevent them
from submitting the form while still allowing them to trigger the file
input dialog.

### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->

- Added `type="button"` attribute to the clear button in the compact
variant
- Added `type="button"` attribute to the upload button in the compact
variant
- Added `type="button"` attribute to the "Browse File" button in the
default variant

This ensures that clicking any of these buttons only triggers the
intended file selection/upload action without causing unwanted form
validation or submission.

### Checklist 📋

#### For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Tested clicking the upload button in a form with File Widget -
form should not submit or show validation errors
- [x] Tested clicking the clear button - should clear the file without
triggering form validation
- [x] Tested clicking the "Browse File" button - should open file dialog
without triggering form validation
- [x] Verified file upload functionality still works correctly after
selecting a file
2025-12-09 15:54:17 +00:00

399 lines
12 KiB
TypeScript

import { FileTextIcon, TrashIcon, UploadIcon } from "@phosphor-icons/react";
import { Cross2Icon } from "@radix-ui/react-icons";
import { useRef, useState } from "react";
import { Button } from "../Button/Button";
import { formatFileSize, getFileLabel } from "./helpers";
import { cn } from "@/lib/utils";
import { Progress } from "../Progress/Progress";
import { Text } from "../Text/Text";
type UploadFileResult = {
file_name: string;
size: number;
content_type: string;
file_uri: string;
};
type FileInputVariant = "default" | "compact";
interface BaseProps {
value?: string;
placeholder?: string;
onChange: (value: string) => void;
className?: string;
maxFileSize?: number;
accept?: string | string[];
variant?: FileInputVariant;
showStorageNote?: boolean;
}
interface UploadModeProps extends BaseProps {
mode?: "upload";
onUploadFile: (file: File) => Promise<UploadFileResult>;
uploadProgress: number;
}
interface Base64ModeProps extends BaseProps {
mode: "base64";
onUploadFile?: never;
uploadProgress?: never;
}
type Props = UploadModeProps | Base64ModeProps;
export function FileInput(props: Props) {
const {
value,
onChange,
className,
maxFileSize,
accept,
placeholder,
variant = "default",
showStorageNote = true,
mode = "upload",
} = props;
const onUploadFile =
mode === "upload" ? (props as UploadModeProps).onUploadFile : undefined;
const uploadProgress =
mode === "upload" ? (props as UploadModeProps).uploadProgress : 0;
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [fileInfo, setFileInfo] = useState<{
name: string;
size: number;
content_type: string;
} | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const storageNote =
"Files are stored securely and will be automatically deleted at most 24 hours after upload.";
function acceptToString(a?: string | string[]) {
if (!a) return "*/*";
return Array.isArray(a) ? a.join(",") : a;
}
function isAcceptedType(file: File, a?: string | string[]) {
if (!a) return true;
const list = Array.isArray(a) ? a : a.split(",").map((s) => s.trim());
const fileType = file.type;
const fileExt = file.name.includes(".")
? `.${file.name.split(".").pop()}`.toLowerCase()
: "";
for (const entry of list) {
if (!entry) continue;
const e = entry.toLowerCase();
if (e.includes("/")) {
const [main, sub] = e.split("/");
const [fMain, fSub] = fileType.toLowerCase().split("/");
if (!fMain || !fSub) continue;
if (sub === "*") {
if (main === fMain) return true;
} else {
if (e === fileType.toLowerCase()) return true;
}
} else if (e.startsWith(".")) {
if (fileExt === e) return true;
}
}
return false;
}
const getFileLabelFromValue = (val: string) => {
if (val.startsWith("data:")) {
const matches = val.match(/^data:([^;]+);/);
if (matches?.[1]) {
const mimeParts = matches[1].split("/");
if (mimeParts.length > 1) {
return `${mimeParts[1].toUpperCase()} file`;
}
return `${matches[1]} file`;
}
} else {
const pathParts = val.split(".");
if (pathParts.length > 1) {
const ext = pathParts.pop();
if (ext) return `${ext.toUpperCase()} file`;
}
}
return "File";
};
const processFileBase64 = (file: File) => {
setIsUploading(true);
setUploadError(null);
const reader = new FileReader();
reader.onload = (e) => {
const base64String = e.target?.result as string;
setFileInfo({
name: file.name,
size: file.size,
content_type: file.type || "application/octet-stream",
});
onChange(base64String);
setIsUploading(false);
};
reader.onerror = () => {
setUploadError("Failed to read file");
setIsUploading(false);
};
reader.readAsDataURL(file);
};
const uploadFile = async (file: File) => {
if (mode === "base64") {
processFileBase64(file);
return;
}
if (!onUploadFile) {
setUploadError("Upload handler not provided");
return;
}
setIsUploading(true);
setUploadError(null);
try {
const result = await onUploadFile(file);
setFileInfo({
name: result.file_name,
size: result.size,
content_type: result.content_type,
});
onChange(result.file_uri);
} catch (error) {
console.error("Upload failed:", error);
setUploadError(error instanceof Error ? error.message : "Upload failed");
} finally {
setIsUploading(false);
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate max size
if (typeof maxFileSize === "number" && file.size > maxFileSize) {
setUploadError(
`File exceeds maximum size of ${formatFileSize(maxFileSize)} (selected ${formatFileSize(file.size)})`,
);
return;
}
// Validate accept types
if (!isAcceptedType(file, accept)) {
setUploadError("Selected file type is not allowed");
return;
}
uploadFile(file);
};
const handleFileDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) uploadFile(file);
};
const handleClear = () => {
if (inputRef.current) {
inputRef.current.value = "";
}
onChange("");
setFileInfo(null);
};
const displayName = placeholder || "File";
if (variant === "compact") {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="nodrag flex flex-col gap-1.5">
{isUploading ? (
<div className="flex flex-col gap-1.5 rounded-md border border-blue-200 bg-blue-50 p-2 dark:border-blue-800 dark:bg-blue-950">
<div className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 animate-pulse text-blue-600 dark:text-blue-400" />
<Text
variant="small"
className="text-blue-700 dark:text-blue-300"
>
{mode === "base64" ? "Processing..." : "Uploading..."}
</Text>
{mode === "upload" && (
<Text
variant="small-medium"
className="ml-auto text-blue-600 dark:text-blue-400"
>
{Math.round(uploadProgress)}%
</Text>
)}
</div>
{mode === "upload" && (
<Progress value={uploadProgress} className="h-1 w-full" />
)}
</div>
) : value ? (
<div className="flex items-center gap-2">
<div className="flex flex-1 items-center gap-2 rounded-xlarge border border-gray-300 bg-gray-50 p-2 dark:border-gray-600 dark:bg-gray-800">
<FileTextIcon className="h-4 w-4 flex-shrink-0 text-gray-600 dark:text-gray-400" />
<Text
variant="small-medium"
className="truncate text-gray-900 dark:text-gray-100"
>
{fileInfo
? getFileLabel(fileInfo.name, fileInfo.content_type)
: getFileLabelFromValue(value)}
</Text>
{fileInfo && (
<Text
variant="small"
className="text-gray-500 dark:text-gray-400"
>
{formatFileSize(fileInfo.size)}
</Text>
)}
</div>
<Button
variant="outline"
size="small"
className="h-7 w-7 min-w-0 flex-shrink-0 border-zinc-300 p-0 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-500"
onClick={handleClear}
type="button"
>
<Cross2Icon className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="small"
onClick={() => inputRef.current?.click()}
className="flex-1 border-zinc-300 text-xs"
disabled={isUploading}
type="button"
>
<UploadIcon className="mr-1.5 h-3.5 w-3.5" />
{`Upload ${displayName}`}
</Button>
</div>
)}
<input
ref={inputRef}
type="file"
accept={acceptToString(accept)}
onChange={handleFileChange}
className="hidden"
disabled={isUploading}
/>
</div>
{uploadError && (
<Text variant="small" className="text-red-600 dark:text-red-400">
{uploadError}
</Text>
)}
</div>
);
}
return (
<div className={cn("w-full", className)}>
{isUploading ? (
<div className="space-y-2">
<div className="flex min-h-14 items-center gap-4">
<div className="agpt-border-input flex min-h-14 w-full flex-col justify-center rounded-xl bg-zinc-50 p-4 text-sm">
<div className="mb-2 flex items-center gap-2">
<UploadIcon className="h-5 w-5 text-blue-600" />
<span className="text-gray-700">
{mode === "base64" ? "Processing..." : "Uploading..."}
</span>
{mode === "upload" && (
<span className="text-gray-500">
{Math.round(uploadProgress)}%
</span>
)}
</div>
{mode === "upload" && (
<Progress value={uploadProgress} className="w-full" />
)}
</div>
</div>
{showStorageNote && mode === "upload" && (
<p className="text-xs text-gray-500">{storageNote}</p>
)}
</div>
) : value ? (
<div className="space-y-2">
<div className="flex min-h-14 items-center gap-4">
<div className="agpt-border-input flex min-h-14 w-full items-center justify-between rounded-xl bg-zinc-50 p-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<FileTextIcon className="h-7 w-7 text-black" />
<div className="flex flex-col gap-0.5">
<span className="font-normal text-black">
{fileInfo
? getFileLabel(fileInfo.name, fileInfo.content_type)
: getFileLabelFromValue(value)}
</span>
<span>{fileInfo ? formatFileSize(fileInfo.size) : ""}</span>
</div>
</div>
<TrashIcon
className="h-5 w-5 cursor-pointer text-black"
onClick={handleClear}
/>
</div>
</div>
{showStorageNote && mode === "upload" && (
<p className="text-xs text-gray-500">{storageNote}</p>
)}
</div>
) : (
<div className="space-y-2">
<div className="flex min-h-14 items-center gap-4">
<div
onDrop={handleFileDrop}
onDragOver={(e) => e.preventDefault()}
className="agpt-border-input flex min-h-14 w-full items-center justify-center rounded-xl border-dashed bg-zinc-50 text-sm text-gray-500"
>
Choose a file or drag and drop it here
</div>
<Button
onClick={() => inputRef.current?.click()}
className="min-w-40"
type="button"
>
Browse File
</Button>
</div>
{uploadError && (
<div className="text-sm text-red-600">Error: {uploadError}</div>
)}
{showStorageNote && mode === "upload" && (
<p className="text-xs text-gray-500">{storageNote}</p>
)}
</div>
)}
<input
ref={inputRef}
type="file"
accept={acceptToString(accept)}
className="hidden"
onChange={handleFileChange}
disabled={isUploading}
/>
</div>
);
}