From a6f944a5fda69969718ff8ba63709201f5af951a Mon Sep 17 00:00:00 2001
From: nielsbosma
Date: Thu, 30 Oct 2025 11:37:43 +0100
Subject: [PATCH 01/52] feat(upload): add FileUpload type and enhance file
metadata
Refactored upload handling to use a new FileUpload record, enabling access to file metadata (name, content type, size, stream) instead of just raw bytes. Updated FileInput and related APIs for richer file info and upload state tracking. Updated docs, tests, and backward-compatible overloads.
---
.../Docs/01_Onboarding/02_Concepts/Uploads.md | 59 +++++++------
.../02_Widgets/02_Inputs/AudioRecorder.md | 14 ++--
Ivy.Docs.Shared/Ivy.Docs.Shared.csproj | 4 -
Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 58 +++++++++++++
.../Apps/Widgets/Inputs/FileInputApp.cs | 15 +---
Ivy.Test/ConvertJsonNodeTests.cs | 8 +-
Ivy.Test/FileInputValidationTests.cs | 9 +-
Ivy/Core/Models/FileBase.cs | 16 ++++
Ivy/Hooks/UseUpload.cs | 39 +++++++--
Ivy/Services/UploadService.cs | 37 ++++----
Ivy/Utils.cs | 15 ++++
Ivy/Widgets/Inputs/FileInput.cs | 84 +++++++++++++------
Ivy/Widgets/Inputs/FileInputValidation.cs | 12 +--
13 files changed, 249 insertions(+), 121 deletions(-)
create mode 100644 Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
create mode 100644 Ivy/Core/Models/FileBase.cs
diff --git a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md
index c98a5174cd..85e950b116 100644
--- a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md
+++ b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md
@@ -24,7 +24,7 @@ public class FileUploadView : ViewBase
public override object? Build()
{
var files = UseState(() => null);
- var uploadUrl = this.UseUpload(fileBytes => { }, "*/*", "file");
+ var uploadUrl = this.UseUpload(fileUpload => { });
return files.ToFileInput(uploadUrl, "Choose a file");
}
@@ -47,12 +47,12 @@ var client = UseService();
// 1. Create upload handler - returns state with
// URL like "/upload/{connectionId}/{uploadId}"
var uploadUrl = this.UseUpload(
- fileBytes => {
+ fileUpload => {
// This handler is called when a file is uploaded
- client.Toast($"Received {fileBytes.Length} bytes", "File Uploaded");
- },
- "application/octet-stream", // Expected MIME type
- "uploaded-file" // Default filename
+ // Access file metadata: fileUpload.Name, fileUpload.Type, fileUpload.Size, fileUpload.Stream
+ client.Toast($"Received {fileUpload.Size} bytes", "File Uploaded");
+ }
+ // mimeType and fileName are optional parameters with defaults from the uploaded file
);
// 2. Create state to hold file information
@@ -61,7 +61,7 @@ var files = UseState(() => null);
// 3. Connect them with ToFileInput - creates a widget that:
// - Updates the files state when user selects files
// - Automatically uploads to the uploadUrl
-// - Calls your handler with the file bytes
+// - Calls your handler with the FileUpload record
files.ToFileInput(uploadUrl, "Choose Files")
```
@@ -77,15 +77,13 @@ public class UploadWithStatusView : ViewBase
var client = UseService();
var files = UseState(() => null);
var uploadUrl = this.UseUpload(
- fileBytes => {
+ fileUpload => {
try {
- client.Toast($"Successfully uploaded {fileBytes.Length} bytes", "Upload Complete");
+ client.Toast($"Successfully uploaded {fileUpload.Size} bytes", "Upload Complete");
} catch (Exception ex) {
client.Toast(ex);
}
- },
- "application/octet-stream",
- "uploaded-file"
+ }
);
return files.ToFileInput(uploadUrl, "Upload File");
@@ -106,18 +104,17 @@ public class ValidatedUploadView : ViewBase
var error = UseState(() => null);
var files = UseState(() => null);
var uploadUrl = this.UseUpload(
- fileBytes => {
- if (fileBytes.Length > 2 * 1024 * 1024) // 2MB limit
+ fileUpload => {
+ if (fileUpload.Size > 2 * 1024 * 1024) // 2MB limit
{
error.Set("File size must be less than 2MB");
return;
}
error.Set((string?)null);
- // Process uploaded file bytes
- client.Toast($"Image uploaded successfully ({fileBytes.Length} bytes)", "Success");
+ // Process uploaded file
+ client.Toast($"Image uploaded successfully ({fileUpload.Size} bytes)", "Success");
},
- "image/jpeg",
- "uploaded-image"
+ "image/jpeg" // Optional: specify expected MIME type
);
return Layout.Vertical(
@@ -157,14 +154,18 @@ public class ImageUploadView : ViewBase
var preview = UseState(() => null);
var files = UseState(() => null);
var uploadUrl = this.UseUpload(
- fileBytes => {
+ fileUpload => {
+ // Convert stream to bytes for preview
+ using var memoryStream = new MemoryStream();
+ fileUpload.Stream.CopyTo(memoryStream);
+ var fileBytes = memoryStream.ToArray();
+
// Create preview URL from uploaded bytes
preview.Set($"data:image/jpeg;base64,{Convert.ToBase64String(fileBytes)}");
- // Process uploaded file bytes
- client.Toast($"Image uploaded successfully ({fileBytes.Length} bytes)", "Success");
+ // Process uploaded file
+ client.Toast($"Image uploaded successfully ({fileUpload.Size} bytes)", "Success");
},
- "image/jpeg",
- "uploaded-image"
+ "image/jpeg"
);
return Layout.Vertical(
@@ -196,19 +197,17 @@ public class MultiFileUploadView : ViewBase
var uploadedFiles = UseState(() => new List());
var newFiles = UseState?>(() => null);
var uploadUrl = this.UseUpload(
- fileBytes => {
- // Process uploaded file bytes
- client.Toast($"File uploaded ({fileBytes.Length} bytes)", "Upload Complete");
+ fileUpload => {
+ // Process uploaded file
+ client.Toast($"File uploaded ({fileUpload.Size} bytes)", "Upload Complete");
// Add to list of uploaded files
uploadedFiles.Set(uploadedFiles.Value.Append($"File {uploadedFiles.Value.Count + 1}").ToList());
- },
- "application/octet-stream",
- "uploaded-files"
+ }
);
return Layout.Vertical(
newFiles.ToFileInput(uploadUrl, "Upload Files"),
- uploadedFiles.Value.Any()
+ uploadedFiles.Value.Any()
? new List(uploadedFiles.Value.Select(f => Text.Inline(f)))
: null
);
diff --git a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md
index c2b0a219ac..80663706b6 100644
--- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md
+++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md
@@ -26,17 +26,15 @@ public class BasicAudioRecorderDemo : ViewBase
public override object? Build()
{
var uploadUrl = this.UseUpload(
- fileBytes => {
- // Process uploaded file bytes
- Console.WriteLine($"Received {fileBytes.Length} bytes");
- },
- "application/octet-stream",
- "uploaded-audio"
+ fileUpload => {
+ // Process uploaded file
+ Console.WriteLine($"Received {fileUpload.Size} bytes");
+ }
);
return new AudioRecorder("Start recording", "Recording audio..").UploadUrl(uploadUrl.Value).ChunkInterval(3000);
- }
-}
+ }
+}
```
## Styling
diff --git a/Ivy.Docs.Shared/Ivy.Docs.Shared.csproj b/Ivy.Docs.Shared/Ivy.Docs.Shared.csproj
index cd8a8ff26e..8c22429ed4 100644
--- a/Ivy.Docs.Shared/Ivy.Docs.Shared.csproj
+++ b/Ivy.Docs.Shared/Ivy.Docs.Shared.csproj
@@ -42,9 +42,5 @@
-
-
-
-
diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
new file mode 100644
index 0000000000..53e90fb453
--- /dev/null
+++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
@@ -0,0 +1,58 @@
+using Ivy.Hooks;
+using Ivy.Shared;
+using Ivy.Views.Builders;
+
+namespace Ivy.Samples.Shared.Apps.Concepts;
+
+[App(icon: Icons.Upload, searchHints: ["file", "attachment", "upload", "stream", "progress", "multipart"])]
+public class UploadApp : SampleBase
+{
+ protected override object? BuildSample()
+ {
+ var selectedFile = UseState();
+ var uploadedBytes = UseState();
+
+ var uploadUrl = this.UseUpload(async (fileUpload) =>
+ {
+ try
+ {
+ if (selectedFile.Value == null)
+ {
+ selectedFile.Set(new FileInput(fileUpload));
+ }
+
+ var totalBytes = fileUpload.Length;
+ var processedBytes = 0L;
+ var buffer = new byte[8192]; // 8KB chunks
+
+ using var memoryStream = new MemoryStream();
+
+ selectedFile.SetState(FileInputState.Loading);
+
+ int bytesRead;
+ while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
+ {
+ await memoryStream.WriteAsync(buffer, 0, bytesRead);
+ processedBytes += bytesRead;
+ var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0;
+ selectedFile.SetProgress(progress);
+ }
+
+ uploadedBytes.Set(memoryStream.ToArray());
+ }
+ catch (Exception)
+ {
+ selectedFile.SetState(FileInputState.Failed);
+ throw;
+ }
+ finally
+ {
+ selectedFile.SetState(FileInputState.Finished);
+ }
+ });
+
+ return Layout.Vertical()
+ | selectedFile.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload")
+ | selectedFile.ToDetails();
+ }
+}
diff --git a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs
index c7e4d8f351..f5cdd8c419 100644
--- a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs
+++ b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs
@@ -10,14 +10,7 @@ public class FileInputApp : SampleBase
protected override object? BuildSample()
{
// Mock file for 'With Value' example
- var mockFile = new FileInput
- {
- Name = "example.txt",
- Type = "text/plain",
- Size = 1234,
- LastModified = DateTime.Now,
- Content = null
- };
+ var mockFile = new FileInput("example.txt", "text/plain", 12345, DateTime.Now);
var singleFile = UseState(() => null);
var singleFileWithValue = UseState(() => mockFile);
@@ -187,10 +180,10 @@ public class FileInputApp : SampleBase
| Text.InlineCode("File Details")
| singleFile.ToFileInput().Placeholder("Select a text file to view content")
- | (singleFile.Value != null ? (object)singleFile.ToDetails().Remove(e => e!.Content) : Text.Block("No file selected"))
+ | (singleFile.Value != null ? (object)singleFile.ToDetails() : Text.Block("No file selected"))
- | singleFile.ToFileInput().Placeholder("Select a file to view as plain text")
- | (singleFile.Value?.ToPlainText() ?? (object)Text.Block("No file selected"))
+ // | singleFile.ToFileInput().Placeholder("Select a file to view as plain text")
+ // | (singleFile.Value?.ToPlainText() ?? (object)Text.Block("No file selected"))
)
// Backend Validation:
diff --git a/Ivy.Test/ConvertJsonNodeTests.cs b/Ivy.Test/ConvertJsonNodeTests.cs
index 6c06f68546..a5c08eb58d 100644
--- a/Ivy.Test/ConvertJsonNodeTests.cs
+++ b/Ivy.Test/ConvertJsonNodeTests.cs
@@ -97,11 +97,11 @@ public void ConvertFileInput()
var result = (FileInput)Core.Utils.ConvertJsonNode(json!, typeof(FileInput))!;
- Assert.Equal("myfile.txt", result.Name);
- Assert.Equal("text/plain", result.Type);
- Assert.Equal(123, result.Size);
+ Assert.Equal("myfile.txt", result.FileName);
+ Assert.Equal("text/plain", result.ContentType);
+ Assert.Equal(123, result.Length);
Assert.Equal(DateTime.Parse("2023-03-14T09:30:00+01:00"), result.LastModified);
- Assert.Equal("Hello", Encoding.UTF8.GetString(result.Content!));
+ //Assert.Equal("Hello", Encoding.UTF8.GetString(result.Content!));
}
private void Test(JsonNode? input, Type type, object? expectedResult)
diff --git a/Ivy.Test/FileInputValidationTests.cs b/Ivy.Test/FileInputValidationTests.cs
index b5e1194a92..3908dd186b 100644
--- a/Ivy.Test/FileInputValidationTests.cs
+++ b/Ivy.Test/FileInputValidationTests.cs
@@ -312,14 +312,7 @@ public void ValidateFileType_WithFileWithoutExtension_ReturnsError()
private static FileInput CreateTestFile(string name, string type = "text/plain")
{
- return new FileInput
- {
- Name = name,
- Type = type,
- Size = 1024,
- LastModified = DateTime.Now,
- Content = null
- };
+ return new FileInput("example.txt", "text/plain", 12345, DateTime.Now);
}
[Fact]
diff --git a/Ivy/Core/Models/FileBase.cs b/Ivy/Core/Models/FileBase.cs
new file mode 100644
index 0000000000..256d610600
--- /dev/null
+++ b/Ivy/Core/Models/FileBase.cs
@@ -0,0 +1,16 @@
+namespace Ivy.Core.Models;
+
+public abstract record FileBase(string FileName, string ContentType, long Length, DateTime? LastModified = null)
+{
+ /// Gets the name of the uploaded file including its extension.
+ public string FileName { get; init; } = FileName;
+
+ /// Gets the MIME type of the uploaded file.
+ public string ContentType { get; init; } = ContentType;
+
+ /// Gets the size of the uploaded file in bytes.
+ public long Length { get; init; } = Length;
+
+ /// Gets the date and time when the file was last modified, if available.
+ public DateTime? LastModified { get; init; } = LastModified;
+}
diff --git a/Ivy/Hooks/UseUpload.cs b/Ivy/Hooks/UseUpload.cs
index 1d92e87fb8..168fb32bb1 100644
--- a/Ivy/Hooks/UseUpload.cs
+++ b/Ivy/Hooks/UseUpload.cs
@@ -6,25 +6,48 @@ namespace Ivy.Hooks;
public static class UseUploadExtensions
{
- public static IState UseUpload(this TView view, Action handler, string mimeType, string fileName) where TView : ViewBase =>
- view.Context.UseUpload(handler, mimeType, fileName);
+ public static IState UseUpload(this TView view, Action handler, string? defaultContentType = null, string? defaultFileName = null) where TView : ViewBase =>
+ view.Context.UseUpload(handler, defaultContentType, defaultFileName);
- public static IState UseUpload(this TView view, Func handler, string mimeType, string fileName) where TView : ViewBase =>
- view.Context.UseUpload(handler, mimeType, fileName);
+ public static IState UseUpload(this TView view, Func handler, string? defaultContentType = null, string? defaultFileName = null) where TView : ViewBase =>
+ view.Context.UseUpload(handler, defaultContentType, defaultFileName);
- public static IState UseUpload(this IViewContext context, Action handler, string mimeType, string fileName) =>
- context.UseUpload(bytes => { handler(bytes); return Task.CompletedTask; }, mimeType, fileName);
+ public static IState UseUpload(this IViewContext context, Action handler, string? defaultContentType = null, string? defaultFileName = null) =>
+ context.UseUpload(upload => { handler(upload); return Task.CompletedTask; }, defaultContentType, defaultFileName);
- public static IState UseUpload(this IViewContext context, Func handler, string mimeType, string fileName)
+ public static IState UseUpload(this IViewContext context, Func handler, string? defaultContentType = null, string? defaultFileName = null)
{
var url = context.UseState();
var uploadService = context.UseService();
context.UseEffect(() =>
{
- var (cleanup, uploadUrl) = uploadService.AddUpload(handler, mimeType, fileName);
+ var (cleanup, uploadUrl) = uploadService.AddUpload(handler, defaultContentType, defaultFileName);
url.Set(uploadUrl);
return cleanup;
});
return url;
}
+
+ public static IState UseUpload(this TView view, Action handler, string mimeType, string fileName) where TView : ViewBase =>
+ view.Context.UseUpload(handler, mimeType, fileName);
+
+ public static IState UseUpload(this TView view, Func handler, string mimeType, string fileName) where TView : ViewBase =>
+ view.Context.UseUpload(handler, mimeType, fileName);
+
+ public static IState UseUpload(this IViewContext context, Action handler, string mimeType, string fileName) =>
+ context.UseUpload(bytes => { handler(bytes); return Task.CompletedTask; }, mimeType, fileName);
+
+ public static IState UseUpload(this IViewContext context, Func handler, string mimeType, string fileName)
+ {
+ // Adapt byte[] handler to FileUpload handler
+ Func adaptedHandler = async (fileUpload) =>
+ {
+ using var memoryStream = new MemoryStream();
+ await fileUpload.Stream.CopyToAsync(memoryStream);
+ var bytes = memoryStream.ToArray();
+ await handler(bytes);
+ };
+
+ return context.UseUpload(adaptedHandler, mimeType, fileName);
+ }
}
\ No newline at end of file
diff --git a/Ivy/Services/UploadService.cs b/Ivy/Services/UploadService.cs
index 73f0b360cd..b806188e3a 100644
--- a/Ivy/Services/UploadService.cs
+++ b/Ivy/Services/UploadService.cs
@@ -1,11 +1,15 @@
using System.Collections.Concurrent;
using System.Reactive.Disposables;
+using Ivy.Core.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
namespace Ivy.Services;
+public record FileUpload(string FileName, string ContentType, long Length, DateTime? LastModified, Stream Stream)
+ : FileBase(FileName, ContentType, Length, LastModified);
+
[ApiController]
[Route("upload")]
public class UploadController(AppSessionStore sessionStore) : Controller
@@ -37,12 +41,12 @@ public async Task Upload([FromRoute] string connectionId, [FromRo
public class UploadService(string connectionId) : IUploadService, IDisposable
{
- private readonly ConcurrentDictionary handler, string mimeType, string fileName)> _uploads = new();
+ private readonly ConcurrentDictionary handler, string? mimeType, string? fileName)> _uploads = new();
- public (IDisposable cleanup, string url) AddUpload(Func handler, string mimeType, string fileName)
+ public (IDisposable cleanup, string url) AddUpload(Func handler, string? defaultContentType = null, string? defaultFileName = null)
{
var uploadId = Guid.NewGuid();
- _uploads[uploadId] = (handler, mimeType, fileName);
+ _uploads[uploadId] = (handler, defaultContentType, defaultFileName);
var cleanup = Disposable.Create(() =>
{
@@ -59,25 +63,26 @@ public async Task Upload(string uploadId, IFormFile file)
return new BadRequestObjectResult($"Invalid or unknown uploadId: '{uploadId}'.");
}
- var (handler, expectedContentType, expectedFileName) = upload;
+ var (handler, defaultContentType, defaultFileName) = upload;
- if (file == null || file.Length == 0)
+ if (file.Length == 0)
{
return new BadRequestObjectResult("Empty file.");
}
- // Optional sanity checks; do not block upload if mismatched, just basic validation could be enforced here
- // If strict validation is desired, uncomment the checks below
- // if (!string.IsNullOrWhiteSpace(expectedContentType) && !string.Equals(file.ContentType, expectedContentType, StringComparison.OrdinalIgnoreCase))
- // {
- // return new BadRequestObjectResult($"Unexpected content type. Expected '{expectedContentType}', got '{file.ContentType}'.");
- // }
+ var actualMimeType = file.ContentType.NullIfEmpty() ?? defaultContentType ?? "application/octet-stream";
+ var actualFileName = file.FileName.NullIfEmpty() ?? defaultFileName ?? "upload";
- using var memoryStream = new MemoryStream();
- await file.CopyToAsync(memoryStream);
- var fileBytes = memoryStream.ToArray();
+ // Note: IFormFile.OpenReadStream() returns a Stream that's valid during reqnuest
+ var fileUpload = new FileUpload(
+ FileName: actualFileName,
+ ContentType: actualMimeType,
+ Length: file.Length,
+ Stream: file.OpenReadStream(),
+ LastModified: DateTime.UtcNow
+ );
- await handler(fileBytes);
+ await handler(fileUpload);
return new OkResult();
}
@@ -90,7 +95,7 @@ public void Dispose()
public interface IUploadService
{
- (IDisposable cleanup, string url) AddUpload(Func handler, string mimeType, string fileName);
+ (IDisposable cleanup, string url) AddUpload(Func handler, string? defaultContentType = null, string? defaultFileName = null);
Task Upload(string uploadId, IFormFile file);
}
\ No newline at end of file
diff --git a/Ivy/Utils.cs b/Ivy/Utils.cs
index 97cb62f918..883a7e0209 100644
--- a/Ivy/Utils.cs
+++ b/Ivy/Utils.cs
@@ -729,4 +729,19 @@ public static string LabelFor(string name, Type? type)
}
return SplitPascalCase(name) ?? name;
}
+
+ public static string FormatBytes(long bytes)
+ {
+ string[] sizes = ["B", "KB", "MB", "GB", "TB"];
+ double len = bytes;
+ int order = 0;
+
+ while (len >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ len /= 1024;
+ }
+
+ return $"{len:0.##} {sizes[order]}";
+ }
}
\ No newline at end of file
diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs
index c3e2299481..eab3a299b2 100644
--- a/Ivy/Widgets/Inputs/FileInput.cs
+++ b/Ivy/Widgets/Inputs/FileInput.cs
@@ -5,32 +5,46 @@
using Ivy.Core;
using Ivy.Core.Helpers;
using Ivy.Core.Hooks;
+using Ivy.Core.Models;
+using Ivy.Services;
using Ivy.Shared;
using Ivy.Widgets.Inputs;
// ReSharper disable once CheckNamespace
namespace Ivy;
+public enum FileInputState
+{
+ Pending,
+ Aborted,
+ Loading,
+ Failed,
+ Finished
+}
+
///
/// Represents a file uploaded through a file input control.
///
-public record FileInput
+public record FileInput : FileBase
{
- /// Gets the name of the uploaded file including its extension.
- public required string Name { get; init; }
-
- /// Gets the MIME type of the uploaded file.
- public required string Type { get; init; }
+ public FileInput(string FileName, string ContentType, long Length, DateTime? LastModified = null)
+ : base(FileName, ContentType, Length, LastModified)
+ {
+ }
- /// Gets the size of the uploaded file in bytes.
- public int Size { get; init; }
+ public FileInput(FileUpload upload) : this(upload.FileName, upload.ContentType, upload.Length, null)
+ {
+ }
- /// Gets the last modified date of the uploaded file.
- public DateTime LastModified { get; init; }
+ ///
+ /// Value from 0.0 to 1.0 indicating upload progress.
+ ///
+ public float Progress { get; init; }
- /// Gets the binary content of the uploaded file.
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
- public byte[]? Content { get; init; }
+ ///
+ /// Gets the current state of the file upload.
+ ///
+ public FileInputState State { get; init; } = FileInputState.Pending;
}
///
@@ -106,7 +120,8 @@ public ValidationResult ValidateValue(object? value)
{
return FileInputValidation.ValidateFileType(file, Accept);
}
- else if (value is IEnumerable files)
+
+ if (value is IEnumerable files)
{
var filesList = files.ToList();
@@ -230,23 +245,41 @@ public FileInput(string? placeholder = null, bool disabled = false, FileInputs v
///
public static class FileInputExtensions
{
- ///
- /// Converts the file content to plain text using UTF-8 encoding.
- ///
- /// The file input containing the content to convert.
- public static string? ToPlainText(this FileInput file)
+ public static void SetProgress(this IState fileInputState, float progress)
{
- if (file.Content == null)
+ var file = fileInputState.Value;
+ if (file != null)
{
- return null;
+ fileInputState.Set(file with { Progress = progress });
}
- return file.Content.Length switch
+ }
+
+ public static void SetState(this IState fileInputState, FileInputState state)
+ {
+ var file = fileInputState.Value;
+ if (file != null)
{
- 0 => null,
- _ => Encoding.UTF8.GetString(file.Content)
- };
+ fileInputState.Set(file with { State = state });
+ }
}
+ // ///
+ // /// Converts the file content to plain text using UTF-8 encoding.
+ // ///
+ // /// The file input containing the content to convert.
+ // public static string? ToPlainText(this FileInput file)
+ // {
+ // if (file.Content == null)
+ // {
+ // return null;
+ // }
+ // return file.Content.Length switch
+ // {
+ // 0 => null,
+ // _ => Encoding.UTF8.GetString(file.Content)
+ // };
+ // }
+
///
/// Creates a file input from a state object.
///
@@ -407,7 +440,6 @@ public static ValidationResult ValidateFiles(this FileInputBase widget, IEnumera
return FileInputValidation.ValidateFileTypes(filesList, widget.Accept);
}
-
///
/// Sets the blur event handler for the file input.
///
diff --git a/Ivy/Widgets/Inputs/FileInputValidation.cs b/Ivy/Widgets/Inputs/FileInputValidation.cs
index 07344dac29..16fd63e703 100644
--- a/Ivy/Widgets/Inputs/FileInputValidation.cs
+++ b/Ivy/Widgets/Inputs/FileInputValidation.cs
@@ -38,7 +38,7 @@ public static ValidationResult ValidateFileTypes(IEnumerable files, s
{
if (!IsFileTypeAllowed(file, allowedPatterns))
{
- invalidFiles.Add(file.Name);
+ invalidFiles.Add(file.FileName);
}
}
@@ -64,7 +64,7 @@ public static ValidationResult ValidateFileType(FileInput file, string? accept)
if (!IsFileTypeAllowed(file, allowedPatterns))
{
- return ValidationResult.Error($"Invalid file type: {file.Name}. Allowed types: {accept}");
+ return ValidationResult.Error($"Invalid file type: {file.FileName}. Allowed types: {accept}");
}
return ValidationResult.Success();
@@ -99,24 +99,24 @@ private static bool IsFileTypeMatch(FileInput file, string pattern)
{
// Wildcard MIME type (e.g., "image/*")
var baseType = pattern[..^2];
- return file.Type.StartsWith(baseType, StringComparison.OrdinalIgnoreCase);
+ return file.ContentType.StartsWith(baseType, StringComparison.OrdinalIgnoreCase);
}
else
{
// Exact MIME type (e.g., "text/plain")
- return string.Equals(file.Type, pattern, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(file.ContentType, pattern, StringComparison.OrdinalIgnoreCase);
}
}
// Handle file extension patterns (e.g., ".txt", ".pdf")
if (pattern.StartsWith("."))
{
- var fileExtension = Path.GetExtension(file.Name);
+ var fileExtension = Path.GetExtension(file.FileName);
return string.Equals(fileExtension, pattern, StringComparison.OrdinalIgnoreCase);
}
// Handle extension without dot (e.g., "txt", "pdf")
- var extension = Path.GetExtension(file.Name);
+ var extension = Path.GetExtension(file.FileName);
if (!string.IsNullOrEmpty(extension))
{
extension = extension[1..]; // Remove the dot
From 8b0dc8245dd68d1156ae8ef82a294ad09e0a96eb Mon Sep 17 00:00:00 2001
From: nielsbosma
Date: Thu, 30 Oct 2025 13:14:39 +0100
Subject: [PATCH 02/52] feat(file-input): add progress and state to FileInput
model
Update FileInput and FileBase to include progress and state
properties, making them mutable for upload tracking. Sync
backend and frontend models, and enhance the UI with a
progress bar during file uploads.
---
Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 4 +-
Ivy/Core/Models/FileBase.cs | 22 ++++++--
Ivy/Widgets/Inputs/FileInput.cs | 8 ++-
.../src/widgets/inputs/FileInputWidget.tsx | 54 ++++++++++++++-----
4 files changed, 68 insertions(+), 20 deletions(-)
diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
index 53e90fb453..9608893e21 100644
--- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
+++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
@@ -1,6 +1,7 @@
using Ivy.Hooks;
using Ivy.Shared;
using Ivy.Views.Builders;
+using Microsoft.Extensions.Logging;
namespace Ivy.Samples.Shared.Apps.Concepts;
@@ -53,6 +54,7 @@ public class UploadApp : SampleBase
return Layout.Vertical()
| selectedFile.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload")
- | selectedFile.ToDetails();
+ | selectedFile
+ ;
}
}
diff --git a/Ivy/Core/Models/FileBase.cs b/Ivy/Core/Models/FileBase.cs
index 256d610600..9bf9b8bf29 100644
--- a/Ivy/Core/Models/FileBase.cs
+++ b/Ivy/Core/Models/FileBase.cs
@@ -1,16 +1,28 @@
namespace Ivy.Core.Models;
-public abstract record FileBase(string FileName, string ContentType, long Length, DateTime? LastModified = null)
+public abstract record FileBase
{
+ protected FileBase()
+ {
+ }
+
+ protected FileBase(string FileName, string ContentType, long Length, DateTime? LastModified = null)
+ {
+ this.FileName = FileName;
+ this.ContentType = ContentType;
+ this.Length = Length;
+ this.LastModified = LastModified;
+ }
+
/// Gets the name of the uploaded file including its extension.
- public string FileName { get; init; } = FileName;
+ public string? FileName { get; set; }
/// Gets the MIME type of the uploaded file.
- public string ContentType { get; init; } = ContentType;
+ public string? ContentType { get; set; }
/// Gets the size of the uploaded file in bytes.
- public long Length { get; init; } = Length;
+ public long Length { get; set; }
/// Gets the date and time when the file was last modified, if available.
- public DateTime? LastModified { get; init; } = LastModified;
+ public DateTime? LastModified { get; set; }
}
diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs
index eab3a299b2..b98b20fd7f 100644
--- a/Ivy/Widgets/Inputs/FileInput.cs
+++ b/Ivy/Widgets/Inputs/FileInput.cs
@@ -27,6 +27,10 @@ public enum FileInputState
///
public record FileInput : FileBase
{
+ public FileInput()
+ {
+ }
+
public FileInput(string FileName, string ContentType, long Length, DateTime? LastModified = null)
: base(FileName, ContentType, Length, LastModified)
{
@@ -39,12 +43,12 @@ public FileInput(FileUpload upload) : this(upload.FileName, upload.ContentType,
///
/// Value from 0.0 to 1.0 indicating upload progress.
///
- public float Progress { get; init; }
+ public float Progress { get; set; }
///
/// Gets the current state of the file upload.
///
- public FileInputState State { get; init; } = FileInputState.Pending;
+ public FileInputState State { get; set; } = FileInputState.Pending;
}
///
diff --git a/frontend/src/widgets/inputs/FileInputWidget.tsx b/frontend/src/widgets/inputs/FileInputWidget.tsx
index 1f3af59304..881aed2c03 100644
--- a/frontend/src/widgets/inputs/FileInputWidget.tsx
+++ b/frontend/src/widgets/inputs/FileInputWidget.tsx
@@ -13,12 +13,21 @@ import {
textVariants,
} from '@/components/ui/input/file-input-variants';
+enum FileInputState {
+ Pending = 'Pending',
+ Aborted = 'Aborted',
+ Loading = 'Loading',
+ Failed = 'Failed',
+ Finished = 'Finished',
+}
+
interface FileInput {
- name: string;
- size: number;
- type: string;
- lastModified: Date;
- content?: string;
+ fileName: string;
+ contentType: string;
+ length: number;
+ lastModified?: Date;
+ progress: number;
+ state: FileInputState;
}
interface FileInputWidgetProps {
@@ -97,13 +106,15 @@ export const FileInputWidget: React.FC = ({
await uploadFile(file);
}
- // Ivy FileInput should only contain metadata, not file content
+ // Ivy FileInput should only contain metadata
+ // Backend maintains progress and state
return {
- name: file.name,
- size: file.size,
- type: file.type,
+ fileName: file.name,
+ contentType: file.type,
+ length: file.size,
lastModified: new Date(file.lastModified),
- // Don't include content - it's handled by UploadService
+ progress: 0,
+ state: FileInputState.Pending,
};
},
[uploadFile, uploadUrl]
@@ -201,10 +212,15 @@ export const FileInputWidget: React.FC = ({
const displayValue = value
? Array.isArray(value)
- ? value.map(f => f.name).join(', ')
- : value.name
+ ? value.map(f => f.fileName).join(', ')
+ : value.fileName
: '';
+ // Get single file for progress/state display (for single file mode)
+ const singleFile = value && !Array.isArray(value) ? value : null;
+ const isLoading = singleFile?.state === FileInputState.Loading;
+ const progress = singleFile?.progress ?? 0;
+
return (
= ({
)}
+ {/* Progress bar for loading state */}
+ {isLoading && (
+
+
+
+ Uploading... {Math.round(progress * 100)}%
+
+
+ )}
);
};
From d9a8d1135d9708f58d00a1c193fcf997f4813f6e Mon Sep 17 00:00:00 2001
From: nielsbosma
Date: Thu, 30 Oct 2025 14:04:58 +0100
Subject: [PATCH 03/52] feat(file-input): add clear handler support to
FileInput
Add `OnClear` event and `HandleClear` extensions for FileInput.
UI now shows a clear button when a clear handler is provided.
Refactor server control to manage file state; remove OnChange.
Update sample apps and tests for new clear semantics.
---
Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 107 ++++++++++++++++--
.../Apps/Widgets/Inputs/FileInputApp.cs | 13 +--
Ivy.Test/FileInputValidationTests.cs | 16 +--
Ivy/Core/Hooks/State.cs | 5 +
Ivy/Widgets/Inputs/FileInput.cs | 82 +++++++-------
.../src/widgets/inputs/FileInputWidget.tsx | 82 ++++++--------
6 files changed, 185 insertions(+), 120 deletions(-)
diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
index 9608893e21..83dba9bd12 100644
--- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
+++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs
@@ -1,6 +1,7 @@
using Ivy.Hooks;
using Ivy.Shared;
using Ivy.Views.Builders;
+using Ivy.Views.Tables;
using Microsoft.Extensions.Logging;
namespace Ivy.Samples.Shared.Apps.Concepts;
@@ -9,6 +10,17 @@ namespace Ivy.Samples.Shared.Apps.Concepts;
public class UploadApp : SampleBase
{
protected override object? BuildSample()
+ {
+ return Layout.Tabs(
+ new Tab("Single File", new SingleFileUpload()),
+ new Tab("Multiple Files", new MultipleFilesUpload())
+ );
+ }
+}
+
+public class SingleFileUpload : ViewBase
+{
+ public override object? Build()
{
var selectedFile = UseState();
var uploadedBytes = UseState();
@@ -17,10 +29,7 @@ public class UploadApp : SampleBase
{
try
{
- if (selectedFile.Value == null)
- {
- selectedFile.Set(new FileInput(fileUpload));
- }
+ selectedFile.Set(new FileInput(fileUpload));
var totalBytes = fileUpload.Length;
var processedBytes = 0L;
@@ -37,24 +46,102 @@ public class UploadApp : SampleBase
processedBytes += bytesRead;
var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0;
selectedFile.SetProgress(progress);
+
+ //Simulate this being slower
+ await Task.Delay(50);
}
uploadedBytes.Set(memoryStream.ToArray());
+ selectedFile.SetState(FileInputState.Finished);
}
catch (Exception)
{
selectedFile.SetState(FileInputState.Failed);
throw;
}
- finally
- {
- selectedFile.SetState(FileInputState.Finished);
- }
});
+ void OnClear()
+ {
+ selectedFile.Default();
+ uploadedBytes.Default();
+ }
+
return Layout.Vertical()
- | selectedFile.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload")
- | selectedFile
+ | Text.H1("Single File Upload")
+ | selectedFile.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload").HandleClear(OnClear)
+ | selectedFile.ToDetails()
;
}
}
+
+public class MultipleFilesUpload : ViewBase
+{
+ public override object? Build()
+ {
+ var selectedFiles = UseState?>();
+ var uploadCount = UseState(0);
+
+ var uploadUrl = this.UseUpload(async (fileUpload) =>
+ {
+ var newFile = new FileInput(fileUpload);
+
+ // Add the new file to the list
+ var currentFiles = selectedFiles.Value?.ToList() ?? new List();
+ currentFiles.Add(newFile);
+ selectedFiles.Set(currentFiles);
+
+ // Find the file we just added to update its progress
+ var fileToUpdate = currentFiles.Last();
+ var fileIndex = currentFiles.Count - 1;
+
+ var totalBytes = fileUpload.Length;
+ var processedBytes = 0L;
+ var buffer = new byte[8192]; // 8KB chunks
+
+ using var memoryStream = new MemoryStream();
+
+ // Update state for this specific file
+ currentFiles[fileIndex] = fileToUpdate with { State = FileInputState.Loading };
+ selectedFiles.Set(currentFiles.ToArray());
+
+ int bytesRead;
+ while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
+ {
+ await memoryStream.WriteAsync(buffer, 0, bytesRead);
+ processedBytes += bytesRead;
+ var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0;
+
+ // Update progress for this specific file
+ var updatedFiles = currentFiles.ToArray();
+ updatedFiles[fileIndex] = updatedFiles[fileIndex] with { Progress = progress };
+ selectedFiles.Set(updatedFiles);
+
+ //Simulate this being slower
+ await Task.Delay(50);
+ }
+
+ // Mark as finished
+ var finalFiles = selectedFiles.Value!.ToArray();
+ finalFiles[fileIndex] = finalFiles[fileIndex] with { State = FileInputState.Finished };
+ selectedFiles.Set(finalFiles);
+
+ uploadCount.Set(uploadCount.Value + 1);
+ });
+
+ void OnClear()
+ {
+ selectedFiles.Default();
+ uploadCount.Default();
+ }
+
+ var layout = Layout.Vertical()
+ | Text.H1("Multiple Files Upload")
+ | selectedFiles.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose files to upload").HandleClear(OnClear)
+ | selectedFiles.Value?.ToTable().Width(Size.Full()).Remove(e => e.Id)
+ | (uploadCount.Value > 0 ? Text.Block($"Uploaded {uploadCount.Value} file(s)") : null);
+
+
+ return layout;
+ }
+}
diff --git a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs
index f5cdd8c419..b0d4452fe7 100644
--- a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs
+++ b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs
@@ -26,8 +26,6 @@ public class FileInputApp : SampleBase
var singleSizeFile = UseState(() => null);
var multipleSizeFiles = UseState?>(() => null);
- var onChangedState = UseState(() => null);
- var onChangeLabel = UseState("");
var onBlurState = UseState(() => null);
var onBlurLabel = UseState("");
@@ -156,17 +154,8 @@ public class FileInputApp : SampleBase
| multipleFiles.ToFileInput().MaxFiles(5).Placeholder("Select up to 5 files")
)
- // Events:
+ // Events:
| Text.H2("Events")
- | Text.H3("OnChange")
- | Layout.Horizontal(
- new FileInput(onChangedState.Value, e =>
- {
- onChangedState.Set(e.Value);
- onChangeLabel.Set("Changed");
- }),
- onChangeLabel
- )
| Text.H3("OnBlur")
| Layout.Horizontal(
onBlurState.ToFileInput().HandleBlur(e => onBlurLabel.Set("Blur")),
diff --git a/Ivy.Test/FileInputValidationTests.cs b/Ivy.Test/FileInputValidationTests.cs
index 3908dd186b..72e1dd18ab 100644
--- a/Ivy.Test/FileInputValidationTests.cs
+++ b/Ivy.Test/FileInputValidationTests.cs
@@ -319,7 +319,7 @@ private static FileInput CreateTestFile(string name, string type = "text/plain")
public void FileInput_ValidateValue_WithNullValue_ReturnsSuccess()
{
// Arrange
- var fileInput = new FileInput(null, null, "Test");
+ var fileInput = new FileInput((FileInput?)null, "Test");
// Act
var result = fileInput.ValidateValue(null);
@@ -334,7 +334,7 @@ public void FileInput_ValidateValue_WithValidSingleFile_ReturnsSuccess()
{
// Arrange
var file = CreateTestFile("test.txt", "text/plain");
- var fileInput = new FileInput(null, null, "Test") with { Accept = ".txt" };
+ var fileInput = new FileInput((FileInput?)null, "Test") with { Accept = ".txt" };
// Act
var result = fileInput.ValidateValue(file);
@@ -349,7 +349,7 @@ public void FileInput_ValidateValue_WithInvalidSingleFile_ReturnsError()
{
// Arrange
var file = CreateTestFile("test.pdf", "application/pdf");
- var fileInput = new FileInput(null, null, "Test") with { Accept = ".txt" };
+ var fileInput = new FileInput((FileInput?)null, "Test") with { Accept = ".txt" };
// Act
var result = fileInput.ValidateValue(file);
@@ -368,7 +368,7 @@ public void FileInput_ValidateValue_WithValidMultipleFiles_ReturnsSuccess()
CreateTestFile("test1.txt", "text/plain"),
CreateTestFile("test2.txt", "text/plain")
};
- var fileInput = new FileInput?>(null, null, "Test") with { Accept = ".txt", MaxFiles = 3 };
+ var fileInput = new FileInput?>((IEnumerable?)null, "Test") with { Accept = ".txt", MaxFiles = 3 };
// Act
var result = fileInput.ValidateValue(files);
@@ -388,7 +388,7 @@ public void FileInput_ValidateValue_WithTooManyFiles_ReturnsError()
CreateTestFile("test2.txt", "text/plain"),
CreateTestFile("test3.txt", "text/plain")
};
- var fileInput = new FileInput?>(null, null, "Test") with { Accept = ".txt", MaxFiles = 2 };
+ var fileInput = new FileInput?>((IEnumerable?)null, "Test") with { Accept = ".txt", MaxFiles = 2 };
// Act
var result = fileInput.ValidateValue(files);
@@ -407,7 +407,7 @@ public void FileInput_ValidateValue_WithInvalidFileTypes_ReturnsError()
CreateTestFile("test1.txt", "text/plain"),
CreateTestFile("test2.pdf", "application/pdf")
};
- var fileInput = new FileInput?>(null, null, "Test") with { Accept = ".txt", MaxFiles = 3 };
+ var fileInput = new FileInput?>((IEnumerable?)null, "Test") with { Accept = ".txt", MaxFiles = 3 };
// Act
var result = fileInput.ValidateValue(files);
@@ -426,7 +426,7 @@ public void FileInput_ValidateValue_WithMimeTypeWildcard_ReturnsSuccess()
CreateTestFile("test1.jpg", "image/jpeg"),
CreateTestFile("test2.png", "image/png")
};
- var fileInput = new FileInput?>(null, null, "Test") with { Accept = "image/*" };
+ var fileInput = new FileInput?>((IEnumerable?)null, "Test") with { Accept = "image/*" };
// Act
var result = fileInput.ValidateValue(files);
@@ -445,7 +445,7 @@ public void FileInput_ValidateValue_WithNoAcceptOrMaxFiles_ReturnsSuccess()
CreateTestFile("test1.txt", "text/plain"),
CreateTestFile("test2.pdf", "application/pdf")
};
- var fileInput = new FileInput?>(null, null, "Test");
+ var fileInput = new FileInput?>((IEnumerable?)null, "Test");
// Act
var result = fileInput.ValidateValue(files);
diff --git a/Ivy/Core/Hooks/State.cs b/Ivy/Core/Hooks/State.cs
index 2d88b25e15..f89c59ab73 100644
--- a/Ivy/Core/Hooks/State.cs
+++ b/Ivy/Core/Hooks/State.cs
@@ -58,6 +58,11 @@ public T Set(Func setter)
Value = setter(Value);
return Value;
}
+
+ public T Default()
+ {
+ return Set(default(T)!);
+ }
}
///
diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs
index b98b20fd7f..6633043c79 100644
--- a/Ivy/Widgets/Inputs/FileInput.cs
+++ b/Ivy/Widgets/Inputs/FileInput.cs
@@ -13,6 +13,7 @@
// ReSharper disable once CheckNamespace
namespace Ivy;
+[JsonConverter(typeof(JsonStringEnumConverter))]
public enum FileInputState
{
Pending,
@@ -36,10 +37,12 @@ public FileInput(string FileName, string ContentType, long Length, DateTime? Las
{
}
- public FileInput(FileUpload upload) : this(upload.FileName, upload.ContentType, upload.Length, null)
+ public FileInput(FileUpload upload) : this(upload.FileName, upload.ContentType, upload.Length)
{
}
+ public Guid Id { get; } = Guid.NewGuid();
+
///
/// Value from 0.0 to 1.0 indicating upload progress.
///
@@ -107,6 +110,9 @@ public abstract record FileInputBase : WidgetBase, IAnyFileInput
/// Gets or sets the event handler called when the input loses focus.
[Event] public Func, ValueTask>? OnBlur { get; set; }
+ /// Gets or sets the event handler called when the clear button is clicked.
+ [Event] public Func, ValueTask>? OnClear { get; set; }
+
///
/// Returns the types that this file input can bind to.
///
@@ -152,6 +158,7 @@ public ValidationResult ValidateValue(object? value)
///
/// Generic file input control that provides type-safe file upload functionality.
+/// State is managed entirely by the server via the upload handler.
///
/// The type of the file value.
public record FileInput : FileInputBase, IInput, IAnyFileInput
@@ -169,55 +176,19 @@ public FileInput(IAnyState state, string? placeholder = null, bool disabled = fa
{
var typedState = state.As();
Value = typedState.Value;
- OnChange = e =>
- {
- typedState.Set(e.Value);
-
- // Auto-validate if Accept or MaxFiles is set
- if (!string.IsNullOrWhiteSpace(Accept) || MaxFiles.HasValue)
- {
- var validation = ValidateValue(e.Value);
- if (!validation.IsValid)
- {
- Invalid = validation.ErrorMessage;
- }
- else
- {
- Invalid = null;
- }
- }
- return ValueTask.CompletedTask;
- };
}
///
/// Initializes a new instance with an explicit value.
///
/// The initial file value.
- /// Optional event handler called when the file value changes.
/// Optional placeholder text displayed when no files are selected.
/// Whether the input should be disabled initially.
/// The visual variant of the file input.
[OverloadResolutionPriority(1)]
- public FileInput(TValue value, Func, TValue>, ValueTask>? onChange, string? placeholder = null, bool disabled = false, FileInputs variant = FileInputs.Drop)
- : this(placeholder, disabled, variant)
- {
- OnChange = onChange;
- Value = value;
- }
-
- ///
- /// Initializes a new instance with an explicit value and synchronous change handler.
- ///
- /// The initial file value.
- /// Optional event handler called when the file value changes.
- /// Optional placeholder text displayed when no files are selected.
- /// Whether the input should be disabled initially.
- /// The visual variant of the file input.
- public FileInput(TValue value, Action, TValue>>? onChange, string? placeholder = null, bool disabled = false, FileInputs variant = FileInputs.Drop)
+ public FileInput(TValue value, string? placeholder = null, bool disabled = false, FileInputs variant = FileInputs.Drop)
: this(placeholder, disabled, variant)
{
- OnChange = onChange == null ? null : e => { onChange(e); return ValueTask.CompletedTask; };
Value = value;
}
@@ -240,8 +211,8 @@ public FileInput(string? placeholder = null, bool disabled = false, FileInputs v
/// Gets the current file value.
[Prop] public TValue Value { get; } = default!;
- /// Gets the event handler called when the file value changes.
- [Event] public Func, TValue>, ValueTask>? OnChange { get; }
+ /// OnChange event is not used - file state is managed by the server.
+ [Event] public Func, TValue>, ValueTask>? OnChange => null;
}
///
@@ -474,4 +445,35 @@ public static FileInputBase HandleBlur(this FileInputBase widget, Action onBlur)
{
return widget.HandleBlur(_ => { onBlur(); return ValueTask.CompletedTask; });
}
+
+ ///
+ /// Sets the clear event handler for the file input.
+ ///
+ /// The file input to configure.
+ /// The event handler to call when the clear button is clicked.
+ [OverloadResolutionPriority(1)]
+ public static FileInputBase HandleClear(this FileInputBase widget, Func, ValueTask> onClear)
+ {
+ return widget with { OnClear = onClear };
+ }
+
+ ///
+ /// Sets the clear event handler for the file input.
+ ///
+ /// The file input to configure.
+ /// The event handler to call when the clear button is clicked.
+ public static FileInputBase HandleClear(this FileInputBase widget, Action> onClear)
+ {
+ return widget.HandleClear(onClear.ToValueTask());
+ }
+
+ ///
+ /// Sets a simple clear event handler for the file input.
+ ///
+ /// The file input to configure.
+ /// The simple action to perform when the clear button is clicked.
+ public static FileInputBase HandleClear(this FileInputBase widget, Action onClear)
+ {
+ return widget.HandleClear(_ => { onClear(); return ValueTask.CompletedTask; });
+ }
}
\ No newline at end of file
diff --git a/frontend/src/widgets/inputs/FileInputWidget.tsx b/frontend/src/widgets/inputs/FileInputWidget.tsx
index 881aed2c03..0268ae9847 100644
--- a/frontend/src/widgets/inputs/FileInputWidget.tsx
+++ b/frontend/src/widgets/inputs/FileInputWidget.tsx
@@ -1,12 +1,12 @@
import React, { useCallback, useState, useRef } from 'react';
import { Input } from '@/components/ui/input';
-import { useEventHandler } from '@/components/event-handler';
import { Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getWidth } from '@/lib/styles';
import { InvalidIcon } from '@/components/InvalidIcon';
import { Sizes } from '@/types/sizes';
+import { useEventHandler } from '@/components/event-handler';
import {
fileInputVariants,
uploadIconVariants,
@@ -22,6 +22,7 @@ enum FileInputState {
}
interface FileInput {
+ id: string;
fileName: string;
contentType: string;
length: number;
@@ -50,6 +51,7 @@ export const FileInputWidget: React.FC = ({
value,
disabled,
invalid,
+ events,
width,
accept,
multiple = false,
@@ -62,6 +64,8 @@ export const FileInputWidget: React.FC = ({
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef(null);
+ const hasClearHandler = events.includes('OnClear');
+
const uploadFile = useCallback(
async (file: File): Promise => {
if (!uploadUrl) return;
@@ -96,30 +100,6 @@ export const FileInputWidget: React.FC = ({
[uploadUrl]
);
- const convertFileToUploadFile = useCallback(
- async (file: File): Promise => {
- if (!file) {
- throw new Error('File is required');
- }
-
- if (uploadUrl) {
- await uploadFile(file);
- }
-
- // Ivy FileInput should only contain metadata
- // Backend maintains progress and state
- return {
- fileName: file.name,
- contentType: file.type,
- length: file.size,
- lastModified: new Date(file.lastModified),
- progress: 0,
- state: FileInputState.Pending,
- };
- },
- [uploadFile, uploadUrl]
- );
-
const handleChange = useCallback(
async (e: React.ChangeEvent) => {
const files = e.target.files;
@@ -129,26 +109,28 @@ export const FileInputWidget: React.FC = ({
if (maxFiles && files.length > maxFiles) {
// Only take the first maxFiles files
const limitedFiles = Array.from(files).slice(0, maxFiles);
- const selectedFiles = multiple
- ? await Promise.all(limitedFiles.map(convertFileToUploadFile))
- : await convertFileToUploadFile(limitedFiles[0]);
-
- handleEvent('OnChange', id, [selectedFiles]);
+ if (multiple) {
+ await Promise.all(limitedFiles.map(uploadFile));
+ } else {
+ await uploadFile(limitedFiles[0]);
+ }
return;
}
- const selectedFiles = multiple
- ? await Promise.all(Array.from(files).map(convertFileToUploadFile))
- : await convertFileToUploadFile(files[0]);
-
- handleEvent('OnChange', id, [selectedFiles]);
+ if (multiple) {
+ await Promise.all(Array.from(files).map(uploadFile));
+ } else {
+ await uploadFile(files[0]);
+ }
},
- [id, multiple, handleEvent, convertFileToUploadFile, maxFiles]
+ [multiple, uploadFile, maxFiles]
);
const handleClear = useCallback(() => {
- handleEvent('OnChange', id, [null]);
- }, [id, handleEvent]);
+ if (hasClearHandler) {
+ handleEvent('OnClear', id, []);
+ }
+ }, [hasClearHandler, handleEvent, id]);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
@@ -187,21 +169,21 @@ export const FileInputWidget: React.FC = ({
if (maxFiles && files.length > maxFiles) {
// Only take the first maxFiles files
const limitedFiles = files.slice(0, maxFiles);
- const selectedFiles = multiple
- ? await Promise.all(limitedFiles.map(convertFileToUploadFile))
- : await convertFileToUploadFile(limitedFiles[0]);
-
- handleEvent('OnChange', id, [selectedFiles]);
+ if (multiple) {
+ await Promise.all(limitedFiles.map(uploadFile));
+ } else {
+ await uploadFile(limitedFiles[0]);
+ }
return;
}
- const selectedFiles = multiple
- ? await Promise.all(files.map(convertFileToUploadFile))
- : await convertFileToUploadFile(files[0]);
-
- handleEvent('OnChange', id, [selectedFiles]);
+ if (multiple) {
+ await Promise.all(files.map(uploadFile));
+ } else {
+ await uploadFile(files[0]);
+ }
},
- [id, multiple, handleEvent, disabled, convertFileToUploadFile, maxFiles]
+ [multiple, disabled, uploadFile, maxFiles]
);
const handleClick = useCallback(() => {
@@ -264,7 +246,7 @@ export const FileInputWidget: React.FC = ({
`Drag and drop your ${multiple ? 'files' : 'file'} here or click to select`}
- {value && !disabled && (
+ {value && !disabled && hasClearHandler && (