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 && ( + )} + + ); + }; + + // Check if we have any files to display + const hasFiles = value && (Array.isArray(value) ? value.length > 0 : true); + const fileList = Array.isArray(value) ? value : value ? [value] : []; return (
= ({ isDragging && !disabled ? 'border-primary bg-primary/5' : 'border-muted-foreground/25', - disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer' + disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', + hasFiles ? 'overflow-y-auto' : '' )} onClick={handleClick} > @@ -237,20 +292,32 @@ export const FileInputWidget: React.FC = ({ disabled={disabled} className="hidden" /> -
- -

- {displayValue || - placeholder || - `Drag and drop your ${multiple ? 'files' : 'file'} here or click to select`} -

-
- {value && !disabled && hasClearHandler && ( + + {/* Show upload prompt when no files */} + {!hasFiles && ( +
+ +

+ {placeholder || + `Drag and drop your ${multiple ? 'files' : 'file'} here or click to select`} +

+
+ )} + + {/* Show file list when files are present */} + {hasFiles && ( +
+ {fileList.map(file => renderFileItem(file))} +
+ )} + + {/* Clear button when files exist and handler is present */} + {hasFiles && !disabled && hasClearHandler && ( )}
- {/* Progress bar for loading state */} - {isLoading && ( -
-
-
-
-

- Uploading... {Math.round(progress * 100)}% -

-
- )}
); }; From 550e25e45ac6bce111b3dc4413b6f6252e912845 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 15:43:01 +0100 Subject: [PATCH 06/52] refactor(state): add atomic Set and Default methods Implement thread-safe Set(Func) and Default methods for IState and ConvertedState. Refactor usages to ensure atomic state updates, improving reliability in concurrent scenarios. --- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 48 +++++------ Ivy.Test/TextInputTests.cs | 18 +++- Ivy/Core/Hooks/ConvertedState.cs | 34 ++++++++ Ivy/Core/Hooks/State.cs | 84 ++++++++++++++----- Ivy/Widgets/Inputs/FileInput.cs | 6 +- 5 files changed, 140 insertions(+), 50 deletions(-) diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index a918f56c77..ef0d7e6ef8 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Ivy.Hooks; using Ivy.Shared; using Ivy.Views.Builders; @@ -81,21 +82,14 @@ public class MultipleFilesUpload : ViewBase { public override object? Build() { - var selectedFiles = UseState?>(); + var selectedFiles = UseState(ImmutableArray.Create()); var uploadCount = UseState(0); var uploadUrl = this.UseUpload(async (fileUpload) => { - var newFile = new FileInput(fileUpload); + var currentFile = 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; + selectedFiles.Set(files => files.Add(currentFile)); var totalBytes = fileUpload.Length; var processedBytes = 0L; @@ -103,9 +97,10 @@ public class MultipleFilesUpload : ViewBase using var memoryStream = new MemoryStream(); - // Update state for this specific file - currentFiles[fileIndex] = fileToUpdate with { State = FileInputState.Loading }; - selectedFiles.Set(currentFiles.ToArray()); + // Update to Loading state + var loadingFile = currentFile with { State = FileInputState.Loading }; + selectedFiles.Set(files => files.Replace(currentFile, loadingFile)); + currentFile = loadingFile; int bytesRead; while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length)) > 0) @@ -114,21 +109,20 @@ public class MultipleFilesUpload : ViewBase 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); + // Update progress - thread-safe atomic operation + var updatedFile = currentFile with { Progress = progress }; + selectedFiles.Set(files => files.Replace(currentFile, updatedFile)); + currentFile = updatedFile; //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); + // Mark as finished - thread-safe atomic operation + var finishedFile = currentFile with { State = FileInputState.Finished }; + selectedFiles.Set(files => files.Replace(currentFile, finishedFile)); - uploadCount.Set(uploadCount.Value + 1); + uploadCount.Set(count => count + 1); }); void OnClear() @@ -139,15 +133,17 @@ void OnClear() void OnDelete(Guid fileId) { - var currentFiles = selectedFiles.Value?.ToList() ?? new List(); - var updatedFiles = currentFiles.Where(f => f.Id != fileId).ToList(); - selectedFiles.Set(updatedFiles); + selectedFiles.Set(files => + { + var fileToRemove = files.FirstOrDefault(f => f.Id == fileId); + return fileToRemove != null ? files.Remove(fileToRemove) : files; + }); } var layout = Layout.Vertical() | Text.H1("Multiple Files Upload") | selectedFiles.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose files to upload").HandleClear(OnClear).HandleDelete(OnDelete) - | selectedFiles.Value?.ToTable() + | selectedFiles.Value.ToTable() .Width(Size.Full()) .Builder(e => e.Length, e => e.Func((long x) => Utils.FormatBytes(x))) .Builder(e => e.Progress, e => e.Func((float x) => x.ToString("P0"))) diff --git a/Ivy.Test/TextInputTests.cs b/Ivy.Test/TextInputTests.cs index 2188828318..667fa1a5ca 100644 --- a/Ivy.Test/TextInputTests.cs +++ b/Ivy.Test/TextInputTests.cs @@ -257,7 +257,23 @@ private class MockState(T value) : IState public T Value { get; set; } = value; - public T Set(T value) => Value = value; + public T Set(T value) + { + Value = value; + return Value; + } + + public T Set(Func setter) + { + Value = setter(Value); + return Value; + } + + public T Default() + { + return Set(default(T)!); + } + public Type GetStateType() => typeof(T); public IDisposable Subscribe(IObserver observer) diff --git a/Ivy/Core/Hooks/ConvertedState.cs b/Ivy/Core/Hooks/ConvertedState.cs index 2dba0d9b8c..3b710f3d2c 100644 --- a/Ivy/Core/Hooks/ConvertedState.cs +++ b/Ivy/Core/Hooks/ConvertedState.cs @@ -57,4 +57,38 @@ public TTo Value get => forward(originalState.Value); set => originalState.Value = backward(value); } + + /// + /// Sets the state value and returns the new value. + /// Thread-safe: delegates to the original state's Set method. + /// + /// The new value to set. + /// The new state value. + public TTo Set(TTo value) + { + originalState.Set(backward(value)); + return value; + } + + /// + /// Updates the state value using a setter function and returns the new value. + /// Thread-safe: the entire read-modify-write operation is atomic. + /// + /// Function that takes the current value and returns the new value. + /// The new state value. + public TTo Set(Func setter) + { + var newValue = originalState.Set(from => backward(setter(forward(from)))); + return forward(newValue); + } + + /// + /// Resets the state to its default value. + /// Thread-safe: delegates to the original state's Default method. + /// + /// The default value. + public TTo Default() + { + return Set(default(TTo)!); + } } \ No newline at end of file diff --git a/Ivy/Core/Hooks/State.cs b/Ivy/Core/Hooks/State.cs index f89c59ab73..ae799cae69 100644 --- a/Ivy/Core/Hooks/State.cs +++ b/Ivy/Core/Hooks/State.cs @@ -42,27 +42,20 @@ public interface IState : IObservable, IAnyState ///
/// The new value to set. /// The new state value. - public T Set(T value) - { - Value = value; - return Value; - } + public T Set(T value); /// /// Updates the state value using a setter function and returns the new value. /// /// Function that takes the current value and returns the new value. /// The new state value. - public T Set(Func setter) - { - Value = setter(Value); - return Value; - } + public T Set(Func setter); - public T Default() - { - return Set(default(T)!); - } + /// + /// Resets the state to its default value. + /// + /// The default value. + public T Default(); } /// @@ -73,6 +66,7 @@ public class State : IState { private T _value; private readonly Subject _subject = new(); + private readonly object _lock = new(); /// /// Creates a new state instance with the specified initial value. @@ -88,15 +82,64 @@ public State(T initialValue) /// public T Value { - get => _value; + get + { + lock (_lock) + { + return _value; + } + } set { - if (Equals(_value, value)) return; - _value = value; + lock (_lock) + { + if (Equals(_value, value)) return; + _value = value; + if (!_subject.IsDisposed) _subject.OnNext(_value); + } + } + } + + /// + /// Sets the state value and returns the new value. + /// Thread-safe. + /// + /// The new value to set. + /// The new state value. + public T Set(T value) + { + Value = value; + return Value; + } + + /// + /// Updates the state value using a setter function and returns the new value. + /// Thread-safe: the entire read-modify-write operation is atomic. + /// + /// Function that takes the current value and returns the new value. + /// The new state value. + public T Set(Func setter) + { + lock (_lock) + { + var newValue = setter(_value); + if (Equals(_value, newValue)) return _value; + _value = newValue; if (!_subject.IsDisposed) _subject.OnNext(_value); + return _value; } } + /// + /// Resets the state to its default value. + /// Thread-safe. + /// + /// The default value. + public T Default() + { + return Set(default(T)!); + } + /// /// Subscribes to state changes and immediately receives the current value. /// @@ -104,8 +147,11 @@ public T Value /// Disposable subscription. public IDisposable Subscribe(IObserver observer) { - observer.OnNext(_value); - return _subject.Subscribe(observer); + lock (_lock) + { + observer.OnNext(_value); + return _subject.Subscribe(observer); + } } /// diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index 94aab0487a..7a663f95c5 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -269,10 +269,8 @@ public static FileInputBase ToFileInput(this IAnyState state, string? placeholde { var type = state.GetStateType(); - //Check that type is FileInput, FileInput? or IEnumerable - var isCollection = type.IsGenericType && - type.GetGenericTypeDefinition() == typeof(IEnumerable<>) && - type.GetGenericArguments()[0] == typeof(FileInput); + //Check that type is FileInput, FileInput? or IEnumerable (including ImmutableArray, List, etc.) + var isCollection = typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string); var isValid = type == typeof(FileInput) || isCollection; if (!isValid) From d3457eed1737b2523b938a3948e23ee7e0150de1 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 15:56:27 +0100 Subject: [PATCH 07/52] refactor(file-input): replace Clear with Delete and State with Status Rename FileInputState to FileInputStatus and remove clear event/handler in favor of delete. Update related usages in backend and frontend. --- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 23 ++++------ Ivy/Widgets/Inputs/FileInput.cs | 42 ++----------------- .../src/widgets/inputs/FileInputWidget.tsx | 29 ++----------- 3 files changed, 16 insertions(+), 78 deletions(-) diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index ef0d7e6ef8..b3ba22238a 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -38,7 +38,7 @@ public class SingleFileUpload : ViewBase using var memoryStream = new MemoryStream(); - selectedFile.SetState(FileInputState.Loading); + selectedFile.SetStatus(FileInputStatus.Loading); int bytesRead; while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length)) > 0) @@ -53,24 +53,25 @@ public class SingleFileUpload : ViewBase } uploadedBytes.Set(memoryStream.ToArray()); - selectedFile.SetState(FileInputState.Finished); + selectedFile.SetStatus(FileInputStatus.Finished); } catch (Exception) { - selectedFile.SetState(FileInputState.Failed); + selectedFile.SetStatus(FileInputStatus.Failed); throw; } }); - void OnClear() + void OnDelete(Guid fileId) { + // Thread-safe: clear the file selectedFile.Default(); uploadedBytes.Default(); } return Layout.Vertical() | Text.H1("Single File Upload") - | selectedFile.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload").HandleClear(OnClear) + | selectedFile.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload").HandleDelete(OnDelete) | selectedFile.ToDetails() .Builder(e => e!.Length, e => e.Func((long x) => Utils.FormatBytes(x))) .Builder(e => e!.Progress, e => e.Func((float x) => x.ToString("P0"))) @@ -98,7 +99,7 @@ public class MultipleFilesUpload : ViewBase using var memoryStream = new MemoryStream(); // Update to Loading state - var loadingFile = currentFile with { State = FileInputState.Loading }; + var loadingFile = currentFile with { Status = FileInputStatus.Loading }; selectedFiles.Set(files => files.Replace(currentFile, loadingFile)); currentFile = loadingFile; @@ -119,18 +120,12 @@ public class MultipleFilesUpload : ViewBase } // Mark as finished - thread-safe atomic operation - var finishedFile = currentFile with { State = FileInputState.Finished }; + var finishedFile = currentFile with { Status = FileInputStatus.Finished }; selectedFiles.Set(files => files.Replace(currentFile, finishedFile)); uploadCount.Set(count => count + 1); }); - void OnClear() - { - selectedFiles.Default(); - uploadCount.Default(); - } - void OnDelete(Guid fileId) { selectedFiles.Set(files => @@ -142,7 +137,7 @@ void OnDelete(Guid fileId) var layout = Layout.Vertical() | Text.H1("Multiple Files Upload") - | selectedFiles.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose files to upload").HandleClear(OnClear).HandleDelete(OnDelete) + | selectedFiles.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose files to upload").HandleDelete(OnDelete) | selectedFiles.Value.ToTable() .Width(Size.Full()) .Builder(e => e.Length, e => e.Func((long x) => Utils.FormatBytes(x))) diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index 7a663f95c5..3c22f61a4f 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -14,7 +14,7 @@ namespace Ivy; [JsonConverter(typeof(JsonStringEnumConverter))] -public enum FileInputState +public enum FileInputStatus { Pending, Aborted, @@ -51,7 +51,7 @@ public FileInput(FileUpload upload) : this(upload.FileName!, upload.ContentType! /// /// Gets the current state of the file upload. /// - public FileInputState State { get; set; } = FileInputState.Pending; + public FileInputStatus Status { get; set; } = FileInputStatus.Pending; } /// @@ -110,9 +110,6 @@ 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; } - /// Gets or sets the event handler called when a file is deleted (passes FileInput.Id as parameter). [Event] public Func, ValueTask>? OnDelete { get; set; } @@ -232,12 +229,12 @@ public static void SetProgress(this IState fileInputState, float pro } } - public static void SetState(this IState fileInputState, FileInputState state) + public static void SetStatus(this IState fileInputState, FileInputStatus status) { var file = fileInputState.Value; if (file != null) { - fileInputState.Set(file with { State = state }); + fileInputState.Set(file with { Status = status }); } } @@ -447,37 +444,6 @@ 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; }); - } - /// /// Sets the delete event handler for the file input. /// diff --git a/frontend/src/widgets/inputs/FileInputWidget.tsx b/frontend/src/widgets/inputs/FileInputWidget.tsx index 27bccc37b7..6e6ecd9b9a 100644 --- a/frontend/src/widgets/inputs/FileInputWidget.tsx +++ b/frontend/src/widgets/inputs/FileInputWidget.tsx @@ -13,7 +13,7 @@ import { textVariants, } from '@/components/ui/input/file-input-variants'; -enum FileInputState { +enum FileInputStatus { Pending = 'Pending', Aborted = 'Aborted', Loading = 'Loading', @@ -27,7 +27,7 @@ interface FileInput { contentType: string; length: number; progress: number; - state: FileInputState; + status: FileInputStatus; } interface FileInputWidgetProps { @@ -63,7 +63,6 @@ export const FileInputWidget: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const inputRef = useRef(null); - const hasClearHandler = events.includes('OnClear'); const hasDeleteHandler = events.includes('OnDelete'); const uploadFile = useCallback( @@ -126,12 +125,6 @@ export const FileInputWidget: React.FC = ({ [multiple, uploadFile, maxFiles] ); - const handleClear = useCallback(() => { - if (hasClearHandler) { - handleEvent('OnClear', id, []); - } - }, [hasClearHandler, handleEvent, id]); - const handleDelete = useCallback( (fileId: string) => { if (hasDeleteHandler) { @@ -212,7 +205,7 @@ export const FileInputWidget: React.FC = ({ // Render individual file item for multiple files view const renderFileItem = (file: FileInput) => { - const isFileLoading = file.state === FileInputState.Loading; + const isFileLoading = file.status === FileInputStatus.Loading; const fileProgress = file.progress ?? 0; return ( @@ -310,22 +303,6 @@ export const FileInputWidget: React.FC = ({ {fileList.map(file => renderFileItem(file))} )} - - {/* Clear button when files exist and handler is present */} - {hasFiles && !disabled && hasClearHandler && ( - - )} ); From deac00d52f482123ef3c27a246988be7adcf1352 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 16:29:31 +0100 Subject: [PATCH 08/52] feat(upload): add cancellation support for file uploads Enable file upload cancellation via CancellationToken and abort handling. FileInput now supports cancellation and implements IDisposable. --- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 94 ++++++++++++------- Ivy/Hooks/UseUpload.cs | 19 ++-- Ivy/Services/UploadService.cs | 10 +- Ivy/Widgets/Inputs/FileInput.cs | 25 ++++- 4 files changed, 100 insertions(+), 48 deletions(-) diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index b3ba22238a..2002760936 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -28,9 +28,11 @@ public class SingleFileUpload : ViewBase var uploadUrl = this.UseUpload(async (fileUpload) => { + var currentFile = new FileInput(fileUpload); + try { - selectedFile.Set(new FileInput(fileUpload)); + selectedFile.Set(currentFile); var totalBytes = fileUpload.Length; var processedBytes = 0L; @@ -41,30 +43,38 @@ public class SingleFileUpload : ViewBase selectedFile.SetStatus(FileInputStatus.Loading); int bytesRead; - while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length)) > 0) + while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length, currentFile.CancellationToken)) > 0) { - await memoryStream.WriteAsync(buffer, 0, bytesRead); + currentFile.CancellationToken.ThrowIfCancellationRequested(); + + await memoryStream.WriteAsync(buffer, 0, bytesRead, currentFile.CancellationToken); processedBytes += bytesRead; var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0; selectedFile.SetProgress(progress); //Simulate this being slower - await Task.Delay(50); + await Task.Delay(50, currentFile.CancellationToken); } uploadedBytes.Set(memoryStream.ToArray()); selectedFile.SetStatus(FileInputStatus.Finished); } + catch (OperationCanceledException) + { + selectedFile.SetStatus(FileInputStatus.Aborted); + } catch (Exception) { selectedFile.SetStatus(FileInputStatus.Failed); throw; } + + return currentFile; //IDisposable }); void OnDelete(Guid fileId) { - // Thread-safe: clear the file + selectedFile.Value?.CancelUpload(); selectedFile.Default(); uploadedBytes.Default(); } @@ -90,49 +100,61 @@ public class MultipleFilesUpload : ViewBase { var currentFile = new FileInput(fileUpload); - selectedFiles.Set(files => files.Add(currentFile)); + try + { + selectedFiles.Set(files => files.Add(currentFile)); - var totalBytes = fileUpload.Length; - var processedBytes = 0L; - var buffer = new byte[8192]; // 8KB chunks + var totalBytes = fileUpload.Length; + var processedBytes = 0L; + var buffer = new byte[8192]; // 8KB chunks - using var memoryStream = new MemoryStream(); + using var memoryStream = new MemoryStream(); - // Update to Loading state - var loadingFile = currentFile with { Status = FileInputStatus.Loading }; - selectedFiles.Set(files => files.Replace(currentFile, loadingFile)); - currentFile = loadingFile; + // Update to Loading state + var loadingFile = currentFile with { Status = FileInputStatus.Loading }; + selectedFiles.Set(files => files.Replace(currentFile, loadingFile)); + currentFile = loadingFile; - 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; + int bytesRead; + while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length, currentFile.CancellationToken)) > 0) + { + currentFile.CancellationToken.ThrowIfCancellationRequested(); - // Update progress - thread-safe atomic operation - var updatedFile = currentFile with { Progress = progress }; - selectedFiles.Set(files => files.Replace(currentFile, updatedFile)); - currentFile = updatedFile; + await memoryStream.WriteAsync(buffer, 0, bytesRead, currentFile.CancellationToken); + processedBytes += bytesRead; + var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0; - //Simulate this being slower - await Task.Delay(50); - } + // Update progress - thread-safe atomic operation + var updatedFile = currentFile with { Progress = progress }; + selectedFiles.Set(files => files.Replace(currentFile, updatedFile)); + currentFile = updatedFile; - // Mark as finished - thread-safe atomic operation - var finishedFile = currentFile with { Status = FileInputStatus.Finished }; - selectedFiles.Set(files => files.Replace(currentFile, finishedFile)); + //Simulate this being slower + await Task.Delay(50, currentFile.CancellationToken); + } - uploadCount.Set(count => count + 1); + // Mark as finished - thread-safe atomic operation + var finishedFile = currentFile with { Status = FileInputStatus.Finished }; + selectedFiles.Set(files => files.Replace(currentFile, finishedFile)); + + uploadCount.Set(count => count + 1); + } + catch (OperationCanceledException) + { + // Upload was aborted by user + var abortedFile = currentFile with { Status = FileInputStatus.Aborted }; + selectedFiles.Set(files => files.Replace(currentFile, abortedFile)); + } + + return currentFile; //IDisposable - automatically disposed by UploadService }); void OnDelete(Guid fileId) { - selectedFiles.Set(files => - { - var fileToRemove = files.FirstOrDefault(f => f.Id == fileId); - return fileToRemove != null ? files.Remove(fileToRemove) : files; - }); + var file = selectedFiles.Value.FirstOrDefault(f => f.Id == fileId); + if (file == null) return; + file.CancelUpload(); + selectedFiles.Set(files => files.Remove(file)); } var layout = Layout.Vertical() diff --git a/Ivy/Hooks/UseUpload.cs b/Ivy/Hooks/UseUpload.cs index 168fb32bb1..67382d5d80 100644 --- a/Ivy/Hooks/UseUpload.cs +++ b/Ivy/Hooks/UseUpload.cs @@ -12,10 +12,16 @@ public static class UseUploadExtensions 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 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? defaultContentType = null, string? defaultFileName = null) => - context.UseUpload(upload => { handler(upload); return Task.CompletedTask; }, defaultContentType, defaultFileName); + context.UseUpload(upload => { handler(upload); return Task.FromResult(null); }, defaultContentType, defaultFileName); + + public static IState UseUpload(this IViewContext context, Func handler, string? defaultContentType = null, string? defaultFileName = null) => + context.UseUpload(async upload => { await handler(upload); return null; }, defaultContentType, defaultFileName); - public static IState UseUpload(this IViewContext context, Func handler, string? defaultContentType = null, string? defaultFileName = null) + public static IState UseUpload(this IViewContext context, Func> handler, string? defaultContentType = null, string? defaultFileName = null) { var url = context.UseState(); var uploadService = context.UseService(); @@ -35,19 +41,20 @@ public static class UseUploadExtensions 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); + context.UseUpload(bytes => { handler(bytes); return Task.FromResult(null); }, 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) => + async Task AdaptedHandler(FileUpload fileUpload) { using var memoryStream = new MemoryStream(); await fileUpload.Stream.CopyToAsync(memoryStream); var bytes = memoryStream.ToArray(); await handler(bytes); - }; + return null; + } - return context.UseUpload(adaptedHandler, mimeType, fileName); + return context.UseUpload((Func>)AdaptedHandler, mimeType, fileName); } } \ No newline at end of file diff --git a/Ivy/Services/UploadService.cs b/Ivy/Services/UploadService.cs index 922e7314e2..5fae3a612a 100644 --- a/Ivy/Services/UploadService.cs +++ b/Ivy/Services/UploadService.cs @@ -41,9 +41,9 @@ 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? defaultContentType = null, string? defaultFileName = null) + public (IDisposable cleanup, string url) AddUpload(Func> handler, string? defaultContentType = null, string? defaultFileName = null) { var uploadId = Guid.NewGuid(); _uploads[uploadId] = (handler, defaultContentType, defaultFileName); @@ -73,7 +73,6 @@ public async Task Upload(string uploadId, IFormFile file) var actualMimeType = file.ContentType.NullIfEmpty() ?? defaultContentType ?? "application/octet-stream"; var actualFileName = file.FileName.NullIfEmpty() ?? defaultFileName ?? "upload"; - // Note: IFormFile.OpenReadStream() returns a Stream that's valid during reqnuest var fileUpload = new FileUpload( FileName: actualFileName, ContentType: actualMimeType, @@ -81,7 +80,8 @@ public async Task Upload(string uploadId, IFormFile file) Stream: file.OpenReadStream() ); - await handler(fileUpload); + var disposable = await handler(fileUpload); + disposable?.Dispose(); // Automatically dispose CancellationTokenSource or other resources return new OkResult(); } @@ -94,7 +94,7 @@ public void Dispose() public interface IUploadService { - (IDisposable cleanup, string url) AddUpload(Func handler, string? defaultContentType = null, string? defaultFileName = null); + (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/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index 3c22f61a4f..7d4ed790a9 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -26,7 +26,7 @@ public enum FileInputStatus /// /// Represents a file uploaded through a file input control. /// -public record FileInput : FileBase +public record FileInput : FileBase, IDisposable { public FileInput() { @@ -52,6 +52,29 @@ public FileInput(FileUpload upload) : this(upload.FileName!, upload.ContentType! /// Gets the current state of the file upload. /// public FileInputStatus Status { get; set; } = FileInputStatus.Pending; + + [JsonIgnore] + private CancellationTokenSource? CancellationTokenSource { get; set; } = new(); + + [JsonIgnore] + public CancellationToken CancellationToken => CancellationTokenSource?.Token ?? CancellationToken.None; + + public void CancelUpload() + { + try + { + CancellationTokenSource?.Cancel(); + } + catch (Exception) + { + //ignore + } + } + + public void Dispose() + { + CancellationTokenSource?.Dispose(); + } } /// From fed0600bce551ce9994b5e525a3f52c5d290d268 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 20:24:12 +0100 Subject: [PATCH 09/52] refactor(file): replace FileInput with FileUpload model Replaces FileInput and FileBase with new FileUpload record and FileUploadStatus enum. Updates all usages, validation, and APIs to use FileUpload. Refactors file upload handling to use async delegates with stream and cancellation support. Updates docs and tests for new model. --- .../Docs/01_Onboarding/02_Concepts/Prompts.md | 43 ------ .../Docs/01_Onboarding/02_Concepts/Uploads.md | 45 ++++--- .../Docs/01_Onboarding/02_Concepts/Widgets.md | 4 +- .../02_Widgets/02_Inputs/AudioRecorder.md | 6 +- .../Docs/02_Widgets/02_Inputs/File.md | 41 +++--- Ivy.Docs.Tools/MarkdownConverter.cs | 8 ++ Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 82 +++++++----- .../Apps/Widgets/Inputs/FileInputApp.cs | 43 +++--- Ivy.Test/ConvertJsonNodeTests.cs | 3 +- Ivy.Test/FileInputValidationTests.cs | 57 ++++---- Ivy/Core/Models/FileBase.cs | 24 ---- Ivy/Hooks/UseUpload.cs | 27 +--- Ivy/Services/UploadService.cs | 97 +++++++++++--- Ivy/Views/Alerts/AlertExtensions.cs | 3 +- Ivy/Views/Forms/FormBuilder.cs | 3 +- Ivy/Widgets/Inputs/FileInput.cs | 124 ++---------------- Ivy/Widgets/Inputs/FileInputValidation.cs | 11 +- 17 files changed, 272 insertions(+), 349 deletions(-) delete mode 100644 Ivy/Core/Models/FileBase.cs diff --git a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Prompts.md b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Prompts.md index aeb95c493e..5255fc9a51 100644 --- a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Prompts.md +++ b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Prompts.md @@ -96,49 +96,6 @@ public class RenameView : ViewBase } ``` -### File Selection Prompts - -Handle file selection through dialogs with file inputs: - -```csharp demo-tabs -public record UploadRequest -{ - public IEnumerable Files { get; set; } = Array.Empty(); -} - -public class UploadView : ViewBase -{ - public override object? Build() - { - var client = this.UseService(); - var isOpen = this.UseState(false); - var uploadData = this.UseState(new UploadRequest()); - - this.UseEffect(() => { - if (!isOpen.Value && uploadData.Value.Files.Any()) - { - // Files would be uploaded here - client.Toast($"Selected {uploadData.Value.Files.Count()} file(s)", "Success"); - } - }, [isOpen]); - - return Layout.Vertical( - new Button( - "Upload File", - onClick: _ => isOpen.Set(true) - ), - isOpen.Value ? uploadData.ToForm() - .Builder(e => e.Files, e => e.ToFileInput().Accept(".pdf,.doc,.docx")) - .Label(e => e.Files, "Select Files:") - .ToDialog(isOpen, - title: "Upload Files", - submitTitle: "Upload" - ) : null - ); - } -} -``` - ### Custom Prompts Create custom dialogs with multiple inputs: 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 85e950b116..1a9adb4907 100644 --- a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md +++ b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md @@ -6,6 +6,8 @@ searchHints: - drag-drop - attachments - images +imports: + - Ivy.Services --- # Uploads @@ -23,8 +25,8 @@ public class FileUploadView : ViewBase { public override object? Build() { - var files = UseState(() => null); - var uploadUrl = this.UseUpload(fileUpload => { }); + var files = UseState(() => null); + var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => { }); return files.ToFileInput(uploadUrl, "Choose a file"); } @@ -47,21 +49,22 @@ var client = UseService(); // 1. Create upload handler - returns state with // URL like "/upload/{connectionId}/{uploadId}" var uploadUrl = this.UseUpload( - fileUpload => { + async (fileUpload, stream, cancellationToken) => { // This handler is called when a file is uploaded - // Access file metadata: fileUpload.Name, fileUpload.Type, fileUpload.Size, fileUpload.Stream - client.Toast($"Received {fileUpload.Size} bytes", "File Uploaded"); + // Access file metadata: fileUpload.FileName, fileUpload.ContentType, fileUpload.Length + // Access file content via the stream parameter + client.Toast($"Received {fileUpload.Length} bytes", "File Uploaded"); } // mimeType and fileName are optional parameters with defaults from the uploaded file ); // 2. Create state to hold file information -var files = UseState(() => null); +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 FileUpload record +// - Calls your handler with the FileUpload record and stream files.ToFileInput(uploadUrl, "Choose Files") ``` @@ -75,11 +78,11 @@ public class UploadWithStatusView : ViewBase public override object? Build() { var client = UseService(); - var files = UseState(() => null); + var files = UseState(() => null); var uploadUrl = this.UseUpload( - fileUpload => { + async (fileUpload, stream, cancellationToken) => { try { - client.Toast($"Successfully uploaded {fileUpload.Size} bytes", "Upload Complete"); + client.Toast($"Successfully uploaded {fileUpload.Length} bytes", "Upload Complete"); } catch (Exception ex) { client.Toast(ex); } @@ -102,17 +105,17 @@ public class ValidatedUploadView : ViewBase { var client = UseService(); var error = UseState(() => null); - var files = UseState(() => null); + var files = UseState(() => null); var uploadUrl = this.UseUpload( - fileUpload => { - if (fileUpload.Size > 2 * 1024 * 1024) // 2MB limit + async (fileUpload, stream, cancellationToken) => { + if (fileUpload.Length > 2 * 1024 * 1024) // 2MB limit { error.Set("File size must be less than 2MB"); return; } error.Set((string?)null); // Process uploaded file - client.Toast($"Image uploaded successfully ({fileUpload.Size} bytes)", "Success"); + client.Toast($"Image uploaded successfully ({fileUpload.Length} bytes)", "Success"); }, "image/jpeg" // Optional: specify expected MIME type ); @@ -152,18 +155,18 @@ public class ImageUploadView : ViewBase { var client = UseService(); var preview = UseState(() => null); - var files = UseState(() => null); + var files = UseState(() => null); var uploadUrl = this.UseUpload( - fileUpload => { + async (fileUpload, stream, cancellationToken) => { // Convert stream to bytes for preview using var memoryStream = new MemoryStream(); - fileUpload.Stream.CopyTo(memoryStream); + await stream.CopyToAsync(memoryStream, cancellationToken); var fileBytes = memoryStream.ToArray(); // Create preview URL from uploaded bytes preview.Set($"data:image/jpeg;base64,{Convert.ToBase64String(fileBytes)}"); // Process uploaded file - client.Toast($"Image uploaded successfully ({fileUpload.Size} bytes)", "Success"); + client.Toast($"Image uploaded successfully ({fileUpload.Length} bytes)", "Success"); }, "image/jpeg" ); @@ -195,11 +198,11 @@ public class MultiFileUploadView : ViewBase { var client = UseService(); var uploadedFiles = UseState(() => new List()); - var newFiles = UseState?>(() => null); + var newFiles = UseState?>(() => null); var uploadUrl = this.UseUpload( - fileUpload => { + async (fileUpload, stream, cancellationToken) => { // Process uploaded file - client.Toast($"File uploaded ({fileUpload.Size} bytes)", "Upload Complete"); + client.Toast($"File uploaded ({fileUpload.Length} bytes)", "Upload Complete"); // Add to list of uploaded files uploadedFiles.Set(uploadedFiles.Value.Append($"File {uploadedFiles.Value.Count + 1}").ToList()); } diff --git a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Widgets.md b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Widgets.md index e784d3aca3..9332888f3a 100644 --- a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Widgets.md +++ b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Widgets.md @@ -6,6 +6,8 @@ searchHints: - elements - widgets - primitives +imports: + - Ivy.Services --- # Widgets @@ -153,7 +155,7 @@ public class InputWidgetsDemo : ViewBase var dateRangeState = UseState<(DateOnly?, DateOnly?)>((null, null)); var colorState = UseState("#00cc92"); var codeState = UseState("var x = 10;"); - var fileState = UseState((FileInput?)null); + var fileState = UseState((FileUpload?)null); var feedbackState = UseState(4); var selectState = UseState(""); var asyncSelectState = UseState((string?)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 80663706b6..5a299f1b34 100644 --- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md +++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md @@ -6,6 +6,8 @@ searchHints: - audio - capture - sound +imports: + - Ivy.Services --- # Audio Recorder @@ -26,9 +28,9 @@ public class BasicAudioRecorderDemo : ViewBase public override object? Build() { var uploadUrl = this.UseUpload( - fileUpload => { + async (fileUpload, stream, cancellationToken) => { // Process uploaded file - Console.WriteLine($"Received {fileUpload.Size} bytes"); + Console.WriteLine($"Received {fileUpload.Length} bytes"); } ); diff --git a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md index 0808218eff..2db19af97a 100644 --- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md +++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md @@ -6,6 +6,8 @@ searchHints: - drag-drop - browse - files +imports: + - Ivy.Services --- # FileInput @@ -24,16 +26,16 @@ Here's a simple example of a `FileInput` that allows users to select files: public class BasicFileInputDemo : ViewBase { public override object? Build() - { - var fileState = this.UseState((FileInput?)null); - var selected = fileState.Value?.Name; + { + var fileState = this.UseState((FileUpload?)null); + var selected = fileState.Value?.FileName; return Layout.Vertical() | fileState.ToFileInput() .Placeholder("Select a file") .Accept(".txt,.pdf,.cs") - | Text.Large(selected); - } -} + | Text.Large(selected); + } +} ``` To create a file upload input, `ToFileInput` is the recommended function. @@ -46,21 +48,21 @@ section. The following demo showcases this. ```csharp demo-below public class FileDropDemo : ViewBase -{ +{ public override object? Build() - { - var fileState = this.UseState((FileInput?)null); - var fileStates = this.UseState((IEnumerable?)null); + { + var fileState = this.UseState((FileUpload?)null); + var fileStates = this.UseState((IEnumerable?)null); return Layout.Vertical() | fileState.ToFileInput().Variant(FileInputs.Drop) | fileStates.ToFileInput().Variant(FileInputs.Drop); } -} - +} + ``` -Multiple file selection is automatically enabled when you use a collection type (`IEnumerable`, `FileInput[]`, `List`, etc.) as your state. You do **not** need to explicitly set a `.Multiple()` property. +Multiple file selection is automatically enabled when you use a collection type (`IEnumerable`, `FileUpload[]`, `List`, etc.) as your state. You do **not** need to explicitly set a `.Multiple()` property. ## Styling @@ -76,13 +78,13 @@ public class FileInputDisabledDemo : ViewBase { public override object? Build() { - var fileState = this.UseState((FileInput?)null); + var fileState = this.UseState((FileUpload?)null); return fileState.ToFileInput() .Placeholder("Select a file") .Accept(".jpg,.png") .Disabled(); } -} +} ``` @@ -99,14 +101,13 @@ Multiple File Selection public class MultiFileSelectionDemo : ViewBase { public override object? Build() - { - - var filesState = UseState>([]); + { + var filesState = UseState>([]); var selected = UseState(""); if(filesState.Value.Count() > 0) { - selected.Set($"Files selected: {string.Join(", ", filesState.Value?.Select(f => f.Name) ?? new string[0])}"); - } + selected.Set($"Files selected: {string.Join(", ", filesState.Value?.Select(f => f.FileName) ?? new string[0])}"); + } return Layout.Vertical() | filesState.ToFileInput() | Text.Large(selected); diff --git a/Ivy.Docs.Tools/MarkdownConverter.cs b/Ivy.Docs.Tools/MarkdownConverter.cs index 0c49626fbf..cacafaf39e 100644 --- a/Ivy.Docs.Tools/MarkdownConverter.cs +++ b/Ivy.Docs.Tools/MarkdownConverter.cs @@ -27,6 +27,7 @@ public class AppMeta public string? Prepare { get; set; } = ""; public bool GroupExpanded { get; set; } = false; public List? SearchHints { get; set; } + public List? Imports { get; set; } } static AppMeta ParseYamlAppMeta(string yaml) @@ -98,6 +99,13 @@ public static async Task ConvertAsync(string name, string relativePath, string a codeBuilder.AppendLine("using Ivy.Core;"); codeBuilder.AppendLine("using static Ivy.Views.Layout;"); codeBuilder.AppendLine("using static Ivy.Views.Text;"); + if (appMeta.Imports != null) + { + foreach (var import in appMeta.Imports) + { + codeBuilder.AppendLine($"using {import};"); + } + } codeBuilder.AppendLine(); codeBuilder.AppendLine($"namespace {@namespace};"); codeBuilder.AppendLine(); diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index 2002760936..0a931a2a07 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Ivy.Hooks; +using Ivy.Services; using Ivy.Shared; using Ivy.Views.Builders; using Ivy.Views.Tables; @@ -23,58 +24,56 @@ public class SingleFileUpload : ViewBase { public override object? Build() { - var selectedFile = UseState(); + var selectedFile = UseState(); var uploadedBytes = UseState(); - var uploadUrl = this.UseUpload(async (fileUpload) => + var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => { - var currentFile = new FileInput(fileUpload); + var uploadId = Guid.NewGuid(); + var currentFile = fileUpload with { Id = uploadId }; try { selectedFile.Set(currentFile); - var totalBytes = fileUpload.Length; + var totalBytes = currentFile.Length; var processedBytes = 0L; var buffer = new byte[8192]; // 8KB chunks using var memoryStream = new MemoryStream(); - selectedFile.SetStatus(FileInputStatus.Loading); + selectedFile.SetStatus(FileUploadStatus.Loading); int bytesRead; - while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length, currentFile.CancellationToken)) > 0) + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) { - currentFile.CancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - await memoryStream.WriteAsync(buffer, 0, bytesRead, currentFile.CancellationToken); + await memoryStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); processedBytes += bytesRead; var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0; selectedFile.SetProgress(progress); //Simulate this being slower - await Task.Delay(50, currentFile.CancellationToken); + await Task.Delay(50, cancellationToken); } uploadedBytes.Set(memoryStream.ToArray()); - selectedFile.SetStatus(FileInputStatus.Finished); + selectedFile.SetStatus(FileUploadStatus.Finished); } catch (OperationCanceledException) { - selectedFile.SetStatus(FileInputStatus.Aborted); + selectedFile.SetStatus(FileUploadStatus.Aborted); } catch (Exception) { - selectedFile.SetStatus(FileInputStatus.Failed); + selectedFile.SetStatus(FileUploadStatus.Failed); throw; } - - return currentFile; //IDisposable }); - void OnDelete(Guid fileId) + void OnDelete(object fileId) { - selectedFile.Value?.CancelUpload(); selectedFile.Default(); uploadedBytes.Default(); } @@ -89,38 +88,60 @@ void OnDelete(Guid fileId) } } +// public class MemoryStreamUploadHandler() : IUploadHandler +// { +// public MemoryStreamUploadHandler(IState state) +// { +// } +// +// public MemoryStreamUploadHandler(IState state) +// { +// } +// +// public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) +// { +// //todo: +// } +// } +// +// public interface IUploadHandler +// { +// Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken); +// } + public class MultipleFilesUpload : ViewBase { public override object? Build() { - var selectedFiles = UseState(ImmutableArray.Create()); + var selectedFiles = UseState(ImmutableArray.Create()); var uploadCount = UseState(0); - var uploadUrl = this.UseUpload(async (fileUpload) => + var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => { - var currentFile = new FileInput(fileUpload); + var uploadId = Guid.NewGuid(); + var currentFile = fileUpload with { Id = uploadId }; try { selectedFiles.Set(files => files.Add(currentFile)); - var totalBytes = fileUpload.Length; + var totalBytes = currentFile.Length; var processedBytes = 0L; var buffer = new byte[8192]; // 8KB chunks using var memoryStream = new MemoryStream(); // Update to Loading state - var loadingFile = currentFile with { Status = FileInputStatus.Loading }; + var loadingFile = currentFile with { Status = FileUploadStatus.Loading }; selectedFiles.Set(files => files.Replace(currentFile, loadingFile)); currentFile = loadingFile; int bytesRead; - while ((bytesRead = await fileUpload.Stream.ReadAsync(buffer, 0, buffer.Length, currentFile.CancellationToken)) > 0) + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) { - currentFile.CancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - await memoryStream.WriteAsync(buffer, 0, bytesRead, currentFile.CancellationToken); + await memoryStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); processedBytes += bytesRead; var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0; @@ -130,11 +151,11 @@ public class MultipleFilesUpload : ViewBase currentFile = updatedFile; //Simulate this being slower - await Task.Delay(50, currentFile.CancellationToken); + await Task.Delay(50, cancellationToken); } // Mark as finished - thread-safe atomic operation - var finishedFile = currentFile with { Status = FileInputStatus.Finished }; + var finishedFile = currentFile with { Status = FileUploadStatus.Finished }; selectedFiles.Set(files => files.Replace(currentFile, finishedFile)); uploadCount.Set(count => count + 1); @@ -142,18 +163,15 @@ public class MultipleFilesUpload : ViewBase catch (OperationCanceledException) { // Upload was aborted by user - var abortedFile = currentFile with { Status = FileInputStatus.Aborted }; + var abortedFile = currentFile with { Status = FileUploadStatus.Aborted }; selectedFiles.Set(files => files.Replace(currentFile, abortedFile)); } - - return currentFile; //IDisposable - automatically disposed by UploadService }); - void OnDelete(Guid fileId) + void OnDelete(object fileId) { - var file = selectedFiles.Value.FirstOrDefault(f => f.Id == fileId); + var file = selectedFiles.Value.FirstOrDefault(f => f.Id.Equals(fileId)); if (file == null) return; - file.CancelUpload(); selectedFiles.Set(files => files.Remove(file)); } diff --git a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs index 1785551cd9..49dfdf9393 100644 --- a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs +++ b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs @@ -1,4 +1,5 @@ -using Ivy.Shared; +using Ivy.Services; +using Ivy.Shared; using Ivy.Views.Builders; using Ivy.Views.Forms; @@ -10,29 +11,29 @@ public class FileInputApp : SampleBase protected override object? BuildSample() { // Mock file for 'With Value' example - var mockFile = new FileInput("example.txt", "text/plain", 12345); - - var singleFile = UseState(() => null); - var singleFileWithValue = UseState(() => mockFile); - var multipleFiles = UseState?>(() => null); - var multipleFilesWithValue = UseState?>(() => new[] { mockFile }); - var disabledFile = UseState(() => null); - var invalidFile = UseState(() => null); - var placeholderFile = UseState(() => null); - var limitedFiles = UseState?>(() => null); - var textFiles = UseState?>(() => null); - var pdfFiles = UseState?>(() => null); - var imageFiles = UseState?>(() => null); - var singleSizeFile = UseState(() => null); - var multipleSizeFiles = UseState?>(() => null); - - var onBlurState = UseState(() => null); + var mockFile = new FileUpload { Id = Guid.NewGuid(), FileName = "example.txt", ContentType = "text/plain", Length = 12345 }; + + var singleFile = UseState(() => null); + var singleFileWithValue = UseState(() => mockFile); + var multipleFiles = UseState?>(() => null); + var multipleFilesWithValue = UseState?>(() => new[] { mockFile }); + var disabledFile = UseState(() => null); + var invalidFile = UseState(() => null); + var placeholderFile = UseState(() => null); + var limitedFiles = UseState?>(() => null); + var textFiles = UseState?>(() => null); + var pdfFiles = UseState?>(() => null); + var imageFiles = UseState?>(() => null); + var singleSizeFile = UseState(() => null); + var multipleSizeFiles = UseState?>(() => null); + + var onBlurState = UseState(() => null); var onBlurLabel = UseState(""); // Validation examples var validationError = UseState(() => null); - var validatedFiles = UseState?>(() => null); - var singleFileWithValidation = UseState(() => null); + var validatedFiles = UseState?>(() => null); + var singleFileWithValidation = UseState(() => null); var dataBinding = Layout.Grid().Columns(3) | Text.InlineCode("FileInput") @@ -218,7 +219,7 @@ public class FileInputApp : SampleBase public class SizingExample : ViewBase { - public record FileModel(FileInput? ProfilePhoto, FileInput? Document, FileInput? Certificate); + public record FileModel(FileUpload? ProfilePhoto, FileUpload? Document, FileUpload? Certificate); public override object? Build() { diff --git a/Ivy.Test/ConvertJsonNodeTests.cs b/Ivy.Test/ConvertJsonNodeTests.cs index 9301cca003..5e592877e7 100644 --- a/Ivy.Test/ConvertJsonNodeTests.cs +++ b/Ivy.Test/ConvertJsonNodeTests.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using Ivy.Services; namespace Ivy.Test; @@ -94,7 +95,7 @@ public void ConvertFileInput() } """); - var result = (FileInput)Core.Utils.ConvertJsonNode(json!, typeof(FileInput))!; + var result = (FileUpload)Core.Utils.ConvertJsonNode(json!, typeof(FileUpload))!; Assert.Equal("myfile.txt", result.FileName); Assert.Equal("text/plain", result.ContentType); diff --git a/Ivy.Test/FileInputValidationTests.cs b/Ivy.Test/FileInputValidationTests.cs index 7b869e6160..7e6b07bf3f 100644 --- a/Ivy.Test/FileInputValidationTests.cs +++ b/Ivy.Test/FileInputValidationTests.cs @@ -1,3 +1,4 @@ +using Ivy.Services; using Ivy.Widgets.Inputs; using Ivy.Core.Hooks; @@ -9,7 +10,7 @@ public class FileInputValidationTests public void ValidateFileCount_WithNullMaxFiles_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt"), CreateTestFile("test2.txt"), @@ -28,7 +29,7 @@ public void ValidateFileCount_WithNullMaxFiles_ReturnsSuccess() public void ValidateFileCount_WithValidCount_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt"), CreateTestFile("test2.txt") @@ -46,7 +47,7 @@ public void ValidateFileCount_WithValidCount_ReturnsSuccess() public void ValidateFileCount_WithExactCount_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt"), CreateTestFile("test2.txt") @@ -64,7 +65,7 @@ public void ValidateFileCount_WithExactCount_ReturnsSuccess() public void ValidateFileCount_WithTooManyFiles_ReturnsError() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt"), CreateTestFile("test2.txt"), @@ -83,7 +84,7 @@ public void ValidateFileCount_WithTooManyFiles_ReturnsError() public void ValidateFileCount_WithSingleFileLimit_ReturnsError() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt"), CreateTestFile("test2.txt") @@ -101,7 +102,7 @@ public void ValidateFileCount_WithSingleFileLimit_ReturnsError() public void ValidateFileTypes_WithNullAccept_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.txt", "text/plain"), CreateTestFile("test.pdf", "application/pdf") @@ -119,7 +120,7 @@ public void ValidateFileTypes_WithNullAccept_ReturnsSuccess() public void ValidateFileTypes_WithEmptyAccept_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.txt", "text/plain"), CreateTestFile("test.pdf", "application/pdf") @@ -137,7 +138,7 @@ public void ValidateFileTypes_WithEmptyAccept_ReturnsSuccess() public void ValidateFileTypes_WithValidExtension_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.txt", "text/plain"), CreateTestFile("test2.txt", "text/plain") @@ -155,7 +156,7 @@ public void ValidateFileTypes_WithValidExtension_ReturnsSuccess() public void ValidateFileTypes_WithValidExtensions_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.txt", "text/plain"), CreateTestFile("test.pdf", "application/pdf") @@ -173,7 +174,7 @@ public void ValidateFileTypes_WithValidExtensions_ReturnsSuccess() public void ValidateFileTypes_WithInvalidExtension_ReturnsError() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.txt", "text/plain"), CreateTestFile("test.pdf", "application/pdf") @@ -191,7 +192,7 @@ public void ValidateFileTypes_WithInvalidExtension_ReturnsError() public void ValidateFileTypes_WithMimeTypeWildcard_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.jpg", "image/jpeg"), CreateTestFile("test.png", "image/png") @@ -209,7 +210,7 @@ public void ValidateFileTypes_WithMimeTypeWildcard_ReturnsSuccess() public void ValidateFileTypes_WithExactMimeType_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.txt", "text/plain") }; @@ -226,7 +227,7 @@ public void ValidateFileTypes_WithExactMimeType_ReturnsSuccess() public void ValidateFileTypes_WithInvalidMimeType_ReturnsError() { // Arrange - var files = new List + var files = new List { CreateTestFile("test.txt", "text/plain"), CreateTestFile("test.pdf", "application/pdf") @@ -310,16 +311,16 @@ public void ValidateFileType_WithFileWithoutExtension_ReturnsError() Assert.Equal("Invalid file type: testfile. Allowed types: .txt", result.ErrorMessage); } - private static FileInput CreateTestFile(string name, string type = "text/plain") + private static FileUpload CreateTestFile(string name, string type = "text/plain") { - return new FileInput(name, type, 12345); + return new FileUpload { Id = Guid.NewGuid(), FileName = name, ContentType = type, Length = 12345 }; } [Fact] public void FileInput_ValidateValue_WithNullValue_ReturnsSuccess() { // Arrange - var fileInput = new FileInput((FileInput?)null, "Test"); + var fileInput = new FileInput((FileUpload?)null, "Test"); // Act var result = fileInput.ValidateValue(null); @@ -334,7 +335,7 @@ public void FileInput_ValidateValue_WithValidSingleFile_ReturnsSuccess() { // Arrange var file = CreateTestFile("test.txt", "text/plain"); - var fileInput = new FileInput((FileInput?)null, "Test") with { Accept = ".txt" }; + var fileInput = new FileInput((FileUpload?)null, "Test") with { Accept = ".txt" }; // Act var result = fileInput.ValidateValue(file); @@ -349,7 +350,7 @@ public void FileInput_ValidateValue_WithInvalidSingleFile_ReturnsError() { // Arrange var file = CreateTestFile("test.pdf", "application/pdf"); - var fileInput = new FileInput((FileInput?)null, "Test") with { Accept = ".txt" }; + var fileInput = new FileInput((FileUpload?)null, "Test") with { Accept = ".txt" }; // Act var result = fileInput.ValidateValue(file); @@ -363,12 +364,12 @@ public void FileInput_ValidateValue_WithInvalidSingleFile_ReturnsError() public void FileInput_ValidateValue_WithValidMultipleFiles_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt", "text/plain"), CreateTestFile("test2.txt", "text/plain") }; - var fileInput = new FileInput?>((IEnumerable?)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); @@ -382,13 +383,13 @@ public void FileInput_ValidateValue_WithValidMultipleFiles_ReturnsSuccess() public void FileInput_ValidateValue_WithTooManyFiles_ReturnsError() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt", "text/plain"), CreateTestFile("test2.txt", "text/plain"), CreateTestFile("test3.txt", "text/plain") }; - var fileInput = new FileInput?>((IEnumerable?)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); @@ -402,12 +403,12 @@ public void FileInput_ValidateValue_WithTooManyFiles_ReturnsError() public void FileInput_ValidateValue_WithInvalidFileTypes_ReturnsError() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt", "text/plain"), CreateTestFile("test2.pdf", "application/pdf") }; - var fileInput = new FileInput?>((IEnumerable?)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); @@ -421,12 +422,12 @@ public void FileInput_ValidateValue_WithInvalidFileTypes_ReturnsError() public void FileInput_ValidateValue_WithMimeTypeWildcard_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.jpg", "image/jpeg"), CreateTestFile("test2.png", "image/png") }; - var fileInput = new FileInput?>((IEnumerable?)null, "Test") with { Accept = "image/*" }; + var fileInput = new FileInput?>((IEnumerable?)null, "Test") with { Accept = "image/*" }; // Act var result = fileInput.ValidateValue(files); @@ -440,12 +441,12 @@ public void FileInput_ValidateValue_WithMimeTypeWildcard_ReturnsSuccess() public void FileInput_ValidateValue_WithNoAcceptOrMaxFiles_ReturnsSuccess() { // Arrange - var files = new List + var files = new List { CreateTestFile("test1.txt", "text/plain"), CreateTestFile("test2.pdf", "application/pdf") }; - var fileInput = new FileInput?>((IEnumerable?)null, "Test"); + var fileInput = new FileInput?>((IEnumerable?)null, "Test"); // Act var result = fileInput.ValidateValue(files); diff --git a/Ivy/Core/Models/FileBase.cs b/Ivy/Core/Models/FileBase.cs deleted file mode 100644 index 2f88e8bd4b..0000000000 --- a/Ivy/Core/Models/FileBase.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Ivy.Core.Models; - -public abstract record FileBase -{ - protected FileBase() - { - } - - protected FileBase(string FileName, string ContentType, long Length) - { - this.FileName = FileName; - this.ContentType = ContentType; - this.Length = Length; - } - - /// Gets the name of the uploaded file including its extension. - public string? FileName { get; set; } - - /// Gets the MIME type of the uploaded file. - public string? ContentType { get; set; } - - /// Gets the size of the uploaded file in bytes. - public long Length { get; set; } -} diff --git a/Ivy/Hooks/UseUpload.cs b/Ivy/Hooks/UseUpload.cs index 67382d5d80..3f3343e530 100644 --- a/Ivy/Hooks/UseUpload.cs +++ b/Ivy/Hooks/UseUpload.cs @@ -6,22 +6,10 @@ namespace Ivy.Hooks; public static class UseUploadExtensions { - public static IState UseUpload(this TView view, Action handler, string? defaultContentType = null, string? defaultFileName = null) where TView : ViewBase => + public static IState UseUpload(this TView view, UploadDelegate 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? defaultContentType = null, string? defaultFileName = null) where TView : ViewBase => - view.Context.UseUpload(handler, defaultContentType, defaultFileName); - - 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? defaultContentType = null, string? defaultFileName = null) => - context.UseUpload(upload => { handler(upload); return Task.FromResult(null); }, defaultContentType, defaultFileName); - - public static IState UseUpload(this IViewContext context, Func handler, string? defaultContentType = null, string? defaultFileName = null) => - context.UseUpload(async upload => { await handler(upload); return null; }, defaultContentType, defaultFileName); - - public static IState UseUpload(this IViewContext context, Func> handler, string? defaultContentType = null, string? defaultFileName = null) + public static IState UseUpload(this IViewContext context, UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null) { var url = context.UseState(); var uploadService = context.UseService(); @@ -41,20 +29,19 @@ public static class UseUploadExtensions 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.FromResult(null); }, mimeType, fileName); + context.UseUpload(async (bytes) => { handler(bytes); await Task.CompletedTask; }, mimeType, fileName); public static IState UseUpload(this IViewContext context, Func handler, string mimeType, string fileName) { - // Adapt byte[] handler to FileUpload handler - async Task AdaptedHandler(FileUpload fileUpload) + // Adapt byte[] handler to UploadDelegate + async Task AdaptedHandler(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) { using var memoryStream = new MemoryStream(); - await fileUpload.Stream.CopyToAsync(memoryStream); + await stream.CopyToAsync(memoryStream, cancellationToken); var bytes = memoryStream.ToArray(); await handler(bytes); - return null; } - return context.UseUpload((Func>)AdaptedHandler, mimeType, fileName); + 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 5fae3a612a..5b5ef01272 100644 --- a/Ivy/Services/UploadService.cs +++ b/Ivy/Services/UploadService.cs @@ -1,14 +1,76 @@ using System.Collections.Concurrent; using System.Reactive.Disposables; -using Ivy.Core.Models; +using System.Text.Json.Serialization; +using Ivy.Core.Hooks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace Ivy.Services; -public record FileUpload(string FileName, string ContentType, long Length, Stream Stream) - : FileBase(FileName, ContentType, Length); +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FileUploadStatus +{ + Pending, + Aborted, + Loading, + Failed, + Finished +} + +/// +/// Represents a file uploaded through a file input control. +/// +public record FileUpload +{ + /// Gets the identifier for this file upload, set by the client. + public object? Id { get; set; } + + /// Gets the name of the uploaded file including its extension. + public string FileName { get; init; } = string.Empty; + + /// Gets the MIME type of the uploaded file. + public string ContentType { get; init; } = string.Empty; + + /// Gets the size of the uploaded file in bytes. + public long Length { get; init; } + + /// + /// Value from 0.0 to 1.0 indicating upload progress. + /// + public float Progress { get; set; } = 0.0f; + + /// + /// Gets the current state of the file upload. + /// + public FileUploadStatus Status { get; set; } = FileUploadStatus.Pending; +} + +public static class FileUploadExtensions +{ + public static void SetProgress(this IState fileInputState, float progress) + { + var file = fileInputState.Value; + if (file != null) + { + fileInputState.Set(file with { Progress = progress }); + } + } + + public static void SetStatus(this IState fileInputState, FileUploadStatus status) + { + var file = fileInputState.Value; + if (file != null) + { + fileInputState.Set(file with { Status = status }); + } + } +} + +/// +/// Delegate for handling file uploads with stream and cancellation support. +/// +public delegate Task UploadDelegate(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken); [ApiController] [Route("upload")] @@ -41,16 +103,18 @@ 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 _uploads = new(); - public (IDisposable cleanup, string url) AddUpload(Func> handler, string? defaultContentType = null, string? defaultFileName = null) + public (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null) { var uploadId = Guid.NewGuid(); - _uploads[uploadId] = (handler, defaultContentType, defaultFileName); + var cts = new CancellationTokenSource(); + _uploads[uploadId] = (handler, cts, defaultContentType, defaultFileName); var cleanup = Disposable.Create(() => { - _uploads.TryRemove(uploadId, out _); + _uploads.TryRemove(uploadId, out var upload); + upload.cts?.Dispose(); }); return (cleanup, $"/upload/{connectionId}/{uploadId}"); @@ -63,7 +127,7 @@ public async Task Upload(string uploadId, IFormFile file) return new BadRequestObjectResult($"Invalid or unknown uploadId: '{uploadId}'."); } - var (handler, defaultContentType, defaultFileName) = upload; + var (handler, cts, defaultContentType, defaultFileName) = upload; if (file.Length == 0) { @@ -73,15 +137,14 @@ public async Task Upload(string uploadId, IFormFile file) var actualMimeType = file.ContentType.NullIfEmpty() ?? defaultContentType ?? "application/octet-stream"; var actualFileName = file.FileName.NullIfEmpty() ?? defaultFileName ?? "upload"; - var fileUpload = new FileUpload( - FileName: actualFileName, - ContentType: actualMimeType, - Length: file.Length, - Stream: file.OpenReadStream() - ); + var fileUpload = new FileUpload + { + FileName = actualFileName, + ContentType = actualMimeType, + Length = file.Length + }; - var disposable = await handler(fileUpload); - disposable?.Dispose(); // Automatically dispose CancellationTokenSource or other resources + await handler(fileUpload, file.OpenReadStream(), cts.Token); return new OkResult(); } @@ -94,7 +157,7 @@ public void Dispose() public interface IUploadService { - (IDisposable cleanup, string url) AddUpload(Func> handler, string? defaultContentType = null, string? defaultFileName = null); + (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null); Task Upload(string uploadId, IFormFile file); } \ No newline at end of file diff --git a/Ivy/Views/Alerts/AlertExtensions.cs b/Ivy/Views/Alerts/AlertExtensions.cs index 0088d44d3e..6448672c2f 100644 --- a/Ivy/Views/Alerts/AlertExtensions.cs +++ b/Ivy/Views/Alerts/AlertExtensions.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Ivy.Core; +using Ivy.Services; using Ivy.Views.Forms; namespace Ivy.Views.Alerts; @@ -24,7 +25,7 @@ public record PromptValueTypeWrapper([Required] T? Value); public override object? Build() { if ( - typeof(T) != typeof(FileInput) + typeof(T) != typeof(FileUpload) && !Utils.IsSimpleType(typeof(T))) { throw new NotSupportedException(); diff --git a/Ivy/Views/Forms/FormBuilder.cs b/Ivy/Views/Forms/FormBuilder.cs index ed89b5ea10..cefefe8238 100644 --- a/Ivy/Views/Forms/FormBuilder.cs +++ b/Ivy/Views/Forms/FormBuilder.cs @@ -4,6 +4,7 @@ using Ivy.Core.Helpers; using Ivy.Core.Hooks; using Ivy.Hooks; +using Ivy.Services; using Ivy.Shared; using Ivy.Widgets.Inputs; @@ -198,7 +199,7 @@ private void _Scaffold() { Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; - if (type == typeof(FileInput)) + if (type == typeof(FileUpload)) { return (state) => state.ToFileInput().Size(Size); } diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index 7d4ed790a9..f947c2357e 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -5,7 +5,6 @@ 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; @@ -13,70 +12,6 @@ // ReSharper disable once CheckNamespace namespace Ivy; -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum FileInputStatus -{ - Pending, - Aborted, - Loading, - Failed, - Finished -} - -/// -/// Represents a file uploaded through a file input control. -/// -public record FileInput : FileBase, IDisposable -{ - public FileInput() - { - } - - public FileInput(string FileName, string ContentType, long Length) - : base(FileName, ContentType, Length) - { - } - - 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. - /// - public float Progress { get; set; } - - /// - /// Gets the current state of the file upload. - /// - public FileInputStatus Status { get; set; } = FileInputStatus.Pending; - - [JsonIgnore] - private CancellationTokenSource? CancellationTokenSource { get; set; } = new(); - - [JsonIgnore] - public CancellationToken CancellationToken => CancellationTokenSource?.Token ?? CancellationToken.None; - - public void CancelUpload() - { - try - { - CancellationTokenSource?.Cancel(); - } - catch (Exception) - { - //ignore - } - } - - public void Dispose() - { - CancellationTokenSource?.Dispose(); - } -} - /// /// Defines the visual variants available for file input controls. /// @@ -134,7 +69,7 @@ public abstract record FileInputBase : WidgetBase, IAnyFileInput [Event] public Func, ValueTask>? OnBlur { get; set; } /// Gets or sets the event handler called when a file is deleted (passes FileInput.Id as parameter). - [Event] public Func, ValueTask>? OnDelete { get; set; } + [Event] public Func, ValueTask>? OnDelete { get; set; } /// /// Returns the types that this file input can bind to. @@ -149,12 +84,12 @@ public ValidationResult ValidateValue(object? value) { if (value == null) return ValidationResult.Success(); - if (value is FileInput file) + if (value is FileUpload file) { return FileInputValidation.ValidateFileType(file, Accept); } - if (value is IEnumerable files) + if (value is IEnumerable files) { var filesList = files.ToList(); @@ -243,41 +178,6 @@ public FileInput(string? placeholder = null, bool disabled = false, FileInputs v /// public static class FileInputExtensions { - public static void SetProgress(this IState fileInputState, float progress) - { - var file = fileInputState.Value; - if (file != null) - { - fileInputState.Set(file with { Progress = progress }); - } - } - - public static void SetStatus(this IState fileInputState, FileInputStatus status) - { - var file = fileInputState.Value; - if (file != null) - { - fileInputState.Set(file with { Status = status }); - } - } - - // /// - // /// 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. /// @@ -289,13 +189,13 @@ public static FileInputBase ToFileInput(this IAnyState state, string? placeholde { var type = state.GetStateType(); - //Check that type is FileInput, FileInput? or IEnumerable (including ImmutableArray, List, etc.) - var isCollection = typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string); - var isValid = type == typeof(FileInput) || isCollection; + //Check that type is FileUpload, FileUpload? or IEnumerable (including ImmutableArray, List, etc.) + var isCollection = typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string); + var isValid = type == typeof(FileUpload) || isCollection; if (!isValid) { - throw new Exception("Invalid type for FileInput"); + throw new Exception("Invalid type for FileInput - must be FileUpload or IEnumerable"); } Type genericType = typeof(FileInput<>).MakeGenericType(type); @@ -411,7 +311,7 @@ public static FileInputBase Large(this FileInputBase widget) /// /// The file input widget containing validation rules. /// The file to validate. - public static ValidationResult ValidateFile(this FileInputBase widget, FileInput file) + public static ValidationResult ValidateFile(this FileInputBase widget, FileUpload file) { return FileInputValidation.ValidateFileType(file, widget.Accept); } @@ -421,7 +321,7 @@ public static ValidationResult ValidateFile(this FileInputBase widget, FileInput /// /// The file input widget containing validation rules. /// The files to validate. - public static ValidationResult ValidateFiles(this FileInputBase widget, IEnumerable files) + public static ValidationResult ValidateFiles(this FileInputBase widget, IEnumerable files) { var filesList = files.ToList(); @@ -473,7 +373,7 @@ public static FileInputBase HandleBlur(this FileInputBase widget, Action onBlur) /// The file input to configure. /// The event handler to call when a file is deleted, receives the FileInput.Id. [OverloadResolutionPriority(1)] - public static FileInputBase HandleDelete(this FileInputBase widget, Func, ValueTask> onDelete) + public static FileInputBase HandleDelete(this FileInputBase widget, Func, ValueTask> onDelete) { return widget with { OnDelete = onDelete }; } @@ -483,7 +383,7 @@ public static FileInputBase HandleDelete(this FileInputBase widget, Func /// The file input to configure. /// The event handler to call when a file is deleted, receives the FileInput.Id. - public static FileInputBase HandleDelete(this FileInputBase widget, Action> onDelete) + public static FileInputBase HandleDelete(this FileInputBase widget, Action> onDelete) { return widget.HandleDelete(onDelete.ToValueTask()); } @@ -493,7 +393,7 @@ public static FileInputBase HandleDelete(this FileInputBase widget, Action /// The file input to configure. /// The simple action to perform when a file is deleted, receives the FileInput.Id. - public static FileInputBase HandleDelete(this FileInputBase widget, Action onDelete) + public static FileInputBase HandleDelete(this FileInputBase widget, Action onDelete) { return widget.HandleDelete(e => { onDelete(e.Value); return ValueTask.CompletedTask; }); } diff --git a/Ivy/Widgets/Inputs/FileInputValidation.cs b/Ivy/Widgets/Inputs/FileInputValidation.cs index e95a378b5c..7fdd2b1076 100644 --- a/Ivy/Widgets/Inputs/FileInputValidation.cs +++ b/Ivy/Widgets/Inputs/FileInputValidation.cs @@ -1,3 +1,4 @@ +using Ivy.Services; using System.Text.RegularExpressions; namespace Ivy.Widgets.Inputs; @@ -9,7 +10,7 @@ public static class FileInputValidation /// /// The files to validate /// Maximum number of files allowed - public static ValidationResult ValidateFileCount(IEnumerable files, int? maxFiles) + public static ValidationResult ValidateFileCount(IEnumerable files, int? maxFiles) { if (maxFiles == null) return ValidationResult.Success(); @@ -27,7 +28,7 @@ public static ValidationResult ValidateFileCount(IEnumerable files, i /// /// The files to validate /// The accept pattern (e.g., ".txt,.pdf" or "image/*") - public static ValidationResult ValidateFileTypes(IEnumerable files, string? accept) + public static ValidationResult ValidateFileTypes(IEnumerable files, string? accept) { if (string.IsNullOrWhiteSpace(accept)) return ValidationResult.Success(); @@ -56,7 +57,7 @@ public static ValidationResult ValidateFileTypes(IEnumerable files, s /// /// The file to validate /// The accept pattern - public static ValidationResult ValidateFileType(FileInput file, string? accept) + public static ValidationResult ValidateFileType(FileUpload file, string? accept) { if (string.IsNullOrWhiteSpace(accept)) return ValidationResult.Success(); @@ -78,7 +79,7 @@ private static List ParseAcceptPattern(string accept) .ToList(); } - private static bool IsFileTypeAllowed(FileInput file, List allowedPatterns) + private static bool IsFileTypeAllowed(FileUpload file, List allowedPatterns) { foreach (var pattern in allowedPatterns) { @@ -90,7 +91,7 @@ private static bool IsFileTypeAllowed(FileInput file, List allowedPatter return false; } - private static bool IsFileTypeMatch(FileInput file, string pattern) + private static bool IsFileTypeMatch(FileUpload file, string pattern) { // Handle MIME type patterns (e.g., "image/*", "text/plain") if (pattern.Contains("/")) From 77a0774ceab7177ae7202225ee192f939a174a55 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 20:57:22 +0100 Subject: [PATCH 10/52] refactor(file-input): use Guid for FileUpload.Id and OnDelete Update FileUpload.Id to Guid and update related OnDelete handlers and usages to use Guid instead of object. Cleans up type safety and clarifies ownership of file identifiers. --- .../Docs/02_Widgets/02_Inputs/AudioRecorder.md | 2 -- .../Docs/02_Widgets/02_Inputs/File.md | 2 +- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 16 ++++++---------- .../Apps/Widgets/Inputs/FileInputApp.cs | 2 +- Ivy.Samples.Shared/GlobalUsings.cs | 1 + Ivy.Test/FileInputValidationTests.cs | 2 +- Ivy/Services/UploadService.cs | 5 +++-- Ivy/Widgets/Inputs/FileInput.cs | 8 ++++---- 8 files changed, 17 insertions(+), 21 deletions(-) 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 5a299f1b34..8da1744369 100644 --- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md +++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md @@ -6,8 +6,6 @@ searchHints: - audio - capture - sound -imports: - - Ivy.Services --- # Audio Recorder diff --git a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md index 2db19af97a..eb03b0f38e 100644 --- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md +++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md @@ -62,7 +62,7 @@ public class FileDropDemo : ViewBase ``` -Multiple file selection is automatically enabled when you use a collection type (`IEnumerable`, `FileUpload[]`, `List`, etc.) as your state. You do **not** need to explicitly set a `.Multiple()` property. +Multiple file selection is automatically enabled when you use a collection type (`IEnumerable`, `FileUpload[]`, `List`, etc.) as your state. You do **not** need to explicitly set a `.Multiple()` property, ## Styling diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index 0a931a2a07..73494184ce 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -29,14 +29,11 @@ public class SingleFileUpload : ViewBase var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => { - var uploadId = Guid.NewGuid(); - var currentFile = fileUpload with { Id = uploadId }; - try { - selectedFile.Set(currentFile); + selectedFile.Set(fileUpload); - var totalBytes = currentFile.Length; + var totalBytes = fileUpload.Length; var processedBytes = 0L; var buffer = new byte[8192]; // 8KB chunks @@ -72,7 +69,7 @@ public class SingleFileUpload : ViewBase } }); - void OnDelete(object fileId) + void OnDelete(Guid fileId) { selectedFile.Default(); uploadedBytes.Default(); @@ -118,8 +115,7 @@ public class MultipleFilesUpload : ViewBase var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => { - var uploadId = Guid.NewGuid(); - var currentFile = fileUpload with { Id = uploadId }; + var currentFile = fileUpload; try { @@ -168,9 +164,9 @@ public class MultipleFilesUpload : ViewBase } }); - void OnDelete(object fileId) + void OnDelete(Guid fileId) { - var file = selectedFiles.Value.FirstOrDefault(f => f.Id.Equals(fileId)); + var file = selectedFiles.Value.FirstOrDefault(f => f.Id == fileId); if (file == null) return; selectedFiles.Set(files => files.Remove(file)); } diff --git a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs index 49dfdf9393..cdd8c48eed 100644 --- a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs +++ b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs @@ -11,7 +11,7 @@ public class FileInputApp : SampleBase protected override object? BuildSample() { // Mock file for 'With Value' example - var mockFile = new FileUpload { Id = Guid.NewGuid(), FileName = "example.txt", ContentType = "text/plain", Length = 12345 }; + var mockFile = new FileUpload { FileName = "example.txt", ContentType = "text/plain", Length = 12345 }; var singleFile = UseState(() => null); var singleFileWithValue = UseState(() => mockFile); diff --git a/Ivy.Samples.Shared/GlobalUsings.cs b/Ivy.Samples.Shared/GlobalUsings.cs index b7e63e36a4..f1ec786296 100644 --- a/Ivy.Samples.Shared/GlobalUsings.cs +++ b/Ivy.Samples.Shared/GlobalUsings.cs @@ -14,5 +14,6 @@ global using System.Globalization; global using Ivy; global using Ivy.Chrome; +global using Ivy.Services; namespace Ivy.Samples.Shared; \ No newline at end of file diff --git a/Ivy.Test/FileInputValidationTests.cs b/Ivy.Test/FileInputValidationTests.cs index 7e6b07bf3f..93b364e526 100644 --- a/Ivy.Test/FileInputValidationTests.cs +++ b/Ivy.Test/FileInputValidationTests.cs @@ -313,7 +313,7 @@ public void ValidateFileType_WithFileWithoutExtension_ReturnsError() private static FileUpload CreateTestFile(string name, string type = "text/plain") { - return new FileUpload { Id = Guid.NewGuid(), FileName = name, ContentType = type, Length = 12345 }; + return new FileUpload { FileName = name, ContentType = type, Length = 12345 }; } [Fact] diff --git a/Ivy/Services/UploadService.cs b/Ivy/Services/UploadService.cs index 5b5ef01272..aace32a971 100644 --- a/Ivy/Services/UploadService.cs +++ b/Ivy/Services/UploadService.cs @@ -23,8 +23,8 @@ public enum FileUploadStatus /// public record FileUpload { - /// Gets the identifier for this file upload, set by the client. - public object? Id { get; set; } + /// Gets the identifier for this file upload, set by the server. + public Guid Id { get; init; } /// Gets the name of the uploaded file including its extension. public string FileName { get; init; } = string.Empty; @@ -139,6 +139,7 @@ public async Task Upload(string uploadId, IFormFile file) var fileUpload = new FileUpload { + Id = guid, FileName = actualFileName, ContentType = actualMimeType, Length = file.Length diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index f947c2357e..03a103f7ad 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -69,7 +69,7 @@ public abstract record FileInputBase : WidgetBase, IAnyFileInput [Event] public Func, ValueTask>? OnBlur { get; set; } /// Gets or sets the event handler called when a file is deleted (passes FileInput.Id as parameter). - [Event] public Func, ValueTask>? OnDelete { get; set; } + [Event] public Func, ValueTask>? OnDelete { get; set; } /// /// Returns the types that this file input can bind to. @@ -373,7 +373,7 @@ public static FileInputBase HandleBlur(this FileInputBase widget, Action onBlur) /// The file input to configure. /// The event handler to call when a file is deleted, receives the FileInput.Id. [OverloadResolutionPriority(1)] - public static FileInputBase HandleDelete(this FileInputBase widget, Func, ValueTask> onDelete) + public static FileInputBase HandleDelete(this FileInputBase widget, Func, ValueTask> onDelete) { return widget with { OnDelete = onDelete }; } @@ -383,7 +383,7 @@ public static FileInputBase HandleDelete(this FileInputBase widget, Func /// The file input to configure. /// The event handler to call when a file is deleted, receives the FileInput.Id. - public static FileInputBase HandleDelete(this FileInputBase widget, Action> onDelete) + public static FileInputBase HandleDelete(this FileInputBase widget, Action> onDelete) { return widget.HandleDelete(onDelete.ToValueTask()); } @@ -393,7 +393,7 @@ public static FileInputBase HandleDelete(this FileInputBase widget, Action /// The file input to configure. /// The simple action to perform when a file is deleted, receives the FileInput.Id. - public static FileInputBase HandleDelete(this FileInputBase widget, Action onDelete) + public static FileInputBase HandleDelete(this FileInputBase widget, Action onDelete) { return widget.HandleDelete(e => { onDelete(e.Value); return ValueTask.CompletedTask; }); } From 726aa19bdc87b3bb5b3b0fb66bdec162da254c0e Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 21:04:53 +0100 Subject: [PATCH 11/52] refactor(docs): remove async keyword from UseUpload handlers Replaces unnecessary async lambdas with synchronous handlers returning Task.CompletedTask in UseUpload examples for clarity and consistency. Also removes commented-out unused upload handler code. --- .../Docs/01_Onboarding/02_Concepts/Uploads.md | 16 ++++++++------ .../02_Widgets/02_Inputs/AudioRecorder.md | 3 ++- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 21 ------------------- 3 files changed, 12 insertions(+), 28 deletions(-) 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 1a9adb4907..79f6be6b43 100644 --- a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md +++ b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md @@ -26,7 +26,7 @@ public class FileUploadView : ViewBase public override object? Build() { var files = UseState(() => null); - var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => { }); + var uploadUrl = this.UseUpload((fileUpload, stream, cancellationToken) => Task.CompletedTask); return files.ToFileInput(uploadUrl, "Choose a file"); } @@ -49,11 +49,12 @@ var client = UseService(); // 1. Create upload handler - returns state with // URL like "/upload/{connectionId}/{uploadId}" var uploadUrl = this.UseUpload( - async (fileUpload, stream, cancellationToken) => { + (fileUpload, stream, cancellationToken) => { // This handler is called when a file is uploaded // Access file metadata: fileUpload.FileName, fileUpload.ContentType, fileUpload.Length // Access file content via the stream parameter client.Toast($"Received {fileUpload.Length} bytes", "File Uploaded"); + return Task.CompletedTask; } // mimeType and fileName are optional parameters with defaults from the uploaded file ); @@ -80,12 +81,13 @@ public class UploadWithStatusView : ViewBase var client = UseService(); var files = UseState(() => null); var uploadUrl = this.UseUpload( - async (fileUpload, stream, cancellationToken) => { + (fileUpload, stream, cancellationToken) => { try { client.Toast($"Successfully uploaded {fileUpload.Length} bytes", "Upload Complete"); } catch (Exception ex) { client.Toast(ex); } + return Task.CompletedTask; } ); @@ -107,15 +109,16 @@ public class ValidatedUploadView : ViewBase var error = UseState(() => null); var files = UseState(() => null); var uploadUrl = this.UseUpload( - async (fileUpload, stream, cancellationToken) => { + (fileUpload, stream, cancellationToken) => { if (fileUpload.Length > 2 * 1024 * 1024) // 2MB limit { error.Set("File size must be less than 2MB"); - return; + return Task.CompletedTask; } error.Set((string?)null); // Process uploaded file client.Toast($"Image uploaded successfully ({fileUpload.Length} bytes)", "Success"); + return Task.CompletedTask; }, "image/jpeg" // Optional: specify expected MIME type ); @@ -200,11 +203,12 @@ public class MultiFileUploadView : ViewBase var uploadedFiles = UseState(() => new List()); var newFiles = UseState?>(() => null); var uploadUrl = this.UseUpload( - async (fileUpload, stream, cancellationToken) => { + (fileUpload, stream, cancellationToken) => { // Process uploaded file client.Toast($"File uploaded ({fileUpload.Length} bytes)", "Upload Complete"); // Add to list of uploaded files uploadedFiles.Set(uploadedFiles.Value.Append($"File {uploadedFiles.Value.Count + 1}").ToList()); + return Task.CompletedTask; } ); 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 8da1744369..d22ae5a086 100644 --- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md +++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md @@ -26,9 +26,10 @@ public class BasicAudioRecorderDemo : ViewBase public override object? Build() { var uploadUrl = this.UseUpload( - async (fileUpload, stream, cancellationToken) => { + (fileUpload, stream, cancellationToken) => { // Process uploaded file Console.WriteLine($"Received {fileUpload.Length} bytes"); + return Task.CompletedTask; } ); diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index 73494184ce..2da8f44a92 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -85,27 +85,6 @@ void OnDelete(Guid fileId) } } -// public class MemoryStreamUploadHandler() : IUploadHandler -// { -// public MemoryStreamUploadHandler(IState state) -// { -// } -// -// public MemoryStreamUploadHandler(IState state) -// { -// } -// -// public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) -// { -// //todo: -// } -// } -// -// public interface IUploadHandler -// { -// Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken); -// } - public class MultipleFilesUpload : ViewBase { public override object? Build() From 9798bdecba30f0545ca8d4081bde6f14c48a7f06 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 21:27:55 +0100 Subject: [PATCH 12/52] feat(upload): add IUploadHandler for pluggable upload logic Introduce IUploadHandler interface and MemoryStreamUploadHandler to enable custom upload logic. Refactor UseUpload to accept IUploadHandler, improving extensibility and code clarity. --- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 54 +++---------------- Ivy/Hooks/UseUpload.cs | 39 +++++++------- Ivy/Services/MemoryStreamUploadHandler.cs | 46 ++++++++++++++++ Ivy/Services/UploadService.cs | 18 +++++-- 4 files changed, 88 insertions(+), 69 deletions(-) create mode 100644 Ivy/Services/MemoryStreamUploadHandler.cs diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index 2da8f44a92..66a27c5036 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -24,61 +24,23 @@ public class SingleFileUpload : ViewBase { public override object? Build() { - var selectedFile = UseState(); - var uploadedBytes = UseState(); + var contentState = UseState(); + var uploadState = UseState(); - var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => - { - try - { - selectedFile.Set(fileUpload); - - var totalBytes = fileUpload.Length; - var processedBytes = 0L; - var buffer = new byte[8192]; // 8KB chunks - - using var memoryStream = new MemoryStream(); - - selectedFile.SetStatus(FileUploadStatus.Loading); - - int bytesRead; - while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) - { - cancellationToken.ThrowIfCancellationRequested(); + var uploadHandler = new MemoryStreamUploadHandler(contentState, uploadState); - await memoryStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); - processedBytes += bytesRead; - var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0; - selectedFile.SetProgress(progress); - - //Simulate this being slower - await Task.Delay(50, cancellationToken); - } - - uploadedBytes.Set(memoryStream.ToArray()); - selectedFile.SetStatus(FileUploadStatus.Finished); - } - catch (OperationCanceledException) - { - selectedFile.SetStatus(FileUploadStatus.Aborted); - } - catch (Exception) - { - selectedFile.SetStatus(FileUploadStatus.Failed); - throw; - } - }); + var uploadUrl = this.UseUpload(uploadHandler); void OnDelete(Guid fileId) { - selectedFile.Default(); - uploadedBytes.Default(); + uploadState.Default(); + contentState.Default(); } return Layout.Vertical() | Text.H1("Single File Upload") - | selectedFile.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload").HandleDelete(OnDelete) - | selectedFile.ToDetails() + | uploadState.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload").HandleDelete(OnDelete) + | uploadState.ToDetails() .Builder(e => e!.Length, e => e.Func((long x) => Utils.FormatBytes(x))) .Builder(e => e!.Progress, e => e.Func((float x) => x.ToString("P0"))) ; diff --git a/Ivy/Hooks/UseUpload.cs b/Ivy/Hooks/UseUpload.cs index 3f3343e530..5a24fcf355 100644 --- a/Ivy/Hooks/UseUpload.cs +++ b/Ivy/Hooks/UseUpload.cs @@ -22,26 +22,27 @@ public static class UseUploadExtensions 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(async (bytes) => { handler(bytes); await Task.CompletedTask; }, mimeType, fileName); + /// + /// Creates an upload endpoint using an IUploadHandler for custom upload logic. + /// + /// The view context. + /// The upload handler to process uploaded files. + /// Optional default content type for uploaded files. + /// Optional default file name for uploaded files. + /// A state containing the upload URL. + public static IState UseUpload(this TView view, IUploadHandler handler, string? defaultContentType = null, string? defaultFileName = null) where TView : ViewBase => + view.Context.UseUpload(handler, defaultContentType, defaultFileName); - public static IState UseUpload(this IViewContext context, Func handler, string mimeType, string fileName) + /// + /// Creates an upload endpoint using an IUploadHandler for custom upload logic. + /// + /// The view context. + /// The upload handler to process uploaded files. + /// Optional default content type for uploaded files. + /// Optional default file name for uploaded files. + /// A state containing the upload URL. + public static IState UseUpload(this IViewContext context, IUploadHandler handler, string? defaultContentType = null, string? defaultFileName = null) { - // Adapt byte[] handler to UploadDelegate - async Task AdaptedHandler(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) - { - using var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream, cancellationToken); - var bytes = memoryStream.ToArray(); - await handler(bytes); - } - - return context.UseUpload(AdaptedHandler, mimeType, fileName); + return context.UseUpload(handler.HandleUploadAsync, defaultContentType, defaultFileName); } } \ No newline at end of file diff --git a/Ivy/Services/MemoryStreamUploadHandler.cs b/Ivy/Services/MemoryStreamUploadHandler.cs new file mode 100644 index 0000000000..2e2cda2e0d --- /dev/null +++ b/Ivy/Services/MemoryStreamUploadHandler.cs @@ -0,0 +1,46 @@ +using Ivy.Core.Hooks; + +namespace Ivy.Services; + +public class MemoryStreamUploadHandler(IState contentState, IState uploadState, int chunkSize = 8192 /* 8KB chunks */) + : IUploadHandler +{ + public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) + { + try + { + uploadState.Set(fileUpload); + + var totalBytes = fileUpload.Length; + var processedBytes = 0L; + var buffer = new byte[chunkSize]; + + using var memoryStream = new MemoryStream(); + + uploadState.SetStatus(FileUploadStatus.Loading); + + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + await memoryStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); + processedBytes += bytesRead; + var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0; + uploadState.SetProgress(progress); + } + + contentState.Set(memoryStream.ToArray()); + uploadState.SetStatus(FileUploadStatus.Finished); + } + catch (OperationCanceledException) + { + uploadState.SetStatus(FileUploadStatus.Aborted); + } + catch (Exception) + { + uploadState.SetStatus(FileUploadStatus.Failed); + throw; + } + } +} \ No newline at end of file diff --git a/Ivy/Services/UploadService.cs b/Ivy/Services/UploadService.cs index aace32a971..56f2e9266d 100644 --- a/Ivy/Services/UploadService.cs +++ b/Ivy/Services/UploadService.cs @@ -46,6 +46,20 @@ public record FileUpload public FileUploadStatus Status { get; set; } = FileUploadStatus.Pending; } +/// +/// Interface for handling file uploads with custom logic. +/// +public interface IUploadHandler +{ + /// + /// Handles the file upload asynchronously. + /// + /// The file upload metadata. + /// The file content stream. + /// Cancellation token for the operation. + Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken); +} + public static class FileUploadExtensions { public static void SetProgress(this IState fileInputState, float progress) @@ -88,10 +102,6 @@ public async Task Upload([FromRoute] string connectionId, [FromRo { return BadRequest("uploadId is required."); } - if (file == null) - { - return BadRequest("file is required."); - } if (sessionStore.Sessions.TryGetValue(connectionId, out var session)) { var uploadService = session.AppServices.GetRequiredService(); From 5ac9e2f772e0e593cf2b7b08dba454e4de0756e7 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Thu, 30 Oct 2025 21:34:56 +0100 Subject: [PATCH 13/52] feat(services): add MultiFileUploadHandler stub implementation Introduce MultiFileUploadHandler class with a stubbed HandleUploadAsync method to support future multi-file uploads. --- Ivy/Services/MemoryStreamUploadHandler.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Ivy/Services/MemoryStreamUploadHandler.cs b/Ivy/Services/MemoryStreamUploadHandler.cs index 2e2cda2e0d..20ba2053ab 100644 --- a/Ivy/Services/MemoryStreamUploadHandler.cs +++ b/Ivy/Services/MemoryStreamUploadHandler.cs @@ -2,6 +2,14 @@ namespace Ivy.Services; +public class MultiFileUploadHandler : IUploadHandler +{ + public Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} + public class MemoryStreamUploadHandler(IState contentState, IState uploadState, int chunkSize = 8192 /* 8KB chunks */) : IUploadHandler { From 800317853f24256d4d0d2c4b093f438b7e2af737 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Fri, 31 Oct 2025 11:59:45 +0100 Subject: [PATCH 14/52] feat(upload): add detailed upload progress and logging Add console logs for upload start, progress, finish, abort, and failures. Improve State to avoid unnecessary notifications on unchanged state. Reset file input after upload/delete to allow re-selecting the same file. --- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 10 +++++ Ivy/Core/Hooks/State.cs | 37 ++++++++++++++----- Ivy/Hooks/UseUpload.cs | 4 +- Ivy/Services/MemoryStreamUploadHandler.cs | 22 +++++++---- Ivy/Services/UploadService.cs | 30 +++++++++++++-- .../src/widgets/inputs/FileInputWidget.tsx | 9 +++++ 6 files changed, 90 insertions(+), 22 deletions(-) diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index 66a27c5036..ab9c0b02c7 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -60,6 +60,7 @@ public class MultipleFilesUpload : ViewBase try { + Console.WriteLine($"[Samples] Start multi upload fileId={currentFile.Id} name='{currentFile.FileName}' length={currentFile.Length}"); selectedFiles.Set(files => files.Add(currentFile)); var totalBytes = currentFile.Length; @@ -74,6 +75,7 @@ public class MultipleFilesUpload : ViewBase currentFile = loadingFile; int bytesRead; + var nextLog = 0.25f; while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) { cancellationToken.ThrowIfCancellationRequested(); @@ -87,6 +89,12 @@ public class MultipleFilesUpload : ViewBase selectedFiles.Set(files => files.Replace(currentFile, updatedFile)); currentFile = updatedFile; + if (progress >= nextLog) + { + Console.WriteLine($"[Samples] Progress fileId={currentFile.Id} {(int)(progress * 100)}%"); + nextLog += 0.25f; + } + //Simulate this being slower await Task.Delay(50, cancellationToken); } @@ -96,12 +104,14 @@ public class MultipleFilesUpload : ViewBase selectedFiles.Set(files => files.Replace(currentFile, finishedFile)); uploadCount.Set(count => count + 1); + Console.WriteLine($"[Samples] Finished fileId={currentFile.Id}"); } catch (OperationCanceledException) { // Upload was aborted by user var abortedFile = currentFile with { Status = FileUploadStatus.Aborted }; selectedFiles.Set(files => files.Replace(currentFile, abortedFile)); + Console.WriteLine($"[Samples] Aborted fileId={currentFile.Id}"); } }); diff --git a/Ivy/Core/Hooks/State.cs b/Ivy/Core/Hooks/State.cs index ae799cae69..fc10013898 100644 --- a/Ivy/Core/Hooks/State.cs +++ b/Ivy/Core/Hooks/State.cs @@ -91,11 +91,20 @@ public T Value } set { + T? newValue = default; + bool changed = false; lock (_lock) { - if (Equals(_value, value)) return; - _value = value; - if (!_subject.IsDisposed) _subject.OnNext(_value); + if (!Equals(_value, value)) + { + _value = value; + newValue = _value; + changed = true; + } + } + if (changed && !_subject.IsDisposed) + { + _subject.OnNext(newValue!); } } } @@ -120,14 +129,24 @@ public T Set(T value) /// The new state value. public T Set(Func setter) { + T current; + T updated; + bool changed; lock (_lock) { - var newValue = setter(_value); - if (Equals(_value, newValue)) return _value; - _value = newValue; - if (!_subject.IsDisposed) _subject.OnNext(_value); - return _value; + current = _value; + updated = setter(current); + changed = !Equals(_value, updated); + if (changed) + { + _value = updated; + } } + if (changed && !_subject.IsDisposed) + { + _subject.OnNext(updated); + } + return _value; } /// @@ -208,4 +227,4 @@ public IEffectTrigger ToTrigger() { return EffectTrigger.AfterChange(this); } -} \ No newline at end of file +} diff --git a/Ivy/Hooks/UseUpload.cs b/Ivy/Hooks/UseUpload.cs index 5a24fcf355..7b3ddda6df 100644 --- a/Ivy/Hooks/UseUpload.cs +++ b/Ivy/Hooks/UseUpload.cs @@ -18,7 +18,7 @@ public static class UseUploadExtensions var (cleanup, uploadUrl) = uploadService.AddUpload(handler, defaultContentType, defaultFileName); url.Set(uploadUrl); return cleanup; - }); + }, [EffectTrigger.AfterInit()]); return url; } @@ -45,4 +45,4 @@ public static class UseUploadExtensions { return context.UseUpload(handler.HandleUploadAsync, defaultContentType, defaultFileName); } -} \ No newline at end of file +} diff --git a/Ivy/Services/MemoryStreamUploadHandler.cs b/Ivy/Services/MemoryStreamUploadHandler.cs index 20ba2053ab..22743154c6 100644 --- a/Ivy/Services/MemoryStreamUploadHandler.cs +++ b/Ivy/Services/MemoryStreamUploadHandler.cs @@ -2,13 +2,6 @@ namespace Ivy.Services; -public class MultiFileUploadHandler : IUploadHandler -{ - public Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } -} public class MemoryStreamUploadHandler(IState contentState, IState uploadState, int chunkSize = 8192 /* 8KB chunks */) : IUploadHandler @@ -17,6 +10,7 @@ public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, Cancel { try { + Console.WriteLine($"[UploadHandler] Start reading fileId={fileUpload.Id} name='{fileUpload.FileName}' length={fileUpload.Length}"); uploadState.Set(fileUpload); var totalBytes = fileUpload.Length; @@ -28,6 +22,7 @@ public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, Cancel uploadState.SetStatus(FileUploadStatus.Loading); int bytesRead; + var nextLog = 0.25f; while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) { cancellationToken.ThrowIfCancellationRequested(); @@ -36,19 +31,30 @@ public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, Cancel processedBytes += bytesRead; var progress = totalBytes > 0 ? ((float)processedBytes / totalBytes) : 0; uploadState.SetProgress(progress); + + if (progress >= nextLog) + { + Console.WriteLine($"[UploadHandler] Progress fileId={fileUpload.Id} {(int)(progress * 100)}%"); + nextLog += 0.25f; + } + + await Task.Delay(50); } contentState.Set(memoryStream.ToArray()); uploadState.SetStatus(FileUploadStatus.Finished); + Console.WriteLine($"[UploadHandler] Finished fileId={fileUpload.Id} bytes={processedBytes}"); } catch (OperationCanceledException) { uploadState.SetStatus(FileUploadStatus.Aborted); + Console.WriteLine($"[UploadHandler] Aborted fileId={fileUpload.Id}"); } catch (Exception) { uploadState.SetStatus(FileUploadStatus.Failed); + Console.WriteLine($"[UploadHandler] Failed fileId={fileUpload.Id}"); throw; } } -} \ No newline at end of file +} diff --git a/Ivy/Services/UploadService.cs b/Ivy/Services/UploadService.cs index 56f2e9266d..6c9151a790 100644 --- a/Ivy/Services/UploadService.cs +++ b/Ivy/Services/UploadService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Reactive.Disposables; +using System.Diagnostics; using System.Text.Json.Serialization; using Ivy.Core.Hooks; using Microsoft.AspNetCore.Http; @@ -132,8 +133,11 @@ public class UploadService(string connectionId) : IUploadService, IDisposable public async Task Upload(string uploadId, IFormFile file) { + var sw = Stopwatch.StartNew(); + if (!Guid.TryParse(uploadId, out var guid) || !_uploads.TryGetValue(guid, out var upload)) { + Console.WriteLine($"[UploadService] Invalid or unknown uploadId: '{uploadId}'"); return new BadRequestObjectResult($"Invalid or unknown uploadId: '{uploadId}'."); } @@ -141,21 +145,41 @@ public async Task Upload(string uploadId, IFormFile file) if (file.Length == 0) { + Console.WriteLine($"[UploadService] Empty file received for uploadId: {uploadId}"); return new BadRequestObjectResult("Empty file."); } var actualMimeType = file.ContentType.NullIfEmpty() ?? defaultContentType ?? "application/octet-stream"; var actualFileName = file.FileName.NullIfEmpty() ?? defaultFileName ?? "upload"; + // Generate a unique id per uploaded file to avoid collisions + // when multiple files are uploaded using the same upload endpoint. var fileUpload = new FileUpload { - Id = guid, + Id = Guid.NewGuid(), FileName = actualFileName, ContentType = actualMimeType, Length = file.Length }; - await handler(fileUpload, file.OpenReadStream(), cts.Token); + // Ensure request stream is disposed deterministically to avoid leaking handles + try + { + using var uploadStream = file.OpenReadStream(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeoutCts.Token); + Console.WriteLine($"[UploadService] Begin upload connection={connectionId} uploadId={uploadId} fileId={fileUpload.Id} name='{fileUpload.FileName}' length={fileUpload.Length} contentType='{fileUpload.ContentType}'"); + await handler(fileUpload, uploadStream, linkedCts.Token); + sw.Stop(); + Console.WriteLine($"[UploadService] Completed upload connection={connectionId} fileId={fileUpload.Id} elapsed={sw.ElapsedMilliseconds}ms"); + } + catch (Exception) + { + sw.Stop(); + Console.WriteLine($"[UploadService] Failed upload connection={connectionId} uploadId={uploadId} elapsed={sw.ElapsedMilliseconds}ms"); + // Bubble up to framework after handler updates status; controller will return 500 + throw; + } return new OkResult(); } @@ -171,4 +195,4 @@ public interface IUploadService (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null); Task Upload(string uploadId, IFormFile file); -} \ No newline at end of file +} diff --git a/frontend/src/widgets/inputs/FileInputWidget.tsx b/frontend/src/widgets/inputs/FileInputWidget.tsx index 6e6ecd9b9a..25423fc314 100644 --- a/frontend/src/widgets/inputs/FileInputWidget.tsx +++ b/frontend/src/widgets/inputs/FileInputWidget.tsx @@ -113,6 +113,8 @@ export const FileInputWidget: React.FC = ({ } else { await uploadFile(limitedFiles[0]); } + // Reset the input so selecting the same file again triggers onChange + e.target.value = ''; return; } @@ -121,6 +123,9 @@ export const FileInputWidget: React.FC = ({ } else { await uploadFile(files[0]); } + + // Reset the input so selecting the same file again triggers onChange + e.target.value = ''; }, [multiple, uploadFile, maxFiles] ); @@ -130,6 +135,10 @@ export const FileInputWidget: React.FC = ({ if (hasDeleteHandler) { handleEvent('OnDelete', id, [fileId]); } + // Also clear file input to allow re-selecting same file + if (inputRef.current) { + inputRef.current.value = ''; + } }, [hasDeleteHandler, handleEvent, id] ); From b58a399f7f934aceae8ce65763c2a39fdb6e8483 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Fri, 31 Oct 2025 12:23:45 +0100 Subject: [PATCH 15/52] feat(uploads): add generic FileUpload and upload cancelation Introduce FileUpload for typed file content, unify IFileUpload usage, and add upload cancelation support in UploadService. Refactor FileInput and validation logic to support generic and non-generic uploads. --- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 15 +++-- Ivy.Test/TextInputTests.cs | 2 +- Ivy/Core/Hooks/ConvertedState.cs | 2 +- Ivy/Core/Hooks/State.cs | 4 +- Ivy/Services/MemoryStreamUploadHandler.cs | 20 +++--- Ivy/Services/UploadService.cs | 63 ++++++++++++++++++- Ivy/Views/Forms/FormBuilder.cs | 24 ++++++- Ivy/Widgets/Inputs/FileInput.cs | 45 ++++++++++--- Ivy/Widgets/Inputs/FileInputValidation.cs | 12 ++-- 9 files changed, 150 insertions(+), 37 deletions(-) diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index ab9c0b02c7..2688ddd20e 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -24,17 +24,17 @@ public class SingleFileUpload : ViewBase { public override object? Build() { - var contentState = UseState(); - var uploadState = UseState(); + var uploadState = UseState?>(); + var uploadService = UseService(); - var uploadHandler = new MemoryStreamUploadHandler(contentState, uploadState); + var uploadHandler = new MemoryStreamUploadHandler(uploadState); var uploadUrl = this.UseUpload(uploadHandler); - void OnDelete(Guid fileId) + void OnDelete(Guid uploadId) { - uploadState.Default(); - contentState.Default(); + //uploadService.CancelFile(uploadId); + //uploadState.Default(); } return Layout.Vertical() @@ -53,6 +53,7 @@ public class MultipleFilesUpload : ViewBase { var selectedFiles = UseState(ImmutableArray.Create()); var uploadCount = UseState(0); + var uploadService = UseService(); var uploadUrl = this.UseUpload(async (fileUpload, stream, cancellationToken) => { @@ -119,6 +120,8 @@ void OnDelete(Guid fileId) { var file = selectedFiles.Value.FirstOrDefault(f => f.Id == fileId); if (file == null) return; + // Request cancellation if this file is still uploading + //todo:uploadService.CancelFile(fileId); selectedFiles.Set(files => files.Remove(file)); } diff --git a/Ivy.Test/TextInputTests.cs b/Ivy.Test/TextInputTests.cs index 667fa1a5ca..4c5ca9a754 100644 --- a/Ivy.Test/TextInputTests.cs +++ b/Ivy.Test/TextInputTests.cs @@ -269,7 +269,7 @@ public T Set(Func setter) return Value; } - public T Default() + public T Reset() { return Set(default(T)!); } diff --git a/Ivy/Core/Hooks/ConvertedState.cs b/Ivy/Core/Hooks/ConvertedState.cs index 3b710f3d2c..92b674d648 100644 --- a/Ivy/Core/Hooks/ConvertedState.cs +++ b/Ivy/Core/Hooks/ConvertedState.cs @@ -87,7 +87,7 @@ public TTo Set(Func setter) /// Thread-safe: delegates to the original state's Default method. /// /// The default value. - public TTo Default() + public TTo Reset() { return Set(default(TTo)!); } diff --git a/Ivy/Core/Hooks/State.cs b/Ivy/Core/Hooks/State.cs index fc10013898..f2e2032815 100644 --- a/Ivy/Core/Hooks/State.cs +++ b/Ivy/Core/Hooks/State.cs @@ -55,7 +55,7 @@ public interface IState : IObservable, IAnyState /// Resets the state to its default value. /// /// The default value. - public T Default(); + public T Reset(); } /// @@ -154,7 +154,7 @@ public T Set(Func setter) /// Thread-safe. /// /// The default value. - public T Default() + public T Reset() { return Set(default(T)!); } diff --git a/Ivy/Services/MemoryStreamUploadHandler.cs b/Ivy/Services/MemoryStreamUploadHandler.cs index 22743154c6..7283434150 100644 --- a/Ivy/Services/MemoryStreamUploadHandler.cs +++ b/Ivy/Services/MemoryStreamUploadHandler.cs @@ -3,7 +3,7 @@ namespace Ivy.Services; -public class MemoryStreamUploadHandler(IState contentState, IState uploadState, int chunkSize = 8192 /* 8KB chunks */) +public class MemoryStreamUploadHandler(IState?> uploadState, int chunkSize = 8192 /* 8KB chunks */) : IUploadHandler { public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) @@ -11,7 +11,16 @@ public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, Cancel try { Console.WriteLine($"[UploadHandler] Start reading fileId={fileUpload.Id} name='{fileUpload.FileName}' length={fileUpload.Length}"); - uploadState.Set(fileUpload); + var typed = new FileUpload + { + Id = fileUpload.Id, + FileName = fileUpload.FileName, + ContentType = fileUpload.ContentType, + Length = fileUpload.Length, + Status = FileUploadStatus.Loading, + Progress = 0f + }; + uploadState.Set(typed); var totalBytes = fileUpload.Length; var processedBytes = 0L; @@ -19,8 +28,6 @@ public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, Cancel using var memoryStream = new MemoryStream(); - uploadState.SetStatus(FileUploadStatus.Loading); - int bytesRead; var nextLog = 0.25f; while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) @@ -38,11 +45,10 @@ public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, Cancel nextLog += 0.25f; } - await Task.Delay(50); + // no artificial delay } - contentState.Set(memoryStream.ToArray()); - uploadState.SetStatus(FileUploadStatus.Finished); + uploadState.Set(typed with { Content = memoryStream.ToArray(), Status = FileUploadStatus.Finished, Progress = 1f }); Console.WriteLine($"[UploadHandler] Finished fileId={fileUpload.Id} bytes={processedBytes}"); } catch (OperationCanceledException) diff --git a/Ivy/Services/UploadService.cs b/Ivy/Services/UploadService.cs index 6c9151a790..7aedf8967f 100644 --- a/Ivy/Services/UploadService.cs +++ b/Ivy/Services/UploadService.cs @@ -19,10 +19,23 @@ public enum FileUploadStatus Finished } +/// +/// Common contract for uploaded file metadata used by both generic and non-generic file upload records. +/// +public interface IFileUpload +{ + Guid Id { get; } + string FileName { get; } + string ContentType { get; } + long Length { get; } + float Progress { get; set; } + FileUploadStatus Status { get; set; } +} + /// /// Represents a file uploaded through a file input control. /// -public record FileUpload +public record FileUpload : IFileUpload { /// Gets the identifier for this file upload, set by the server. public Guid Id { get; init; } @@ -47,6 +60,19 @@ public record FileUpload public FileUploadStatus Status { get; set; } = FileUploadStatus.Pending; } +/// +/// Generic variant of FileUpload allowing an associated typed payload to be tracked alongside the upload metadata. +/// +/// The type of the associated payload. +public record FileUpload : FileUpload +{ + /// + /// Optional typed content associated with this upload (e.g., raw bytes, parsed model). + /// + [JsonIgnore] + public T? Content { get; init; } +} + /// /// Interface for handling file uploads with custom logic. /// @@ -63,7 +89,7 @@ public interface IUploadHandler public static class FileUploadExtensions { - public static void SetProgress(this IState fileInputState, float progress) + public static void SetProgress(this IState?> fileInputState, float progress) { var file = fileInputState.Value; if (file != null) @@ -72,7 +98,7 @@ public static void SetProgress(this IState fileInputState, float pr } } - public static void SetStatus(this IState fileInputState, FileUploadStatus status) + public static void SetStatus(this IState?> fileInputState, FileUploadStatus status) { var file = fileInputState.Value; if (file != null) @@ -115,6 +141,7 @@ public async Task Upload([FromRoute] string connectionId, [FromRo public class UploadService(string connectionId) : IUploadService, IDisposable { private readonly ConcurrentDictionary _uploads = new(); + private readonly ConcurrentDictionary _inflightUploads = new(); public (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null) { @@ -168,6 +195,9 @@ public async Task Upload(string uploadId, IFormFile file) using var uploadStream = file.OpenReadStream(); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeoutCts.Token); + // Track this upload by fileId so we can cancel on demand + _inflightUploads[fileUpload.Id] = linkedCts; + Console.WriteLine($"[UploadService] Begin upload connection={connectionId} uploadId={uploadId} fileId={fileUpload.Id} name='{fileUpload.FileName}' length={fileUpload.Length} contentType='{fileUpload.ContentType}'"); await handler(fileUpload, uploadStream, linkedCts.Token); sw.Stop(); @@ -180,10 +210,31 @@ public async Task Upload(string uploadId, IFormFile file) // Bubble up to framework after handler updates status; controller will return 500 throw; } + finally + { + // Remove tracking for this fileId + _inflightUploads.TryRemove(fileUpload.Id, out _); + } return new OkResult(); } + public void CancelUpload(Guid fileId) + { + if (_inflightUploads.TryGetValue(fileId, out var cts)) + { + try + { + Console.WriteLine($"[UploadService] Cancellation requested connection={connectionId} fileId={fileId}"); + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // If already disposed, ignore + } + } + } + public void Dispose() { _uploads.Clear(); @@ -195,4 +246,10 @@ public interface IUploadService (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null); Task Upload(string uploadId, IFormFile file); + + /// + /// Requests cancellation of an in-flight upload by its fileId. + /// Safe to call if no upload is in-flight for the given id. + /// + void CancelUpload(Guid fileId); } diff --git a/Ivy/Views/Forms/FormBuilder.cs b/Ivy/Views/Forms/FormBuilder.cs index cefefe8238..fec26471e8 100644 --- a/Ivy/Views/Forms/FormBuilder.cs +++ b/Ivy/Views/Forms/FormBuilder.cs @@ -199,11 +199,31 @@ private void _Scaffold() { Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; - if (type == typeof(FileUpload)) + static bool IsFileUploadType(Type t) + { + if (t == typeof(FileUpload)) return true; + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(FileUpload<>)) return true; + return typeof(IFileUpload).IsAssignableFrom(t); + } + + if (IsFileUploadType(nonNullableType)) { return (state) => state.ToFileInput().Size(Size); } + // Collections of FileUpload / FileUpload + foreach (var it in type.GetInterfaces().Concat([type])) + { + if (it.IsGenericType && it.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + var arg = it.GetGenericArguments()[0]; + if (IsFileUploadType(arg)) + { + return (state) => state.ToFileInput().Size(Size); + } + } + } + if (name.EndsWith("Id") && (type == typeof(Guid) || type == typeof(int) || type == typeof(string))) { return (state) => state.ToReadOnlyInput().Size(Size); @@ -677,4 +697,4 @@ private static string InvalidMessage(int invalidFields) { return invalidFields == 1 ? "There is 1 invalid field." : $"There are {invalidFields} invalid fields."; } -} \ No newline at end of file +} diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index 03a103f7ad..922bcac0e3 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -84,12 +84,12 @@ public ValidationResult ValidateValue(object? value) { if (value == null) return ValidationResult.Success(); - if (value is FileUpload file) + if (value is IFileUpload file) { return FileInputValidation.ValidateFileType(file, Accept); } - if (value is IEnumerable files) + if (value is IEnumerable files) { var filesList = files.ToList(); @@ -189,13 +189,40 @@ public static FileInputBase ToFileInput(this IAnyState state, string? placeholde { var type = state.GetStateType(); - //Check that type is FileUpload, FileUpload? or IEnumerable (including ImmutableArray, List, etc.) - var isCollection = typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string); - var isValid = type == typeof(FileUpload) || isCollection; + bool IsFileUploadType(Type t) + { + if (t == typeof(FileUpload)) return true; + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(FileUpload<>)) return true; + return typeof(IFileUpload).IsAssignableFrom(t); + } + + bool IsEnumerableOfFileUpload(Type t) + { + if (t == typeof(string)) return false; + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + var arg = t.GetGenericArguments()[0]; + return IsFileUploadType(arg); + } + // Check implemented interfaces (handles ImmutableArray<>, List<>, etc.) + foreach (var it in t.GetInterfaces()) + { + if (it.IsGenericType && it.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + var arg = it.GetGenericArguments()[0]; + if (IsFileUploadType(arg)) return true; + } + } + return false; + } + + //Check that type is FileUpload / FileUpload or IEnumerable/IEnumerable> + var isCollection = IsEnumerableOfFileUpload(type); + var isValid = IsFileUploadType(type) || isCollection; if (!isValid) { - throw new Exception("Invalid type for FileInput - must be FileUpload or IEnumerable"); + throw new Exception("Invalid type for FileInput - must be FileUpload, FileUpload, or a collection thereof"); } Type genericType = typeof(FileInput<>).MakeGenericType(type); @@ -311,7 +338,7 @@ public static FileInputBase Large(this FileInputBase widget) /// /// The file input widget containing validation rules. /// The file to validate. - public static ValidationResult ValidateFile(this FileInputBase widget, FileUpload file) + public static ValidationResult ValidateFile(this FileInputBase widget, IFileUpload file) { return FileInputValidation.ValidateFileType(file, widget.Accept); } @@ -321,7 +348,7 @@ public static ValidationResult ValidateFile(this FileInputBase widget, FileUploa /// /// The file input widget containing validation rules. /// The files to validate. - public static ValidationResult ValidateFiles(this FileInputBase widget, IEnumerable files) + public static ValidationResult ValidateFiles(this FileInputBase widget, IEnumerable files) { var filesList = files.ToList(); @@ -397,4 +424,4 @@ public static FileInputBase HandleDelete(this FileInputBase widget, Action { return widget.HandleDelete(e => { onDelete(e.Value); return ValueTask.CompletedTask; }); } -} \ No newline at end of file +} diff --git a/Ivy/Widgets/Inputs/FileInputValidation.cs b/Ivy/Widgets/Inputs/FileInputValidation.cs index 7fdd2b1076..477cb87f11 100644 --- a/Ivy/Widgets/Inputs/FileInputValidation.cs +++ b/Ivy/Widgets/Inputs/FileInputValidation.cs @@ -10,7 +10,7 @@ public static class FileInputValidation /// /// The files to validate /// Maximum number of files allowed - public static ValidationResult ValidateFileCount(IEnumerable files, int? maxFiles) + public static ValidationResult ValidateFileCount(IEnumerable files, int? maxFiles) { if (maxFiles == null) return ValidationResult.Success(); @@ -28,7 +28,7 @@ public static ValidationResult ValidateFileCount(IEnumerable files, /// /// The files to validate /// The accept pattern (e.g., ".txt,.pdf" or "image/*") - public static ValidationResult ValidateFileTypes(IEnumerable files, string? accept) + public static ValidationResult ValidateFileTypes(IEnumerable files, string? accept) { if (string.IsNullOrWhiteSpace(accept)) return ValidationResult.Success(); @@ -57,7 +57,7 @@ public static ValidationResult ValidateFileTypes(IEnumerable files, /// /// The file to validate /// The accept pattern - public static ValidationResult ValidateFileType(FileUpload file, string? accept) + public static ValidationResult ValidateFileType(IFileUpload file, string? accept) { if (string.IsNullOrWhiteSpace(accept)) return ValidationResult.Success(); @@ -79,7 +79,7 @@ private static List ParseAcceptPattern(string accept) .ToList(); } - private static bool IsFileTypeAllowed(FileUpload file, List allowedPatterns) + private static bool IsFileTypeAllowed(IFileUpload file, List allowedPatterns) { foreach (var pattern in allowedPatterns) { @@ -91,7 +91,7 @@ private static bool IsFileTypeAllowed(FileUpload file, List allowedPatte return false; } - private static bool IsFileTypeMatch(FileUpload file, string pattern) + private static bool IsFileTypeMatch(IFileUpload file, string pattern) { // Handle MIME type patterns (e.g., "image/*", "text/plain") if (pattern.Contains("/")) @@ -141,4 +141,4 @@ private ValidationResult(bool isValid, string? errorMessage = null) public static ValidationResult Success() => new(true); public static ValidationResult Error(string message) => new(false, message); -} \ No newline at end of file +} From 17805b55cf124666c2791f2c80bb0c125c894e42 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Fri, 31 Oct 2025 12:26:28 +0100 Subject: [PATCH 16/52] refactor(file-input): rename delete handlers to cancel Rename file input delete event handlers and related methods to cancel for clarity and consistency in handling file upload cancellation. --- Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs | 9 +++--- Ivy/Widgets/Inputs/FileInput.cs | 28 +++++++++---------- .../src/widgets/inputs/FileInputWidget.tsx | 14 +++++----- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs index 2688ddd20e..d0a03a0943 100644 --- a/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -31,15 +31,16 @@ public class SingleFileUpload : ViewBase var uploadUrl = this.UseUpload(uploadHandler); - void OnDelete(Guid uploadId) + void OnCancel(Guid uploadId) { + // Cancel the in-flight upload and reset state //uploadService.CancelFile(uploadId); //uploadState.Default(); } return Layout.Vertical() | Text.H1("Single File Upload") - | uploadState.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload").HandleDelete(OnDelete) + | uploadState.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose a file to upload").HandleCancel(OnCancel) | uploadState.ToDetails() .Builder(e => e!.Length, e => e.Func((long x) => Utils.FormatBytes(x))) .Builder(e => e!.Progress, e => e.Func((float x) => x.ToString("P0"))) @@ -116,7 +117,7 @@ public class MultipleFilesUpload : ViewBase } }); - void OnDelete(Guid fileId) + void OnCancel(Guid fileId) { var file = selectedFiles.Value.FirstOrDefault(f => f.Id == fileId); if (file == null) return; @@ -127,7 +128,7 @@ void OnDelete(Guid fileId) var layout = Layout.Vertical() | Text.H1("Multiple Files Upload") - | selectedFiles.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose files to upload").HandleDelete(OnDelete) + | selectedFiles.ToFileInput(uploadUrl).Accept("*/*").Placeholder("Choose files to upload").HandleCancel(OnCancel) | selectedFiles.Value.ToTable() .Width(Size.Full()) .Builder(e => e.Length, e => e.Func((long x) => Utils.FormatBytes(x))) diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index 922bcac0e3..5cb01e2a0b 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -68,8 +68,8 @@ 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 a file is deleted (passes FileInput.Id as parameter). - [Event] public Func, ValueTask>? OnDelete { get; set; } + /// Gets or sets the event handler called when a file upload is canceled (passes FileUpload.Id as parameter). + [Event] public Func, ValueTask>? OnCancel { get; set; } /// /// Returns the types that this file input can bind to. @@ -395,33 +395,33 @@ public static FileInputBase HandleBlur(this FileInputBase widget, Action onBlur) } /// - /// Sets the delete event handler for the file input. + /// Sets the cancel event handler for the file input. /// /// The file input to configure. - /// The event handler to call when a file is deleted, receives the FileInput.Id. + /// The event handler to call when a file is canceled, receives the FileUpload.Id. [OverloadResolutionPriority(1)] - public static FileInputBase HandleDelete(this FileInputBase widget, Func, ValueTask> onDelete) + public static FileInputBase HandleCancel(this FileInputBase widget, Func, ValueTask> onCancel) { - return widget with { OnDelete = onDelete }; + return widget with { OnCancel = onCancel }; } /// - /// Sets the delete event handler for the file input. + /// Sets the cancel event handler for the file input. /// /// The file input to configure. - /// The event handler to call when a file is deleted, receives the FileInput.Id. - public static FileInputBase HandleDelete(this FileInputBase widget, Action> onDelete) + /// The event handler to call when a file is canceled, receives the FileUpload.Id. + public static FileInputBase HandleCancel(this FileInputBase widget, Action> onCancel) { - return widget.HandleDelete(onDelete.ToValueTask()); + return widget.HandleCancel(onCancel.ToValueTask()); } /// - /// Sets a simple delete event handler for the file input. + /// Sets a simple cancel event handler for the file input. /// /// The file input to configure. - /// The simple action to perform when a file is deleted, receives the FileInput.Id. - public static FileInputBase HandleDelete(this FileInputBase widget, Action onDelete) + /// The simple action to perform when a file is canceled, receives the FileUpload.Id. + public static FileInputBase HandleCancel(this FileInputBase widget, Action onCancel) { - return widget.HandleDelete(e => { onDelete(e.Value); return ValueTask.CompletedTask; }); + return widget.HandleCancel(e => { onCancel(e.Value); return ValueTask.CompletedTask; }); } } diff --git a/frontend/src/widgets/inputs/FileInputWidget.tsx b/frontend/src/widgets/inputs/FileInputWidget.tsx index 25423fc314..86d8eed961 100644 --- a/frontend/src/widgets/inputs/FileInputWidget.tsx +++ b/frontend/src/widgets/inputs/FileInputWidget.tsx @@ -63,7 +63,7 @@ export const FileInputWidget: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const inputRef = useRef(null); - const hasDeleteHandler = events.includes('OnDelete'); + const hasCancelHandler = events.includes('OnCancel'); const uploadFile = useCallback( async (file: File): Promise => { @@ -130,17 +130,17 @@ export const FileInputWidget: React.FC = ({ [multiple, uploadFile, maxFiles] ); - const handleDelete = useCallback( + const handleCancel = useCallback( (fileId: string) => { - if (hasDeleteHandler) { - handleEvent('OnDelete', id, [fileId]); + if (hasCancelHandler) { + handleEvent('OnCancel', id, [fileId]); } // Also clear file input to allow re-selecting same file if (inputRef.current) { inputRef.current.value = ''; } }, - [hasDeleteHandler, handleEvent, id] + [hasCancelHandler, handleEvent, id] ); const handleDragEnter = useCallback( @@ -236,7 +236,7 @@ export const FileInputWidget: React.FC = ({ )} - {hasDeleteHandler && ( + {hasCancelHandler && (