feat(frontend): add file input widget with variants and base64 support (#11533)

<!-- Clearly explain the need for these changes: -->

This PR enhances the FileInput component to support multiple variants
and modes, and integrates it into the form renderer as a file widget.
The changes enable a more flexible file input experience with both
server upload and local base64 conversion capabilities.

### Compact one
<img width="354" height="91" alt="Screenshot 2025-12-03 at 8 05 51 PM"
src="https://github.com/user-attachments/assets/1295a34f-4d9f-4a65-89f2-4b6516f176ff"
/>
<img width="386" height="96" alt="Screenshot 2025-12-03 at 8 06 11 PM"
src="https://github.com/user-attachments/assets/3c10e350-8ddc-43ff-bf0a-68b23f8db394"
/>

## Default one
<img width="671" height="165" alt="Screenshot 2025-12-03 at 8 05 08 PM"
src="https://github.com/user-attachments/assets/bd17c5f1-8cdb-4818-9850-a9e61252d8c1"
/>
<img width="656" height="141" alt="Screenshot 2025-12-03 at 8 05 21 PM"
src="https://github.com/user-attachments/assets/1edf260f-6245-44b9-bd3e-dd962f497413"
/>

### Changes 🏗️

#### FileInput Component Enhancements
- **Added variant support**: Introduced `default` and `compact` variants
- `default`: Full-featured UI with drag & drop, progress bar, and
storage note
- `compact`: Minimal inline design suitable for tight spaces like node
inputs
- **Added mode support**: Introduced `upload` and `base64` modes
- `upload`: Uploads files to server (requires `onUploadFile` and
`uploadProgress`)
  - `base64`: Converts files to base64 locally without server upload
- **Improved type safety**: Refactored props using discriminated unions
(`UploadModeProps | Base64ModeProps`) to ensure type-safe usage
- **Enhanced file handling**: Added `getFileLabelFromValue` helper to
extract file labels from base64 data URIs or file paths
- **Better UX**: Added `showStorageNote` prop to control visibility of
storage disclaimer

#### FileWidget Integration
- **Replaced legacy Input**: Migrated from legacy `Input` component to
new `FileInput` component
- **Smart variant selection**: Automatically selects `default` or
`compact` variant based on form context size
- **Base64 mode**: Uses base64 mode for form inputs, eliminating need
for server uploads in builder context
- **Improved accessibility**: Better disabled/readonly state handling
with visual feedback

#### Form Renderer Updates
- **Disabled validation**: Added `noValidate={true}` and
`liveValidate={false}` to prevent premature form validation

#### Storybook Updates
- **Expanded stories**: Added comprehensive stories for all variant/mode
combinations:
  - `Default`: Default variant with upload mode
  - `Compact`: Compact variant with base64 mode
  - `CompactWithUpload`: Compact variant with upload mode
  - `DefaultWithBase64`: Default variant with base64 mode
- **Improved documentation**: Updated component descriptions to clearly
explain variants and modes

### 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] Test FileInput component in Storybook with all variant/mode
combinations
  - [x] Test file upload flow in default variant with upload mode
  - [x] Test base64 conversion in compact variant with base64 mode
  - [x] Test file widget in form renderer (node inputs)
  - [x] Test file type validation (accept prop)
  - [x] Test file size validation (maxFileSize prop)
  - [x] Test error handling for invalid files
  - [x] Test disabled and readonly states
  - [x] Test file clearing/removal functionality
  - [x] Verify compact variant renders correctly in tight spaces
  - [x] Verify default variant shows storage note only in upload mode
  - [x] Test drag & drop functionality in default variant
This commit is contained in:
Abhimanyu Yadav
2025-12-04 20:43:01 +05:30
committed by GitHub
parent 729400dbe1
commit 4e87f668e3
4 changed files with 371 additions and 108 deletions

View File

@@ -1,8 +1,8 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import type { Meta } from "@storybook/nextjs";
import { useState } from "react";
import { FileInput } from "./FileInput";
const meta: Meta<typeof FileInput> = {
const meta: Meta = {
title: "Atoms/FileInput",
component: FileInput,
tags: ["autodocs"],
@@ -11,26 +11,13 @@ const meta: Meta<typeof FileInput> = {
docs: {
description: {
component:
"File upload input with progress and removable preview.\n\nProps:\n- accept: optional MIME/extensions filter (e.g. ['image/*', '.pdf']).\n- maxFileSize: optional maximum size in bytes; larger files are rejected with an inline error.",
"File upload input with two variants and two modes.\n\n**Variants:**\n- `default`: Full-featured with drag & drop, progress bar, and storage note.\n- `compact`: Minimal inline design for tight spaces like node inputs.\n\n**Modes:**\n- `upload`: Uploads file to server (requires `onUploadFile` and `uploadProgress`).\n- `base64`: Converts file to base64 locally (no server upload).\n\n**Props:**\n- `accept`: optional MIME/extensions filter (e.g. ['image/*', '.pdf']).\n- `maxFileSize`: optional maximum size in bytes; larger files are rejected with an inline error.",
},
},
},
argTypes: {
onUploadFile: { action: "upload" },
accept: {
control: "object",
description:
"Optional accept filter. Supports MIME types (image/*) and extensions (.pdf).",
},
maxFileSize: {
control: "number",
description: "Optional maximum file size in bytes.",
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
function mockUpload(file: File): Promise<{
file_name: string;
@@ -52,16 +39,16 @@ function mockUpload(file: File): Promise<{
);
}
export const Basic: Story = {
export const Default = {
parameters: {
docs: {
description: {
story:
"This example accepts images or PDFs only and limits size to 5MB. Oversized or disallowed file types show an inline error and do not upload.",
"Default variant with upload mode. Full-featured with drag & drop dropzone, progress bar, and storage note. Accepts images or PDFs only and limits size to 5MB.",
},
},
},
render: function BasicStory() {
render: function DefaultStory() {
const [value, setValue] = useState<string>("");
const [progress, setProgress] = useState<number>(0);
@@ -79,6 +66,8 @@ export const Basic: Story = {
return (
<div className="w-[560px]">
<FileInput
variant="default"
mode="upload"
value={value}
onChange={setValue}
onUploadFile={onUploadFile}
@@ -90,3 +79,99 @@ export const Basic: Story = {
);
},
};
export const Compact = {
parameters: {
docs: {
description: {
story:
"Compact variant with base64 mode. Minimal inline design suitable for node inputs. Converts file to base64 locally without server upload.",
},
},
},
render: function CompactStory() {
const [value, setValue] = useState<string>("");
return (
<div className="w-[300px]">
<FileInput
variant="compact"
mode="base64"
value={value}
onChange={setValue}
placeholder="Document"
accept={["image/*", ".pdf"]}
maxFileSize={5 * 1024 * 1024}
/>
</div>
);
},
};
export const CompactWithUpload = {
parameters: {
docs: {
description: {
story:
"Compact variant with upload mode. Useful when you need minimal UI but still want server uploads.",
},
},
},
render: function CompactUploadStory() {
const [value, setValue] = useState<string>("");
const [progress, setProgress] = useState<number>(0);
async function onUploadFile(file: File) {
setProgress(0);
const interval = setInterval(() => {
setProgress((p) => (p >= 100 ? 100 : p + 20));
}, 80);
const result = await mockUpload(file);
clearInterval(interval);
setProgress(100);
return result;
}
return (
<div className="w-[300px]">
<FileInput
variant="compact"
mode="upload"
value={value}
onChange={setValue}
onUploadFile={onUploadFile}
uploadProgress={progress}
placeholder="Resume"
/>
</div>
);
},
};
export const DefaultWithBase64 = {
parameters: {
docs: {
description: {
story:
"Default variant with base64 mode. Full-featured UI but converts to base64 locally instead of uploading.",
},
},
},
render: function DefaultBase64Story() {
const [value, setValue] = useState<string>("");
return (
<div className="w-[560px]">
<FileInput
variant="default"
mode="base64"
value={value}
onChange={setValue}
placeholder="Image"
accept={["image/*"]}
maxFileSize={2 * 1024 * 1024}
/>
</div>
);
},
};

View File

@@ -1,9 +1,11 @@
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;
@@ -12,26 +14,51 @@ type UploadFileResult = {
file_uri: string;
};
interface Props {
onUploadFile: (file: File) => Promise<UploadFileResult>;
uploadProgress: number;
value?: string; // file URI or empty
placeholder?: string; // e.g. "Resume", "Document", etc.
type FileInputVariant = "default" | "compact";
interface BaseProps {
value?: string;
placeholder?: string;
onChange: (value: string) => void;
className?: string;
maxFileSize?: number; // bytes (optional)
accept?: string | string[]; // input accept filter (optional)
maxFileSize?: number;
accept?: string | string[];
variant?: FileInputVariant;
showStorageNote?: boolean;
}
export function FileInput({
onUploadFile,
uploadProgress,
value,
onChange,
className,
maxFileSize,
accept,
}: Props) {
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<{
@@ -40,7 +67,96 @@ export function FileInput({
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);
@@ -53,7 +169,6 @@ export function FileInput({
content_type: result.content_type,
});
// Set the file URI as the value
onChange(result.file_uri);
} catch (error) {
console.error("Upload failed:", error);
@@ -87,43 +202,104 @@ export function FileInput({
if (file) uploadFile(file);
};
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; // e.g. image/png
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("/")) {
// MIME type, support wildcards like image/*
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(".")) {
// Extension match
if (fileExt === e) return true;
}
const handleClear = () => {
if (inputRef.current) {
inputRef.current.value = "";
}
return false;
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}
>
<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}
>
<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 (
@@ -134,15 +310,23 @@ export function FileInput({
<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">Uploading...</span>
<span className="text-gray-500">
{Math.round(uploadProgress)}%
<span className="text-gray-700">
{mode === "base64" ? "Processing..." : "Uploading..."}
</span>
{mode === "upload" && (
<span className="text-gray-500">
{Math.round(uploadProgress)}%
</span>
)}
</div>
<Progress value={uploadProgress} className="w-full" />
{mode === "upload" && (
<Progress value={uploadProgress} className="w-full" />
)}
</div>
</div>
<p className="text-xs text-gray-500">{storageNote}</p>
{showStorageNote && mode === "upload" && (
<p className="text-xs text-gray-500">{storageNote}</p>
)}
</div>
) : value ? (
<div className="space-y-2">
@@ -154,24 +338,20 @@ export function FileInput({
<span className="font-normal text-black">
{fileInfo
? getFileLabel(fileInfo.name, fileInfo.content_type)
: "File"}
: getFileLabelFromValue(value)}
</span>
<span>{fileInfo ? formatFileSize(fileInfo.size) : ""}</span>
</div>
</div>
<TrashIcon
className="h-5 w-5 cursor-pointer text-black"
onClick={() => {
if (inputRef.current) {
inputRef.current.value = "";
}
onChange("");
setFileInfo(null);
}}
onClick={handleClear}
/>
</div>
</div>
<p className="text-xs text-gray-500">{storageNote}</p>
{showStorageNote && mode === "upload" && (
<p className="text-xs text-gray-500">{storageNote}</p>
)}
</div>
) : (
<div className="space-y-2">
@@ -196,7 +376,9 @@ export function FileInput({
<div className="text-sm text-red-600">Error: {uploadError}</div>
)}
<p className="text-xs text-gray-500">{storageNote}</p>
{showStorageNote && mode === "upload" && (
<p className="text-xs text-gray-500">{storageNote}</p>
)}
</div>
)}

View File

@@ -45,6 +45,8 @@ export const FormRenderer = ({
onChange={handleChange}
uiSchema={uiSchema}
formData={initialValues}
noValidate={true}
liveValidate={false}
/>
</div>
);

View File

@@ -1,33 +1,27 @@
import { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/__legacy__/ui/input";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
export const FileWidget = (props: WidgetProps) => {
const { onChange, multiple = false, disabled, readonly, id } = props;
const { onChange, disabled, readonly, value, schema, formContext } = props;
// TODO: It's temporary solution for file input, will complete it follow up prs
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) {
onChange(undefined);
return;
}
const { size } = formContext || {};
const file = files[0];
const reader = new FileReader();
reader.onload = (e) => {
onChange(e.target?.result);
};
reader.readAsDataURL(file);
const displayName = schema?.title || "File";
const handleChange = (fileUri: string) => {
onChange(fileUri);
};
return (
<Input
id={id}
type="file"
multiple={multiple}
disabled={disabled || readonly}
<FileInput
variant={size === "large" ? "default" : "compact"}
mode="base64"
value={value}
placeholder={displayName}
onChange={handleChange}
className="rounded-full"
className={
disabled || readonly ? "pointer-events-none opacity-50" : undefined
}
/>
);
};