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 c98a5174cd..e525c15106 100644 --- a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md +++ b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Uploads.md @@ -6,215 +6,276 @@ searchHints: - drag-drop - attachments - images +imports: + - Ivy.Services + - Ivy.Core.Helpers --- # Uploads -Handle file uploads robustly with support for single/multiple files, drag-and-drop interfaces, and status feedback for various file types. +Handle file uploads with automatic state management, progress tracking, and support for single/multiple files with built-in validation. ## Basic Usage -The most common way to handle uploads is using the FileInput component: +The upload system uses three key components working together: + +1. **State for Files**: Holds the uploaded file(s) data in memory +2. **UseUpload Hook**: Creates an upload endpoint and returns an upload context +3. **MemoryStreamUploadHandler**: Automatically manages file data in state + +Here's a simple example: ```csharp demo-below -public class FileUploadView : ViewBase +public class SingleFileUpload : ViewBase { public override object? Build() { - var files = UseState(() => null); - var uploadUrl = this.UseUpload(fileBytes => { }, "*/*", "file"); - - return files.ToFileInput(uploadUrl, "Choose a file"); + var uploadState = UseState?>(); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(uploadState)) + .Accept("*/*") + .MaxFileSize(10 * 1024 * 1024); // 10 MB + + return Layout.Vertical() + | Text.H1("Single File Upload") + | uploadState.ToFileInput(upload).Placeholder("Choose a file to upload") + | uploadState.ToDetails(); } } ``` ## How It Works -The upload system connects three key pieces: - -1. **UseUpload Hook**: Creates a server-side upload endpoint and returns a state containing the upload URL -2. **State for Files**: A state variable that holds the selected file(s) information -3. **ToFileInput Extension**: Connects the file state to the upload URL, creating a file input widget - -Here's how they work together: +The upload flow is automatic: ```csharp -var client = UseService(); - -// 1. Create upload handler - returns state with -// URL like "/upload/{connectionId}/{uploadId}" -var uploadUrl = this.UseUpload( - fileBytes => { - // 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 -); - -// 2. Create state to hold file information -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 -files.ToFileInput(uploadUrl, "Choose Files") +// 1. Create state to hold file data +var uploadState = UseState?>(); + +// 2. Create upload context with handler - the handler automatically: +// - Receives the file stream +// - Reads it into memory +// - Updates the state with file data +// - Tracks upload progress +var upload = this.UseUpload(MemoryStreamUploadHandler.Create(uploadState)) + .Accept("image/*") // Configure accepted file types + .MaxFileSize(5 * 1024 * 1024); // Configure max file size (5 MB) + +// 3. Connect to a file input - this creates a widget that: +// - Shows a file picker +// - Handles file selection +// - Uploads to the server +// - Updates the state automatically +uploadState.ToFileInput(upload).Placeholder("Choose an image"); + +// 4. Access uploaded file data +if (uploadState.Value != null) +{ + var fileName = uploadState.Value.FileName; + var fileSize = uploadState.Value.Length; + var fileData = uploadState.Value.Content; // byte[] containing file data + var progress = uploadState.Value.Progress; // 0.0 to 1.0 +} ``` -### Upload Status Feedback +## Multiple File Uploads -Provide feedback during file upload using toasts: +Use `ImmutableArray>` for multiple files: ```csharp demo-below -public class UploadWithStatusView : ViewBase +public class MultipleFilesUpload : ViewBase { public override object? Build() { - var client = UseService(); - var files = UseState(() => null); - var uploadUrl = this.UseUpload( - fileBytes => { - try { - client.Toast($"Successfully uploaded {fileBytes.Length} bytes", "Upload Complete"); - } catch (Exception ex) { - client.Toast(ex); - } - }, - "application/octet-stream", - "uploaded-file" - ); - - return files.ToFileInput(uploadUrl, "Upload File"); + var selectedFiles = UseState(ImmutableArray.Create>()); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(selectedFiles)) + .Accept("*/*") + .MaxFileSize(10 * 1024 * 1024); + + return Layout.Vertical() + | Text.H1("Multiple Files Upload") + | selectedFiles.ToFileInput(upload).Placeholder("Choose files to upload") + | 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"))) + .Remove(e => e.Id); } } ``` -### File Validation +## File Validation -Validate files before upload: +Configure validation directly on the upload context: ```csharp demo-below -public class ValidatedUploadView : ViewBase +public class FileUploadValidation : ViewBase { public override object? Build() { - var client = UseService(); - var error = UseState(() => null); - var files = UseState(() => null); - var uploadUrl = this.UseUpload( - fileBytes => { - if (fileBytes.Length > 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"); - }, - "image/jpeg", - "uploaded-image" - ); - - return Layout.Vertical( - files.ToFileInput(uploadUrl, "Upload Image").Accept(".jpg,.jpeg,.png"), - error.Value != null - ? new Callout(error.Value, variant: CalloutVariant.Error) - : null - ); + var selectedFiles = UseState(ImmutableArray.Create>()); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(selectedFiles)) + .Accept("image/*") // Only images + .MaxFileSize(5 * 1024 * 1024) // 5 MB per file + .MaxFiles(3); // Maximum 3 files total + + return Layout.Vertical() + | Text.H1("Upload Validation") + | selectedFiles.ToFileInput(upload).Placeholder("Choose up to 3 images (max 5 MB each)") + | 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"))) + .Remove(e => e.Id); } } ``` -### Best Practices +Validation errors are automatically shown to the user via toast notifications. -1. **File Validation**: Validate file types and sizes using `Accept()` and custom validation -2. **Status Feedback**: Provide clear feedback about upload status (processing, success, errors) -3. **Error Handling**: Implement proper error handling in your upload handler -4. **Security**: Always validate files on the server side -5. **User Experience**: Show file information (name, size) after selection and clear status messages +## File Content Types - +The upload handler supports both binary and text content: -## Examples +```csharp +// Binary content (default) +var binaryState = UseState?>(); +var binaryUpload = this.UseUpload(MemoryStreamUploadHandler.Create(binaryState)); + +// Text content +var textState = UseState?>(); +var textUpload = this.UseUpload(MemoryStreamUploadHandler.Create(textState, Encoding.UTF8)); -
- -Image Upload with Preview - - +// Multiple binary files +var filesState = UseState(ImmutableArray.Create>()); +var filesUpload = this.UseUpload(MemoryStreamUploadHandler.Create(filesState)); + +// Multiple text files +var textFilesState = UseState(ImmutableArray.Create>()); +var textFilesUpload = this.UseUpload(MemoryStreamUploadHandler.Create(textFilesState, Encoding.UTF8)); +``` + +## Dialog Integration + +Use ephemeral state for temporary file selection in dialogs: ```csharp demo-below -public class ImageUploadView : ViewBase +public class DialogFileUpload : ViewBase { public override object? Build() { - var client = UseService(); - var preview = UseState(() => null); - var files = UseState(() => null); - var uploadUrl = this.UseUpload( - fileBytes => { - // 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"); - }, - "image/jpeg", - "uploaded-image" - ); - - return Layout.Vertical( - files.ToFileInput(uploadUrl, "Upload Image").Accept("image/*"), - preview.Value != null - ? new Image(preview.Value) - : null - ); + var selectedFile = UseState?>(); + + // Ephemeral state used inside the dialog while picking a file + var dialogFile = UseState?>(); + var uploadContext = this.UseUpload(MemoryStreamUploadHandler.Create(dialogFile)) + .Accept("*/*") + .MaxFileSize(10 * 1024 * 1024); + + var isOpen = UseState(false); + + var dialog = isOpen.Value + ? new Dialog( + _ => { isOpen.Value = false; dialogFile.Reset(); return ValueTask.CompletedTask; }, + new DialogHeader("Select File"), + new DialogBody( + dialogFile.ToFileInput(uploadContext).Placeholder("Choose a file to upload") + ), + new DialogFooter( + new Button("Cancel", _ => { isOpen.Value = false; dialogFile.Reset(); }, variant: ButtonVariant.Outline), + new Button("Ok", _ => + { + if (dialogFile.Value != null) + selectedFile.Set(dialogFile.Value); + isOpen.Value = false; + dialogFile.Reset(); + }) + ) + ) + : null; + + return Layout.Vertical() + | Text.H1("Dialog Upload") + | new Button("Open Dialog", _ => { dialogFile.Reset(); isOpen.Value = true; }) + | (selectedFile.Value != null + ? selectedFile.ToDetails() + : Text.P("No file selected")) + | dialog; } } - ``` - -
+## Form Integration -
- -Multiple File Upload with List - - +Integrate file uploads in forms using the context-aware `.Builder()` overload: ```csharp demo-below -public class MultiFileUploadView : ViewBase +public record FormFileUploadModel +{ + [Required] + public FileUpload? Attachment1 { get; set; } + + public FileUpload? Attachment2 { get; set; } +} + +public class FormFileUpload : ViewBase { public override object? Build() { - var client = UseService(); - 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"); - // 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() - ? new List(uploadedFiles.Value.Select(f => Text.Inline(f))) - : null - ); + var model = UseState(() => new FormFileUploadModel()); + + var form = model.ToForm() + .Builder(e => e.Attachment1, (state, view) => + { + var uploadContext = view.UseUpload(MemoryStreamUploadHandler.Create(state)) + .Accept("image/jpeg") + .MaxFileSize(1 * 1024 * 1024); + return state.ToFileInput(uploadContext); + }) + .Label(x => x.Attachment1, "Attachment1 image/jpeg (Required)") + .Builder(e => e.Attachment2, (state, view) => + { + var uploadContext = view.UseUpload(MemoryStreamUploadHandler.Create(state)) + .Accept("application/pdf") + .MaxFileSize(5 * 1024 * 1024); + return state.ToFileInput(uploadContext); + }) + .Label(x => x.Attachment2, "Attachment2 application/pdf (Optional)"); + + return Layout.Vertical() + | Text.H1("Form with File Upload") + | form + | model.Value.Attachment1?.ToDetails() + | model.Value.Attachment2?.ToDetails(); } } ``` - -
+## FileUpload Record + +The `FileUpload` record contains all file information: + +```csharp +public record FileUpload +{ + public Guid Id { get; init; } // Unique identifier + public string FileName { get; init; } // Original file name + public string ContentType { get; init; } // MIME type + public long Length { get; init; } // File size in bytes + public float Progress { get; init; } // Upload progress (0.0 to 1.0) + public TContent Content { get; init; } // File content (byte[] or string) + public FileUploadStatus Status { get; init; } // Pending, Uploading, Completed, Failed, Aborted +} +``` + +## Best Practices + +1. **Choose the Right Content Type**: Use `byte[]` for binary files, `string` for text files +2. **Set Validation Rules**: Always configure `Accept()` and `MaxFileSize()` to guide users +3. **Limit Multiple Uploads**: Use `MaxFiles()` when accepting multiple files +4. **Progress Feedback**: The `Progress` property automatically updates during upload +5. **State Reset**: Use `state.Reset()` to clear uploaded files +6. **Form Integration**: Use the context-aware `.Builder()` overload for proper hook access + + 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..c1529806bc 100644 --- a/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Widgets.md +++ b/Ivy.Docs.Shared/Docs/01_Onboarding/02_Concepts/Widgets.md @@ -153,7 +153,8 @@ 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 fileUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); var feedbackState = UseState(4); var selectState = UseState(""); var asyncSelectState = UseState((string?)null); @@ -194,7 +195,7 @@ public class InputWidgetsDemo : ViewBase | boolState.ToSwitchInput().Label("Enable notifications") ).Title("BoolInput").Description("Checkbox input").Height(Size.Units(60)) | new Card( - fileState.ToFileInput().Placeholder("Upload file") + fileState.ToFileInput(fileUpload).Placeholder("Upload file") ).Title("FileInput").Description("File upload").Height(Size.Units(60)) | new Card( dateRangeState.ToDateRangeInput().Placeholder("Select date range") 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..b2cd95de20 100644 --- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md +++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/AudioRecorder.md @@ -6,55 +6,185 @@ searchHints: - audio - capture - sound +imports: + - Ivy.Services --- # Audio Recorder -Enable audio recording with a flexible interface for capturing user audio input. +Enable audio recording with a flexible interface for capturing user audio input with automatic upload support. -The `AudioRecorder` widget allows users to record audio using their microphone. It provides an audio recording interface with options for audio formats, audio upload endpoint, and upload chunking. This widget is for recording audio, not playing it. +The `AudioRecorder` widget allows users to record audio using their microphone. It provides an audio recording interface with options for audio formats, automatic uploads, and chunked streaming. This widget is for recording audio, not playing it. ## Basic Usage -Here's a simple example of a `AudioRecorder` that allows users to record audio: +Here's a simple example of an `AudioRecorder` that uploads audio to the server: ```csharp demo-below public class BasicAudioRecorderDemo : ViewBase { public override object? Build() { - var uploadUrl = this.UseUpload( - fileBytes => { - // Process uploaded file bytes - Console.WriteLine($"Received {fileBytes.Length} bytes"); + // Create an upload handler for audio chunks + var upload = this.UseUpload( + (fileUpload, stream, cancellationToken) => { + // Process uploaded audio chunk + Console.WriteLine($"Received {fileUpload.Length} bytes of audio"); + return Task.CompletedTask; }, - "application/octet-stream", - "uploaded-audio" + defaultContentType: "audio/webm" // Match the recorder's mime type ); - return new AudioRecorder("Start recording", "Recording audio..").UploadUrl(uploadUrl.Value).ChunkInterval(3000); - } -} + return new AudioRecorder(upload.Value, "Start recording", "Recording audio...") + .ChunkInterval(3000); // Upload every 3 seconds + } +} ``` +## Upload Modes + +The audio recorder supports two upload modes: + +### Chunked Upload (Streaming) + +Upload audio in chunks while recording: + +```csharp demo-below +public class ChunkedUploadDemo : ViewBase +{ + public override object? Build() + { + var upload = this.UseUpload( + (fileUpload, stream, cancellationToken) => { + // Each chunk arrives as recording continues + Console.WriteLine($"Chunk received: {fileUpload.Length} bytes"); + return Task.CompletedTask; + }, + defaultContentType: "audio/webm" + ); + + return new AudioRecorder(upload.Value, "Record with streaming", "Streaming...") + .ChunkInterval(1000); // Upload every 1 second + } +} +``` + +### Single Upload + +Upload the complete recording when stopped: + +```csharp demo-below +public class SingleUploadDemo : ViewBase +{ + public override object? Build() + { + var upload = this.UseUpload( + (fileUpload, stream, cancellationToken) => { + // Complete recording arrives when user stops + Console.WriteLine($"Recording complete: {fileUpload.Length} bytes"); + return Task.CompletedTask; + }, + defaultContentType: "audio/webm" + ); + + // No ChunkInterval = upload when recording stops + return new AudioRecorder(upload.Value, "Record", "Recording..."); + } +} +``` + +## Audio Format + +Specify the audio format using MIME type: + +```csharp demo-below +public class AudioFormatDemo : ViewBase +{ + public override object? Build() + { + // Use webm format (most compatible) + var webmUpload = this.UseUpload( + (fileUpload, stream, cancellationToken) => Task.CompletedTask, + defaultContentType: "audio/webm" + ); + + return new AudioRecorder(webmUpload.Value, "Record WebM", "Recording WebM...") + .MimeType("audio/webm"); + } +} +``` + + +Use `audio/webm` for best browser compatibility. Other formats like `audio/mp4` or `audio/wav` may work depending on the browser. + + ## Styling -`AudioRecorder` can be customized with various styling options: +### Size Variants + +Control the size of the audio recorder: + +```csharp demo-below +public class SizeVariantsDemo : ViewBase +{ + public override object? Build() + { + var upload = this.UseUpload( + (fileUpload, stream, cancellationToken) => Task.CompletedTask, + defaultContentType: "audio/webm" + ); + + return Layout.Vertical() + | Text.H2("Small") + | new AudioRecorder(upload.Value, "Record", "Recording...").Small() + | Text.H2("Medium (Default)") + | new AudioRecorder(upload.Value, "Record", "Recording...") + | Text.H2("Large") + | new AudioRecorder(upload.Value, "Record", "Recording...").Large(); + } +} +``` -### Disabled +### Custom Labels -To render a disabled `AudioRecorder` control, the `Disabled` function should be used. +Customize the labels shown when idle and recording: + +```csharp demo-below +public class CustomLabelsDemo : ViewBase +{ + public override object? Build() + { + var upload = this.UseUpload( + (fileUpload, stream, cancellationToken) => Task.CompletedTask, + defaultContentType: "audio/webm" + ); + + return new AudioRecorder(upload.Value) + .Label("Click to start voice memo") + .RecordingLabel("Recording your voice..."); + } +} +``` + +### Disabled State + +Disable the audio recorder: ```csharp demo-below public class AudioRecorderDisabledDemo : ViewBase { public override object? Build() { - return new AudioRecorder("Disabled audio recorder", disabled: true); + var upload = this.UseUpload( + (fileUpload, stream, cancellationToken) => Task.CompletedTask, + defaultContentType: "audio/webm" + ); + + return new AudioRecorder(upload.Value, "Recording disabled", disabled: true); } -} +} ``` - \ No newline at end of file + 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..8910c701e0 100644 --- a/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md +++ b/Ivy.Docs.Shared/Docs/02_Widgets/02_Inputs/File.md @@ -6,114 +6,263 @@ searchHints: - drag-drop - browse - files +imports: + - Ivy.Services + - Ivy.Core.Helpers --- # FileInput -Enable file uploads with a flexible interface supporting type filtering, size limits, and single or multiple file selection. +Enable file uploads with automatic state management, progress tracking, type filtering, size limits, and support for single or multiple file selections. -The `FileInput` widget allows users to upload files. It provides a file selector interface with options for file type filtering, size limitations, and support for single or multiple file selections. +The `FileInput` widget provides a file upload interface with built-in validation, progress tracking, and drag-and-drop support. It works seamlessly with the upload system to automatically manage file data in state. ## Basic Usage -Here's a simple example of a `FileInput` that allows users to select files: +To create a file input, use `ToFileInput()` with an upload context from `UseUpload()`: ```csharp demo-below public class BasicFileInputDemo : ViewBase { public override object? Build() - { - var fileState = this.UseState((FileInput?)null); - var selected = fileState.Value?.Name; + { + var fileState = UseState?>(); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(fileState)) + .Accept(".txt,.pdf,.cs") + .MaxFileSize(5 * 1024 * 1024); // 5 MB + + var selected = fileState.Value?.FileName ?? "No file selected"; + return Layout.Vertical() - | fileState.ToFileInput() - .Placeholder("Select a file") - .Accept(".txt,.pdf,.cs") - | Text.Large(selected); - } -} + | fileState.ToFileInput(upload).Placeholder("Select a file") + | Text.Large(selected); + } +} ``` -To create a file upload input, `ToFileInput` is the recommended function. +## Single vs Multiple Files -## Variants - -The `FileInput` widget supports different variants to suit various use cases. It has a variant -where users can select a single file or multiple files and drag and drop them in the file upload -section. The following demo showcases this. +The type of state determines whether single or multiple files can be selected: ```csharp demo-below -public class FileDropDemo : ViewBase -{ +public class SingleVsMultipleDemo : ViewBase +{ public override object? Build() - { - var fileState = this.UseState((FileInput?)null); - var fileStates = this.UseState((IEnumerable?)null); - return Layout.Vertical() - | fileState.ToFileInput().Variant(FileInputs.Drop) - | fileStates.ToFileInput().Variant(FileInputs.Drop); + { + // Single file - use nullable FileUpload + var singleFile = UseState?>(); + var singleUpload = this.UseUpload(MemoryStreamUploadHandler.Create(singleFile)); + + // Multiple files - use ImmutableArray> + var multipleFiles = UseState(ImmutableArray.Create>()); + var multipleUpload = this.UseUpload(MemoryStreamUploadHandler.Create(multipleFiles)); + + return Layout.Vertical() + | Text.H2("Single File") + | singleFile.ToFileInput(singleUpload).Placeholder("Choose one file") + | Text.H2("Multiple Files") + | multipleFiles.ToFileInput(multipleUpload).Placeholder("Choose multiple files"); } -} - +} ``` -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 `ImmutableArray<FileUpload<T>>` as your state type. You do **not** need to explicitly set a `.Multiple()` property. -## Styling +## Variants + +The `FileInput` widget supports different visual variants: + +```csharp demo-below +public class FileInputVariantsDemo : ViewBase +{ + public override object? Build() + { + var defaultFile = UseState?>(); + var defaultUpload = this.UseUpload(MemoryStreamUploadHandler.Create(defaultFile)); + + var dropFile = UseState?>(); + var dropUpload = this.UseUpload(MemoryStreamUploadHandler.Create(dropFile)); + + var dropFiles = UseState(ImmutableArray.Create>()); + var dropFilesUpload = this.UseUpload(MemoryStreamUploadHandler.Create(dropFiles)); + + return Layout.Vertical() + | Text.H2("Default Variant") + | defaultFile.ToFileInput(defaultUpload).Placeholder("Browse for file") + | Text.H2("Drop Variant (Single)") + | dropFile.ToFileInput(dropUpload).Variant(FileInputs.Drop) + | Text.H2("Drop Variant (Multiple)") + | dropFiles.ToFileInput(dropFilesUpload).Variant(FileInputs.Drop); + } +} +``` -`FileInput` can be customized with various styling options: +## File Type Filtering -### Disabled +Use `.Accept()` on the upload context to filter file types: + +```csharp demo-below +public class FileTypeFilteringDemo : ViewBase +{ + public override object? Build() + { + var imageFile = UseState?>(); + var imageUpload = this.UseUpload(MemoryStreamUploadHandler.Create(imageFile)) + .Accept("image/*"); // Only images + + var documentFile = UseState?>(); + var documentUpload = this.UseUpload(MemoryStreamUploadHandler.Create(documentFile)) + .Accept(".pdf,.doc,.docx"); // Specific file extensions + + return Layout.Vertical() + | Text.H2("Images Only") + | imageFile.ToFileInput(imageUpload).Placeholder("Choose an image") + | Text.H2("Documents Only") + | documentFile.ToFileInput(documentUpload).Placeholder("Choose a document"); + } +} +``` -To render a disabled `FileInput` control, the `Disabled` function should be used. +## File Size Limits + +Configure maximum file size with `.MaxFileSize()`: ```csharp demo-below -public class FileInputDisabledDemo : ViewBase +public class FileSizeLimitDemo : ViewBase { public override object? Build() { - var fileState = this.UseState((FileInput?)null); - return fileState.ToFileInput() - .Placeholder("Select a file") - .Accept(".jpg,.png") - .Disabled(); + var file = UseState?>(); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(file)) + .MaxFileSize(2 * 1024 * 1024); // 2 MB limit + + return Layout.Vertical() + | Text.H2("2 MB Size Limit") + | file.ToFileInput(upload).Placeholder("Max 2 MB") + | (file.Value != null + ? Text.P($"Selected: {file.Value.FileName} ({Utils.FormatBytes(file.Value.Length)})") + : null); } -} +} ``` - +## Multiple Files Limit + +When accepting multiple files, use `.MaxFiles()` to set a maximum count: + +```csharp demo-below +public class MaxFilesDemo : ViewBase +{ + public override object? Build() + { + var files = UseState(ImmutableArray.Create>()); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(files)) + .MaxFiles(3) // Maximum 3 files + .MaxFileSize(5 * 1024 * 1024); + + return Layout.Vertical() + | Text.H2("Maximum 3 Files") + | files.ToFileInput(upload).Placeholder("Choose up to 3 files") + | Text.P($"{files.Value.Length} file(s) selected"); + } +} +``` + +## Upload Progress + +The `FileUpload` record automatically tracks upload progress: + +```csharp demo-below +public class UploadProgressDemo : ViewBase +{ + public override object? Build() + { + var files = UseState(ImmutableArray.Create>()); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(files)); + + return Layout.Vertical() + | Text.H2("Upload Progress") + | files.ToFileInput(upload).Placeholder("Choose files") + | files.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"))) + .Remove(e => e.Id); + } +} +``` + +## Styling + +### Placeholder Text + +Set custom placeholder text: + +```csharp demo-below +public class PlaceholderDemo : ViewBase +{ + public override object? Build() + { + var file = UseState?>(); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(file)); + + return file.ToFileInput(upload) + .Placeholder("Drag and drop your file here or click to browse"); + } +} +``` -## Examples +### Size Variants -
- -Multiple File Selection - - +Control the size of the file input: ```csharp demo-below -public class MultiFileSelectionDemo : ViewBase +public class FileInputSizeVariantsDemo : ViewBase { public override object? Build() - { - - 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])}"); - } + { + var smallFile = UseState?>(); + var smallUpload = this.UseUpload(MemoryStreamUploadHandler.Create(smallFile)); + + var mediumFile = UseState?>(); + var mediumUpload = this.UseUpload(MemoryStreamUploadHandler.Create(mediumFile)); + + var largeFile = UseState?>(); + var largeUpload = this.UseUpload(MemoryStreamUploadHandler.Create(largeFile)); + return Layout.Vertical() - | filesState.ToFileInput() - | Text.Large(selected); + | Text.H2("Small") + | smallFile.ToFileInput(smallUpload).Small() + | Text.H2("Medium (Default)") + | mediumFile.ToFileInput(mediumUpload) + | Text.H2("Large") + | largeFile.ToFileInput(largeUpload).Large(); } } +``` + +### Disabled State +Disable the file input: + +```csharp demo-below +public class FileInputDisabledDemo : ViewBase +{ + public override object? Build() + { + var fileState = UseState?>(); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(fileState)); + + return fileState.ToFileInput(upload) + .Placeholder("This file input is disabled") + .Accept(".jpg,.png") + .Disabled(); + } +} ``` - -
+ diff --git a/Ivy.Docs.Shared/GlobalUsings.cs b/Ivy.Docs.Shared/GlobalUsings.cs index a827d5609b..766a70d895 100644 --- a/Ivy.Docs.Shared/GlobalUsings.cs +++ b/Ivy.Docs.Shared/GlobalUsings.cs @@ -27,5 +27,6 @@ global using Ivy.Docs.Shared.Helpers; global using Ivy.Views.DataTables; global using Ivy.Views.Kanban; +global using Ivy.Services; namespace Ivy.Docs.Shared; \ No newline at end of file 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.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 new file mode 100644 index 0000000000..0ccf819d03 --- /dev/null +++ b/Ivy.Samples.Shared/Apps/Concepts/UploadApp.cs @@ -0,0 +1,212 @@ +using Ivy.Core.Helpers; +using Ivy.Hooks; +using Ivy.Shared; +using Ivy.Views.Builders; +using Ivy.Views.Tables; +using Ivy.Views.Forms; + +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() + { + return Layout.Tabs( + new Tab("Single File", new SingleFileUpload()), + new Tab("Multiple Files", new MultipleFilesUpload()), + new Tab("Dialog", new DialogFileUpload()), + new Tab("Form", new FormFileUpload()), + new Tab("Validation", new FileUploadValidation()) + ).Variant(TabsVariant.Content); + } +} + +public class SingleFileUpload : ViewBase +{ + public override object? Build() + { + var uploadState = UseState?>(); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(uploadState)) + .Accept("*/*").MaxFileSize(10 * 1024 * 1024); + + return Layout.Vertical() + | Text.H1("Single File Upload") + | uploadState.ToFileInput(upload).Placeholder("Choose a file to upload") + | uploadState.ToDetails() + ; + } +} + +public class MultipleFilesUpload : ViewBase +{ + public override object? Build() + { + var selectedFiles = UseState(ImmutableArray.Create>()); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(selectedFiles)).Accept("*/*").MaxFileSize(10 * 1024 * 1024); + + var layout = Layout.Vertical() + | Text.H1("Multiple Files Upload") + | selectedFiles.ToFileInput(upload).Placeholder("Choose files to upload") + | 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"))) + .Remove(e => e.Id); + + return layout; + } +} + +public class DialogFileUpload : ViewBase +{ + public override object? Build() + { + var selectedFile = UseState?>(); + + // Ephemeral state used inside the dialog while picking a file + var dialogFile = UseState?>(); + var uploadContext = this.UseUpload(MemoryStreamUploadHandler.Create(dialogFile)).Accept("*/*").MaxFileSize(10 * 1024 * 1024); + + // Dialog visibility state + var isOpen = UseState(false); + + ValueTask OnDialogClose(Event _) + { + isOpen.Value = false; + dialogFile.Reset(); + return ValueTask.CompletedTask; + } + + var openButton = new Button("Open Dialog", _ => + { + dialogFile.Reset(); + isOpen.Value = true; + }); + + var dialog = isOpen.Value + ? new Dialog( + OnDialogClose, + new DialogHeader("Select File"), + new DialogBody( + Layout.Vertical() + | dialogFile.ToFileInput(uploadContext) + .Accept("*/*") + .Placeholder("Choose a file to upload") + ), + new DialogFooter( + new Button("Cancel", _ => + { + isOpen.Value = false; + dialogFile.Reset(); + }, variant: ButtonVariant.Outline), + new Button("Ok", _ => + { + if (dialogFile.Value != null) + { + selectedFile.Set(dialogFile.Value); + } + isOpen.Value = false; + dialogFile.Reset(); + }) + ) + ) + : null; + + return Layout.Vertical() + | Text.H1("Dialog Upload") + | openButton + | (selectedFile.Value != null + ? selectedFile.ToDetails() + : Text.P("No file selected")) + | dialog; + } +} + +public record FileUploadValidationSettings +{ + public long MaxFileSize { get; init; } = 5 * 1024 * 1024; // 5 MB + + public int MaxFiles { get; init; } = 3; + + public string? Accept { get; init; } + + public string? Placeholder { get; init; } = null!; +} + + +public class FileUploadValidation : ViewBase +{ + public override object? Build() + { + var settings = UseState(new FileUploadValidationSettings()); + return Layout.Horizontal() + | new FileUploadValidationUploader(settings.Value).Key(settings) + | new Separator() + | settings.ToForm(submitTitle: "Update").WithLayout().Width(120); + } +} + +public class FileUploadValidationUploader(FileUploadValidationSettings settings) : ViewBase +{ + public override object? Build() + { + var selectedFiles = UseState(ImmutableArray.Create>()); + var upload = this.UseUpload(MemoryStreamUploadHandler.Create(selectedFiles)) + .Accept(settings.Accept!) + .MaxFileSize(settings.MaxFileSize) + .MaxFiles(settings.MaxFiles); + + var layout = Layout.Vertical() + | Text.H1("Upload Validation") + | selectedFiles.ToFileInput(upload).Placeholder(settings.Placeholder!) + | 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"))) + .Remove(e => e.Id); + + return layout; + } +} + +public record FormFileUploadModel +{ + [Required] + public FileUpload? Attachment1 { get; set; } + + public FileUpload? Attachment2 { get; set; } +} + +public class FormFileUpload : ViewBase +{ + public override object? Build() + { + var model = UseState(() => new FormFileUploadModel()); + + var form = model.ToForm() + .Builder(e => e.Attachment1, (state, view) => + { + var uploadContext = view.UseUpload(MemoryStreamUploadHandler.Create(state)) + .Accept("image/jpeg").MaxFileSize(1 * 1024 * 1024); + return state.ToFileInput(uploadContext); + }) + .Label(x => x.Attachment1, "image/jpeg (Required)") + .Builder(e => e.Attachment2, (state, view) => + { + var uploadContext = view.UseUpload(MemoryStreamUploadHandler.Create(state)) + .Accept("application/pdf").MaxFileSize(5 * 1024 * 1024); + return state.ToFileInput(uploadContext); + }) + .Label(x => x.Attachment2, "application/pdf (Optional)"); + + return Layout.Vertical() + | Text.H1("Form with File Upload") + | form + | model.Value.Attachment1?.ToDetails() + | model.Value.Attachment2?.ToDetails() + ; + } +} + + diff --git a/Ivy.Samples.Shared/Apps/Widgets/Inputs/AudioRecorderApp.cs b/Ivy.Samples.Shared/Apps/Widgets/Inputs/AudioRecorderApp.cs index 79c5b59894..663410ff08 100644 --- a/Ivy.Samples.Shared/Apps/Widgets/Inputs/AudioRecorderApp.cs +++ b/Ivy.Samples.Shared/Apps/Widgets/Inputs/AudioRecorderApp.cs @@ -1,3 +1,4 @@ +using Ivy.Hooks; using Ivy.Shared; namespace Ivy.Samples.Shared.Apps.Widgets.Inputs; @@ -9,28 +10,37 @@ public class AudioRecorderApp() : SampleBase { protected override object? BuildSample() { + // Create a dummy upload for display-only examples + var dummyUpload = this.UseUpload( + (fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask, + defaultContentType: "audio/webm" + ); + return Layout.Vertical() | Text.H1("Audio Recorder Widget Examples") | Text.P("Demonstrates the AudioRecorder widget for capturing audio input. This widget is for recording audio, not playing it. The recorder interface is theme-aware and adapts to light/dark themes.") + | Text.H2("Upload Examples") + | new Card(new AudioRecorderChunkedUpload()).Title("Chunked Upload") + | new Card(new AudioRecorderSingleUpload()).Title("Single Upload") | Text.H2("Sizes") - | CreateSizesSection() + | CreateSizesSection(dummyUpload.Value) | Text.H2("Basic Examples") | Layout.Vertical().Gap(6) | (new Card( Layout.Vertical().Gap(4) | Text.H4("Basic Audio Recorder") | Text.Small("Default audio recorder with microphone access.") - | new AudioRecorder("Start recording", "Recording audio...") + | new AudioRecorder(dummyUpload.Value, "Start recording", "Recording audio...") ).Title("Basic Usage")) | (new Card( Layout.Vertical().Gap(4) | Text.H4("Disabled Audio Recorder") | Text.Small("Audio recorder in disabled state.") - | new AudioRecorder("Start recording", "Recording audio...", disabled: true) + | new AudioRecorder(dummyUpload.Value, "Start recording", "Recording audio...", disabled: true) ).Title("Disabled State")); } - private object CreateSizesSection() + private object CreateSizesSection(UploadContext upload) { return Layout.Grid().Columns(4) | Text.InlineCode("Description") @@ -39,13 +49,80 @@ private object CreateSizesSection() | Text.InlineCode("Large") | Text.InlineCode("Audio Recorder") - | new AudioRecorder("Start recording", "Recording audio...").Small() - | new AudioRecorder("Start recording", "Recording audio...") - | new AudioRecorder("Start recording", "Recording audio...").Large() + | new AudioRecorder(upload, "Start recording", "Recording audio...").Small() + | new AudioRecorder(upload, "Start recording", "Recording audio...") + | new AudioRecorder(upload, "Start recording", "Recording audio...").Large() | Text.InlineCode("Disabled State") - | new AudioRecorder("Start recording", "Recording audio...", disabled: true).Small() - | new AudioRecorder("Start recording", "Recording audio...", disabled: true) - | new AudioRecorder("Start recording", "Recording audio...", disabled: true).Large(); + | new AudioRecorder(upload, "Start recording", "Recording audio...", disabled: true).Small() + | new AudioRecorder(upload, "Start recording", "Recording audio...", disabled: true) + | new AudioRecorder(upload, "Start recording", "Recording audio...", disabled: true).Large(); + } +} + +public class AudioRecorderChunkedUpload : ViewBase +{ + public override object? Build() + { + var client = UseService(); + var audioFile = UseState?>(); + var chunkCount = UseState(0); + + // Use ChunkedMemoryStreamUploadHandler to accumulate chunks into a single file + var upload = this.UseUpload( + ChunkedMemoryStreamUploadHandler.Create(audioFile), + defaultContentType: "audio/webm" + ); + + // Track when chunks arrive + UseEffect(() => + { + if (audioFile.Value?.Length > 0) + { + var newCount = chunkCount.Value + 1; + chunkCount.Set(newCount); + client.Toast($"Chunk {newCount}: Total size {Utils.FormatBytes(audioFile.Value.Length)}", "Audio Chunk Received"); + } + }, audioFile); + + return Layout.Vertical().Gap(4) + | Text.P("Records audio and uploads in 2-second chunks while recording. Each chunk is accumulated into a single file.") + | new AudioRecorder(upload.Value, "Start chunked recording", "Recording (uploading every 2s)...") + .ChunkInterval(2000) + | Text.Small($"Chunks received: {chunkCount.Value}") + | (audioFile.Value != null + ? Text.Small($"Total accumulated: {Utils.FormatBytes(audioFile.Value.Length)}") + : null); + } +} + +public class AudioRecorderSingleUpload : ViewBase +{ + public override object? Build() + { + var client = UseService(); + var audioFile = UseState?>(); + + // Use MemoryStreamUploadHandler for single complete upload + var upload = this.UseUpload( + MemoryStreamUploadHandler.Create(audioFile), + defaultContentType: "audio/webm" + ); + + // Show toast when upload completes + UseEffect(() => + { + if (audioFile.Value?.Status == FileUploadStatus.Finished) + { + client.Toast($"Recording uploaded: {Utils.FormatBytes(audioFile.Value.Length)}", "Upload Complete"); + } + }, audioFile); + + return Layout.Vertical().Gap(4) + | Text.P("Records audio and uploads the complete recording when you stop. No chunks during recording.") + | new AudioRecorder(upload.Value, "Start single recording", "Recording (will upload when stopped)...") + | (audioFile.Value != null + ? Text.Small($"Last upload: {Utils.FormatBytes(audioFile.Value.Length)}") + : Text.Small("No recordings uploaded yet")); } -} \ No newline at end of file +} diff --git a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs index c7e4d8f351..4f2937cec8 100644 --- a/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs +++ b/Ivy.Samples.Shared/Apps/Widgets/Inputs/FileInputApp.cs @@ -1,4 +1,6 @@ -using Ivy.Shared; +using Ivy.Hooks; +using Ivy.Services; +using Ivy.Shared; using Ivy.Views.Builders; using Ivy.Views.Forms; @@ -10,57 +12,63 @@ 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 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 onChangedState = UseState(() => null); - var onChangeLabel = UseState(""); - var onBlurState = UseState(() => null); + var mockFile = new FileUpload { 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); + + // Upload contexts (using simple lambda handlers for demo purposes) + var singleFileUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var singleFileWithValueUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var multipleFilesUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var multipleFilesWithValueUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var placeholderFileUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var textFilesUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var pdfFilesUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var imageFilesUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var singleSizeFileUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var multipleSizeFilesUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var onBlurUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var validatedFilesUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var singleFileWithValidationUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); var dataBinding = Layout.Grid().Columns(3) | Text.InlineCode("FileInput") | (Layout.Vertical() - | singleFile.ToFileInput() - | singleFile.ToFileInput() + | singleFile.ToFileInput(singleFileUpload) + | singleFile.ToFileInput(singleFileUpload) ) | singleFile | Text.InlineCode("FileInput?") | (Layout.Vertical() - | singleFile.ToFileInput() - | singleFile.ToFileInput() + | singleFile.ToFileInput(singleFileUpload) + | singleFile.ToFileInput(singleFileUpload) ) | singleFile | Text.InlineCode("IEnumerable") | (Layout.Vertical() - | multipleFiles.ToFileInput() + | multipleFiles.ToFileInput(multipleFilesUpload) ) | multipleFiles ; @@ -77,14 +85,14 @@ public class FileInputApp : SampleBase | Text.InlineCode("Large") | Text.InlineCode("Single File") - | singleSizeFile.ToFileInput().Small().Placeholder("Small file input") - | singleSizeFile.ToFileInput().Placeholder("Medium file input") - | singleSizeFile.ToFileInput().Large().Placeholder("Large file input") + | singleSizeFile.ToFileInput(singleSizeFileUpload).Small().Placeholder("Small file input") + | singleSizeFile.ToFileInput(singleSizeFileUpload).Placeholder("Medium file input") + | singleSizeFile.ToFileInput(singleSizeFileUpload).Large().Placeholder("Large file input") | Text.InlineCode("Multiple Files") - | multipleSizeFiles.ToFileInput().Small() - | multipleSizeFiles.ToFileInput() - | multipleSizeFiles.ToFileInput().Large() + | multipleSizeFiles.ToFileInput(multipleSizeFilesUpload).Small() + | multipleSizeFiles.ToFileInput(multipleSizeFilesUpload) + | multipleSizeFiles.ToFileInput(multipleSizeFilesUpload).Large() ) | Text.H2("Variants") @@ -97,18 +105,18 @@ public class FileInputApp : SampleBase | Text.InlineCode("With Placeholder") | Text.InlineCode("Single File") - | singleFile.ToFileInput() - | singleFileWithValue.ToFileInput() - | singleFile.ToFileInput().Disabled() - | singleFile.ToFileInput().Invalid("Please select a valid file") - | placeholderFile.ToFileInput().Placeholder("Click to select a file") + | singleFile.ToFileInput(singleFileUpload) + | singleFileWithValue.ToFileInput(singleFileWithValueUpload) + | singleFile.ToFileInput(singleFileUpload).Disabled() + | singleFile.ToFileInput(singleFileUpload).Invalid("Please select a valid file") + | placeholderFile.ToFileInput(placeholderFileUpload).Placeholder("Click to select a file") | Text.InlineCode("Multiple Files") - | multipleFiles.ToFileInput() - | multipleFilesWithValue.ToFileInput() - | multipleFiles.ToFileInput().Disabled() - | multipleFiles.ToFileInput().Invalid("Please select valid files") - | multipleFiles.ToFileInput().Placeholder("Click to select files") + | multipleFiles.ToFileInput(multipleFilesUpload) + | multipleFilesWithValue.ToFileInput(multipleFilesWithValueUpload) + | multipleFiles.ToFileInput(multipleFilesUpload).Disabled() + | multipleFiles.ToFileInput(multipleFilesUpload).Invalid("Please select valid files") + | multipleFiles.ToFileInput(multipleFilesUpload).Placeholder("Click to select files") ) // Data Binding: @@ -124,19 +132,19 @@ public class FileInputApp : SampleBase | Text.Block("Text Files") | Text.InlineCode(".txt,.md,.csv") - | textFiles.ToFileInput().Accept(".txt,.md,.csv").Placeholder("Select text files") + | textFiles.ToFileInput(textFilesUpload).Accept(".txt,.md,.csv").Placeholder("Select text files") | Text.Block("PDF Files") | Text.InlineCode(".pdf") - | pdfFiles.ToFileInput().Accept(".pdf").Placeholder("Select PDF files") + | pdfFiles.ToFileInput(pdfFilesUpload).Accept(".pdf").Placeholder("Select PDF files") | Text.Block("Images") | Text.InlineCode(".jpg,.jpeg,.png,.gif,.webp") - | imageFiles.ToFileInput().Accept(".jpg,.jpeg,.png,.gif,.webp").Placeholder("Select image files") + | imageFiles.ToFileInput(imageFilesUpload).Accept(".jpg,.jpeg,.png,.gif,.webp").Placeholder("Select image files") | Text.Block("All Files") | Text.InlineCode("(default)") - | singleFile.ToFileInput().Placeholder("Select any file") + | singleFile.ToFileInput(singleFileUpload).Placeholder("Select any file") ) // File Count Limits: @@ -148,35 +156,26 @@ public class FileInputApp : SampleBase | Text.Block("No Limit") | Text.Block("Default behavior - no restriction on number of files") - | multipleFiles.ToFileInput().Placeholder("Select unlimited files") + | multipleFiles.ToFileInput(multipleFilesUpload).Placeholder("Select unlimited files") | Text.Block("1 File") | Text.Block("Single file selection only") - | singleFile.ToFileInput().Placeholder("Select one file") + | singleFile.ToFileInput(singleFileUpload).Placeholder("Select one file") | Text.Block("3 Files") | Text.Block("Maximum of 3 files allowed") - | multipleFiles.ToFileInput().MaxFiles(3).Placeholder("Select up to 3 files") + | multipleFiles.ToFileInput(multipleFilesUpload).MaxFiles(3).Placeholder("Select up to 3 files") | Text.Block("5 Files") | Text.Block("Maximum of 5 files allowed") - | multipleFiles.ToFileInput().MaxFiles(5).Placeholder("Select up to 5 files") + | multipleFiles.ToFileInput(multipleFilesUpload).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")), + onBlurState.ToFileInput(onBlurUpload).HandleBlur(e => onBlurLabel.Set("Blur")), onBlurLabel ) @@ -186,11 +185,11 @@ public class FileInputApp : SampleBase | Text.InlineCode("File Input") | 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.ToFileInput(singleFileUpload).Placeholder("Select a text file to view content") + | (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(singleFileUpload).Placeholder("Select a file to view as plain text") + // | (singleFile.Value?.ToPlainText() ?? (object)Text.Block("No file selected")) ) // Backend Validation: @@ -221,10 +220,10 @@ public class FileInputApp : SampleBase | Text.InlineCode("File Input") | Text.Block("Single file with type validation") - | singleFileWithValidation.ToFileInput().Accept(".txt,.pdf").Placeholder("Select .txt or .pdf file") + | singleFileWithValidation.ToFileInput(singleFileWithValidationUpload).Accept(".txt,.pdf").Placeholder("Select .txt or .pdf file") | Text.Block("Multiple files with count and type validation") - | validatedFiles.ToFileInput().MaxFiles(3).Accept("image/*").Placeholder("Select up to 3 image files") + | validatedFiles.ToFileInput(validatedFilesUpload).MaxFiles(3).Accept("image/*").Placeholder("Select up to 3 image files") ) // File Upload Form with Different Sizes: @@ -236,18 +235,22 @@ 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() { var fileModel = UseState(() => new FileModel(null, null, null)); + var profilePhotoUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var documentUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + var certificateUpload = this.UseUpload((fileUpload, stream, cancellationToken) => System.Threading.Tasks.Task.CompletedTask); + return Layout.Vertical() | new Card( fileModel.ToForm() - .Builder(m => m.ProfilePhoto, s => s.ToFileInput().Large().Accept("image/*")) - .Builder(m => m.Document, s => s.ToFileInput().Accept(".pdf,.doc,.docx")) - .Builder(m => m.Certificate, s => s.ToFileInput().Small().Accept(".pdf")) + .Builder(m => m.ProfilePhoto, s => s.ToFileInput(profilePhotoUpload).Large().Accept("image/*")) + .Builder(m => m.Document, s => s.ToFileInput(documentUpload).Accept(".pdf,.doc,.docx")) + .Builder(m => m.Certificate, s => s.ToFileInput(certificateUpload).Small().Accept(".pdf")) .Label(m => m.ProfilePhoto, "Profile Photo") .Label(m => m.Document, "Document") .Label(m => m.Certificate, "Certificate") 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/ConvertJsonNodeTests.cs b/Ivy.Test/ConvertJsonNodeTests.cs index 6c06f68546..415f3db9e9 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; @@ -87,21 +88,17 @@ public void ConvertFileInput() { var json = JsonNode.Parse(""" { - "name": "myfile.txt", - "size": 123, - "type": "text/plain", - "lastModified": "2023-03-14T09:30:00+01:00", - "content": "SGVsbG8=" + "fileName": "myfile.txt", + "length": 123, + "contentType": "text/plain" } """); - var result = (FileInput)Core.Utils.ConvertJsonNode(json!, typeof(FileInput))!; + var result = (FileUpload)Core.Utils.ConvertJsonNode(json!, typeof(FileUpload))!; - Assert.Equal("myfile.txt", result.Name); - Assert.Equal("text/plain", result.Type); - Assert.Equal(123, result.Size); - Assert.Equal(DateTime.Parse("2023-03-14T09:30:00+01:00"), result.LastModified); - Assert.Equal("Hello", Encoding.UTF8.GetString(result.Content!)); + Assert.Equal("myfile.txt", result.FileName); + Assert.Equal("text/plain", result.ContentType); + Assert.Equal(123, result.Length); } private void Test(JsonNode? input, Type type, object? expectedResult) diff --git a/Ivy.Test/FileInputValidationTests.cs b/Ivy.Test/FileInputValidationTests.cs index b5e1194a92..93b364e526 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,23 +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 = name, - Type = type, - Size = 1024, - LastModified = DateTime.Now, - Content = null - }; + return new FileUpload { FileName = name, ContentType = type, Length = 12345 }; } [Fact] public void FileInput_ValidateValue_WithNullValue_ReturnsSuccess() { // Arrange - var fileInput = new FileInput(null, null, "Test"); + var fileInput = new FileInput((FileUpload?)null, "Test"); // Act var result = fileInput.ValidateValue(null); @@ -341,7 +335,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((FileUpload?)null, "Test") with { Accept = ".txt" }; // Act var result = fileInput.ValidateValue(file); @@ -356,7 +350,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((FileUpload?)null, "Test") with { Accept = ".txt" }; // Act var result = fileInput.ValidateValue(file); @@ -370,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?>(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); @@ -389,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?>(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); @@ -409,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?>(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); @@ -428,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?>(null, null, "Test") with { Accept = "image/*" }; + var fileInput = new FileInput?>((IEnumerable?)null, "Test") with { Accept = "image/*" }; // Act var result = fileInput.ValidateValue(files); @@ -447,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?>(null, null, "Test"); + var fileInput = new FileInput?>((IEnumerable?)null, "Test"); // Act var result = fileInput.ValidateValue(files); diff --git a/Ivy.Test/TextInputTests.cs b/Ivy.Test/TextInputTests.cs index 2188828318..4c5ca9a754 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 Reset() + { + return Set(default(T)!); + } + public Type GetStateType() => typeof(T); public IDisposable Subscribe(IObserver observer) diff --git a/Ivy/AppHub.cs b/Ivy/AppHub.cs index 7c1a1912a9..11d32e04f0 100644 --- a/Ivy/AppHub.cs +++ b/Ivy/AppHub.cs @@ -129,8 +129,8 @@ public override async Task OnConnectedAsync() queryableRegistry, server.Args, Context.ConnectionId)); - appServices.AddSingleton(typeof(IUploadService), new UploadService(Context.ConnectionId)); appServices.AddSingleton(typeof(IClientProvider), clientProvider); + appServices.AddSingleton(typeof(IUploadService), new UploadService(Context.ConnectionId, clientProvider)); if (server.AuthProviderType != null) { @@ -210,18 +210,49 @@ public override async Task OnConnectedAsync() LastInteraction = DateTime.UtcNow, }; + var connectionAborted = Context.ConnectionAborted; + appState.EventQueue = new EventDispatchQueue(connectionAborted); + if (appId != AppIds.Chrome && sessionStore.FindChrome(appState) == null) { var navigateArgs = new NavigateArgs(appId, Chrome: GetChromeParam(httpContext)); clientProvider.Redirect(navigateArgs.GetUrl(), replaceHistory: true); } - async void OnWidgetTreeChanged(WidgetTreeChanged[] changes) + void OnWidgetTreeChanged(WidgetTreeChanged[] changes) { try { - logger.LogDebug($"> Update"); - await clientNotifier.NotifyClientAsync(appState.ConnectionId, "Update", changes); + logger.LogDebug("> Update"); + appState.PendingUpdate = changes; + if (appState.UpdateScheduled) return; + appState.UpdateScheduled = true; + + appState.EventQueue?.Enqueue(async () => + { + try { await Task.Delay(16); } + catch + { + } + + try + { + var payload = appState.PendingUpdate; + if (payload != null) + { + clientProvider.Sender.Send("Update", payload); + } + } + catch (Exception ex) + { + logger.LogError(ex, "{ConnectionId}", appState.ConnectionId); + } + finally + { + appState.UpdateScheduled = false; + appState.PendingUpdate = null; + } + }); } catch (Exception e) { @@ -234,7 +265,6 @@ async void OnWidgetTreeChanged(WidgetTreeChanged[] changes) sessionStore.Sessions[Context.ConnectionId] = appState; var connectionId = Context.ConnectionId; - var connectionAborted = Context.ConnectionAborted; await base.OnConnectedAsync(); @@ -245,7 +275,7 @@ async void OnWidgetTreeChanged(WidgetTreeChanged[] changes) await Clients.Caller.SendAsync("Refresh", new { Widgets = widgetTree.GetWidgets().Serialize() - }); + }, cancellationToken: connectionAborted); } catch (Exception e) { @@ -254,12 +284,12 @@ async void OnWidgetTreeChanged(WidgetTreeChanged[] changes) await Clients.Caller.SendAsync("Refresh", new { Widgets = tree.GetWidgets().Serialize() - }); + }, cancellationToken: connectionAborted); } if (server.AuthProviderType != null && appId != AppIds.Auth) { - _ = Task.Run(() => AuthRefreshLoopAsync(connectionId, connectionAborted)); + _ = Task.Run(() => AuthRefreshLoopAsync(connectionId, connectionAborted), connectionAborted); } } catch (Exception ex) @@ -299,6 +329,15 @@ public override async Task OnDisconnectedAsync(Exception? exception) { try { + try + { + var cp = appState.AppServices.GetService(); + if (cp?.Sender is ClientSender cs) + { + cs.Dispose(); + } + } + catch { } appState.Dispose(); } catch (Exception ex) @@ -475,11 +514,8 @@ async Task AbandonConnection(bool resetTokenAndReload) await AbandonConnection(resetTokenAndReload: true); return; } - else - { - logger.LogInformation("AuthRefreshLoop: waiting 30 seconds before retrying for {ConnectionId}", connectionId); - await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); - } + logger.LogInformation("AuthRefreshLoop: waiting 30 seconds before retrying for {ConnectionId}", connectionId); + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); continue; } @@ -508,28 +544,34 @@ public void HotReload() } } - public void Event(string eventName, string widgetId, JsonArray? args) + public Task Event(string eventName, string widgetId, JsonArray? args) { - logger.LogInformation($"Event: {eventName} {widgetId} {args}"); + logger.LogDebug($"Event: {eventName} {widgetId} {args}"); if (!sessionStore.Sessions.TryGetValue(Context.ConnectionId, out var appSession)) { logger.LogWarning($"Event: {eventName} {widgetId} [AppSession Not Found]"); - return; + return Task.CompletedTask; } - try + // Enqueue async event handling to avoid tying up ThreadPool workers + appSession.EventQueue?.Enqueue(async () => { - appSession.LastInteraction = DateTime.UtcNow; - if (!appSession.WidgetTree.TriggerEvent(widgetId, eventName, args ?? new JsonArray())) + try { - logger.LogWarning($"Event '{eventName}' for Widget '{widgetId}' not found."); + appSession.LastInteraction = DateTime.UtcNow; + if (!await appSession.WidgetTree.TriggerEventAsync(widgetId, eventName, args ?? new JsonArray())) + { + logger.LogWarning($"Event '{eventName}' for Widget '{widgetId}' not found."); + } } - } - catch (Exception e) - { - var exceptionHandler = appSession.AppServices.GetService()!; - exceptionHandler.HandleException(e); - } + catch (Exception e) + { + var exceptionHandler = appSession.AppServices.GetService()!; + exceptionHandler.HandleException(e); + } + }); + + return Task.CompletedTask; } public async Task Navigate(string? appId, HistoryState? state) @@ -600,26 +642,91 @@ public async Task Navigate(string? appId, HistoryState? state) } } -public class ClientSender(IClientNotifier clientNotifier, string connectionId) : IClientSender +public class ClientSender : IClientSender, IDisposable { - public void Send(string method, object? data) + private readonly IClientNotifier _clientNotifier; + private readonly string _connectionId; + private readonly System.Threading.Channels.Channel<(string method, object? data)> _channel; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _worker; + + public ClientSender(IClientNotifier clientNotifier, string connectionId) { - // Fire and forget, but handle exceptions to prevent crashes - _ = Task.Run(async () => + _clientNotifier = clientNotifier; + _connectionId = connectionId; + var options = new System.Threading.Channels.BoundedChannelOptions(2048) + { + SingleReader = true, + SingleWriter = false, + FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest + }; + _channel = System.Threading.Channels.Channel.CreateBounded<(string, object?)>(options); + + _worker = Task.Factory.StartNew(async () => { try { - await clientNotifier.NotifyClientAsync(connectionId, method, data); - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] Failed to send {method} to client {connectionId}: {ex.Message}"); + while (await _channel.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false)) + { + while (_channel.Reader.TryRead(out var msg)) + { + try + { + await _clientNotifier.NotifyClientAsync(_connectionId, msg.method, msg.data).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Failed to send {msg.method} to client {_connectionId}: {ex.Message}"); + } + } + } } - }); + catch (OperationCanceledException) { } + }, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } + + public void Send(string method, object? data) + { + if (!_channel.Writer.TryWrite((method, data))) + { + _ = _channel.Writer.WriteAsync((method, data), _cts.Token); + } + } + + public void Dispose() + { + try + { + _cts.Cancel(); + } + catch + { + // ignored + } + + try + { + _channel.Writer.TryComplete(); + } + catch + { + // ignored + } + + try + { + _worker.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // ignored + } + + _cts.Dispose(); } } public class ClientProvider(IClientSender sender) : IClientProvider { public IClientSender Sender { get; set; } = sender; -} \ No newline at end of file +} diff --git a/Ivy/Apps/AppDescriptor.cs b/Ivy/Apps/AppDescriptor.cs index 18bb698188..a5dc060a9b 100644 --- a/Ivy/Apps/AppDescriptor.cs +++ b/Ivy/Apps/AppDescriptor.cs @@ -32,7 +32,7 @@ public class AppDescriptor : IAppRepositoryNode public Func? ViewFactory { get; init; } - public FuncBuilder? ViewFunc { get; init; } + public FuncViewBuilder? ViewFunc { get; init; } public required bool IsVisible { get; init; } diff --git a/Ivy/Apps/AppSession.cs b/Ivy/Apps/AppSession.cs index 865d1021d1..bf0bb21266 100644 --- a/Ivy/Apps/AppSession.cs +++ b/Ivy/Apps/AppSession.cs @@ -36,12 +36,20 @@ public void TrackDisposable(IDisposable disposable) internal ConcurrentDictionary Signals { get; set; } = new(); + // Dedicated per-session event queue to avoid consuming ThreadPool workers + public EventDispatchQueue? EventQueue { get; set; } + + // Coalesced UI update scheduling + internal volatile bool UpdateScheduled; + internal object? PendingUpdate; // holds WidgetTreeChanged[] payload + public void Dispose() { _isDisposed = true; + EventQueue?.Dispose(); _disposables.Dispose(); WidgetTree.Dispose(); } public bool IsDisposed() => _isDisposed; -} \ No newline at end of file +} diff --git a/Ivy/Core/AbstractWidget.cs b/Ivy/Core/AbstractWidget.cs index 6d8b660208..6fd098c88a 100644 --- a/Ivy/Core/AbstractWidget.cs +++ b/Ivy/Core/AbstractWidget.cs @@ -151,7 +151,7 @@ public JsonNode Serialize() /// Name of event to invoke. /// Arguments to pass to event handler. /// true if event was successfully invoked; otherwise, false. - public bool InvokeEvent(string eventName, JsonArray args) + public async Task InvokeEventAsync(string eventName, JsonArray args) { var type = GetType(); var property = type.GetProperty(eventName); @@ -181,7 +181,8 @@ true when eventType.GetGenericTypeDefinition() == typeof(Event<,>) => var result = ((Delegate)eventDelegate).DynamicInvoke(eventInstance); if (result is ValueTask valueTask) { - valueTask.AsTask().GetAwaiter().GetResult(); + // Properly await the async event handler instead of blocking + await valueTask; } return true; } @@ -266,4 +267,3 @@ private static bool IsFunc(object eventDelegate, out Type? eventType, out Type? return widget with { Children = [.. widget.Children, child] }; } } - diff --git a/Ivy/Core/EventDispatchQueue.cs b/Ivy/Core/EventDispatchQueue.cs new file mode 100644 index 0000000000..0e1abaaf2e --- /dev/null +++ b/Ivy/Core/EventDispatchQueue.cs @@ -0,0 +1,100 @@ +using System.Threading.Channels; + +namespace Ivy.Core; + +/// +/// Dedicated event dispatch queue that processes enqueued actions on a long-running +/// background thread, avoiding consumption of ThreadPool workers during bursts. +/// +public sealed class EventDispatchQueue : IDisposable +{ + private const int DefaultChannelCapacity = 1024; + private readonly Channel> _channel; + private readonly CancellationTokenSource _cts; + private readonly Task _worker; + + public EventDispatchQueue(CancellationToken externalCancellation) + { + var options = new BoundedChannelOptions(DefaultChannelCapacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest + }; + _channel = Channel.CreateBounded>(options); + _cts = CancellationTokenSource.CreateLinkedTokenSource(externalCancellation); + + _worker = Task.Run(async () => + { + try + { + while (await _channel.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false)) + { + while (_channel.Reader.TryRead(out var work)) + { + try + { + await work().ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] EventDispatchQueue work failed: {ex}"); + } + } + } + } + catch (OperationCanceledException) { } + }, _cts.Token); + } + + public void Enqueue(Action action) + { + // Wrap synchronous action in a Task-returning function + if (!_channel.Writer.TryWrite(() => { action(); return Task.CompletedTask; })) + { + // Fallback to best-effort + _ = _channel.Writer.WriteAsync(() => { action(); return Task.CompletedTask; }, _cts.Token); + } + } + + public void Enqueue(Func asyncAction) + { + if (!_channel.Writer.TryWrite(asyncAction)) + { + // Fallback to best-effort + _ = _channel.Writer.WriteAsync(asyncAction, _cts.Token); + } + } + + public void Dispose() + { + try + { + _cts.Cancel(); + } + catch + { + // ignored + } + + try + { + _channel.Writer.TryComplete(); + } + catch + { + // ignored + } + + try + { + _worker.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // ignored + } + + _cts.Dispose(); + } +} diff --git a/Ivy/Core/Hooks/ConvertedState.cs b/Ivy/Core/Hooks/ConvertedState.cs index 2dba0d9b8c..92b674d648 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 Reset() + { + return Set(default(TTo)!); + } } \ No newline at end of file diff --git a/Ivy/Core/Hooks/EffectQueue.cs b/Ivy/Core/Hooks/EffectQueue.cs index 392ed84656..5ae41f9191 100644 --- a/Ivy/Core/Hooks/EffectQueue.cs +++ b/Ivy/Core/Hooks/EffectQueue.cs @@ -106,8 +106,8 @@ private async Task ProcessEffectsForPriority(EffectPriority targetPriority) try { var task = effect.Handler(); - await task; - _disposables.Add(task.Result); + var disposable = await task; + _disposables.Add(disposable); await Task.Yield(); } catch (Exception ex) diff --git a/Ivy/Core/Hooks/State.cs b/Ivy/Core/Hooks/State.cs index 2d88b25e15..437c804a6b 100644 --- a/Ivy/Core/Hooks/State.cs +++ b/Ivy/Core/Hooks/State.cs @@ -42,22 +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); + + /// + /// Resets the state to its default value. + /// + /// The default value. + public T Reset(); } /// @@ -68,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. @@ -83,13 +82,81 @@ public State(T initialValue) /// public T Value { - get => _value; + get + { + lock (_lock) + { + return _value; + } + } set { - if (Equals(_value, value)) return; - _value = value; - if (!_subject.IsDisposed) _subject.OnNext(_value); + T? newValue = default; + bool changed = false; + lock (_lock) + { + if (!Equals(_value, value)) + { + _value = value; + newValue = _value; + changed = true; + } + } + if (changed && !_subject.IsDisposed) + { + _subject.OnNext(newValue!); + } + } + } + + /// + /// 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) + { + T current; + T updated; + bool changed; + lock (_lock) + { + current = _value; + updated = setter(current); + changed = !Equals(_value, updated); + if (changed) + { + _value = updated; + } + } + if (changed && !_subject.IsDisposed) + { + _subject.OnNext(updated); } + return updated; + } + + /// + /// Resets the state to its default value. + /// Thread-safe. + /// + /// The default value. + public T Reset() + { + return Set(default(T)!); } /// @@ -99,8 +166,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); + } } /// @@ -157,4 +227,4 @@ public IEffectTrigger ToTrigger() { return EffectTrigger.AfterChange(this); } -} \ No newline at end of file +} diff --git a/Ivy/Core/IWidget.cs b/Ivy/Core/IWidget.cs index d24e773668..d4174386c7 100644 --- a/Ivy/Core/IWidget.cs +++ b/Ivy/Core/IWidget.cs @@ -30,7 +30,7 @@ public interface IWidget /// The name of the event to invoke (e.g., "onClick", "onChange"). /// The arguments passed from the client-side event. /// True if the event was successfully invoked; false if the event handler was not found. - public bool InvokeEvent(string eventName, JsonArray args); + public Task InvokeEventAsync(string eventName, JsonArray args); /// /// Gets an attached property value that was set by a parent widget for layout or behavior purposes. diff --git a/Ivy/Core/IWidgetTree.cs b/Ivy/Core/IWidgetTree.cs index dc89d17b29..65b397d10c 100644 --- a/Ivy/Core/IWidgetTree.cs +++ b/Ivy/Core/IWidgetTree.cs @@ -41,7 +41,7 @@ public interface IWidgetTree : IDisposable /// The name of the event to trigger. /// The arguments to pass with the event as a JSON array. /// True if the event was successfully triggered, false otherwise. - public bool TriggerEvent(string widgetId, string eventName, JsonArray args); + public Task TriggerEventAsync(string widgetId, string eventName, JsonArray args); /// /// Performs a hot reload of the widget tree, allowing for dynamic updates diff --git a/Ivy/Core/WidgetTree.cs b/Ivy/Core/WidgetTree.cs index 73d6b595bb..d7afe678a6 100644 --- a/Ivy/Core/WidgetTree.cs +++ b/Ivy/Core/WidgetTree.cs @@ -618,7 +618,7 @@ private static string GenerateId(Path path) /// The arguments to pass to the event handler. /// True if the event was successfully invoked; false otherwise. /// Thrown if the widget is not found or the node is not a widget. - public bool TriggerEvent(string widgetId, string eventName, JsonArray args) + public async Task TriggerEventAsync(string widgetId, string eventName, JsonArray args) { if (!_nodes.TryGetValue(widgetId, out var node)) throw new NotSupportedException($"Node '{widgetId}' not found."); @@ -628,7 +628,7 @@ public bool TriggerEvent(string widgetId, string eventName, JsonArray args) var widget = node.Widget!; - var result = widget.InvokeEvent(eventName, args); + var result = await widget.InvokeEventAsync(eventName, args); return result; } @@ -745,4 +745,4 @@ public void Dispose() _buildRequestedSemaphore.Dispose(); } } -} \ No newline at end of file +} diff --git a/Ivy/DefaultContentBuilder.cs b/Ivy/DefaultContentBuilder.cs index 60482080d7..830a78dd1b 100644 --- a/Ivy/DefaultContentBuilder.cs +++ b/Ivy/DefaultContentBuilder.cs @@ -86,7 +86,7 @@ public class DefaultContentBuilder : IContentBuilder return ObservableViewFactory.FromObservable(content); } - if (content is FuncBuilder funcBuilder) + if (content is FuncViewBuilder funcBuilder) { return new FuncView(funcBuilder); } diff --git a/Ivy/Hooks/UseUpload.cs b/Ivy/Hooks/UseUpload.cs index 1d92e87fb8..3f3f8e2e47 100644 --- a/Ivy/Hooks/UseUpload.cs +++ b/Ivy/Hooks/UseUpload.cs @@ -6,25 +6,52 @@ 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, 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 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) + public static IState UseUpload(this IViewContext context, UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null) { - var url = context.UseState(); var uploadService = context.UseService(); + + // Create a temporary context to get initial values for validation + var tempContext = new UploadContext("", _ => { }); + var ctxState = context.UseState(tempContext); + context.UseEffect(() => { - var (cleanup, uploadUrl) = uploadService.AddUpload(handler, mimeType, fileName); - url.Set(uploadUrl); + var (cleanup, uploadUrl) = uploadService.AddUpload(handler, () => (ctxState.Value.Accept, ctxState.Value.MaxFileSize), defaultContentType, defaultFileName); + ctxState.Set(new UploadContext(uploadUrl, fileId => uploadService.Cancel(fileId)) + { + Accept = ctxState.Value.Accept, + MaxFileSize = ctxState.Value.MaxFileSize, + MaxFiles = ctxState.Value.MaxFiles + }); return cleanup; - }); - return url; + }, [EffectTrigger.AfterInit()]); + return ctxState; + } + + /// + /// 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); + + /// + /// 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) + { + return context.UseUpload(handler.HandleUploadAsync, defaultContentType, defaultFileName); } -} \ No newline at end of file +} diff --git a/Ivy/Server.cs b/Ivy/Server.cs index 2d84573d42..def5d16c94 100644 --- a/Ivy/Server.cs +++ b/Ivy/Server.cs @@ -79,7 +79,7 @@ public Server(ServerArgs? args = null) Services.AddSingleton(_args); } - public Server(FuncBuilder viewFactory) : this() + public Server(FuncViewBuilder viewFactory) : this() { AddApp(new AppDescriptor { @@ -276,7 +276,8 @@ public async Task RunAsync(CancellationTokenSource? cts = null) }; #if (DEBUG) - _ = Task.Run(() => + // Run key listener on a dedicated thread to avoid consuming a ThreadPool worker + _ = Task.Factory.StartNew(() => { while (!cts.Token.IsCancellationRequested) { @@ -286,7 +287,7 @@ public async Task RunAsync(CancellationTokenSource? cts = null) sessionStore.Dump(); } } - }, cts.Token); + }, cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); if (Utils.IsPortInUse(_args.Port)) { @@ -335,6 +336,16 @@ public async Task RunAsync(CancellationTokenSource? cts = null) AppRepository.Reload(); + // Ensure sufficient ThreadPool workers to avoid heartbeat warnings under bursty loads + try + { + ThreadPool.GetMinThreads(out var workerMin, out var ioMin); + var target = Math.Max(workerMin, Environment.ProcessorCount * 16); + var targetIo = Math.Max(ioMin, Environment.ProcessorCount * 16); + ThreadPool.SetMinThreads(target, targetIo); + } + catch { /* best-effort */ } + var builder = WebApplication.CreateBuilder(); builder.Configuration.AddConfiguration(Configuration); @@ -617,4 +628,4 @@ private static StaticFileOptions GetStaticFileOptions(string path, IFileProvider } }; } -} \ No newline at end of file +} diff --git a/Ivy/Services/ChunkedMemoryStreamUploadHandler.cs b/Ivy/Services/ChunkedMemoryStreamUploadHandler.cs new file mode 100644 index 0000000000..c20987b819 --- /dev/null +++ b/Ivy/Services/ChunkedMemoryStreamUploadHandler.cs @@ -0,0 +1,165 @@ +using System.Collections.Immutable; +using System.Text; +using Ivy.Core.Hooks; + +namespace Ivy.Services; + +/// +/// Upload handler that accumulates multiple chunks into a single file. +/// Useful for audio recording or streaming uploads where data arrives in pieces. +/// +public static class ChunkedMemoryStreamUploadHandler +{ + /// + /// Creates a chunked upload handler that accumulates byte array chunks into a single file. + /// Each upload appends to the previous content, building up the complete file over time. + /// + /// State holding the accumulated file + /// Buffer size for reading chunks (default 8192) + public static IUploadHandler Create(IState?> singleState, int chunkSize = 8192) + => new ChunkedMemoryStreamUploadHandlerImpl(singleState, chunkSize); + + /// + /// Creates a chunked upload handler that stores each chunk as a separate file in an array. + /// Useful when you want to process or display individual chunks. + /// + /// State holding all received chunks + /// Buffer size for reading chunks (default 8192) + public static IUploadHandler CreateArray(IState>> arrayState, int chunkSize = 8192) + => new ChunkedArrayUploadHandlerImpl(arrayState, chunkSize); +} + +internal sealed class ChunkedMemoryStreamUploadHandlerImpl : IUploadHandler +{ + private readonly IState?> _state; + private readonly int _chunkSize; + private long _totalAccumulated; + + internal ChunkedMemoryStreamUploadHandlerImpl(IState?> state, int chunkSize = 8192) + { + _state = state; + _chunkSize = chunkSize; + _totalAccumulated = 0; + } + + public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) + { + try + { + // Read the new chunk + var chunkBytes = await ReadChunkAsync(stream, _chunkSize, cancellationToken); + + var current = _state.Value; + byte[] newContent; + + if (current?.Content != null) + { + // Append to existing content + newContent = new byte[current.Content.Length + chunkBytes.Length]; + Array.Copy(current.Content, 0, newContent, 0, current.Content.Length); + Array.Copy(chunkBytes, 0, newContent, current.Content.Length, chunkBytes.Length); + _totalAccumulated = newContent.Length; + } + else + { + // First chunk - start new file + newContent = chunkBytes; + _totalAccumulated = chunkBytes.Length; + } + + // Update state with accumulated content + var updated = new FileUpload + { + Id = current?.Id ?? fileUpload.Id, + FileName = fileUpload.FileName, + ContentType = fileUpload.ContentType, + Length = _totalAccumulated, + Content = newContent, + Status = FileUploadStatus.Loading, + Progress = 1.0f // Each chunk completes fully + }; + + _state.Set(updated); + } + catch (OperationCanceledException) + { + var current = _state.Value; + if (current != null) + { + _state.Set(current with { Status = FileUploadStatus.Aborted }); + } + throw; + } + catch (Exception) + { + var current = _state.Value; + if (current != null) + { + _state.Set(current with { Status = FileUploadStatus.Failed }); + } + throw; + } + } + + private static async Task ReadChunkAsync(Stream stream, int chunkSize, CancellationToken ct) + { + using var memoryStream = new MemoryStream(); + var buffer = new byte[chunkSize]; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(buffer, ct)) > 0) + { + ct.ThrowIfCancellationRequested(); + await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct); + } + + return memoryStream.ToArray(); + } +} + +internal sealed class ChunkedArrayUploadHandlerImpl : IUploadHandler +{ + private readonly IState>> _state; + private readonly int _chunkSize; + + internal ChunkedArrayUploadHandlerImpl(IState>> state, int chunkSize = 8192) + { + _state = state; + _chunkSize = chunkSize; + } + + public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) + { + // Read the chunk + var chunkBytes = await ReadChunkAsync(stream, _chunkSize, cancellationToken); + + // Add as new entry in array + var chunk = new FileUpload + { + Id = fileUpload.Id, + FileName = fileUpload.FileName, + ContentType = fileUpload.ContentType, + Length = chunkBytes.Length, + Content = chunkBytes, + Status = FileUploadStatus.Finished, + Progress = 1.0f + }; + + _state.Set(list => list.Add(chunk)); + } + + private static async Task ReadChunkAsync(Stream stream, int chunkSize, CancellationToken ct) + { + using var memoryStream = new MemoryStream(); + var buffer = new byte[chunkSize]; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(buffer, ct)) > 0) + { + ct.ThrowIfCancellationRequested(); + await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct); + } + + return memoryStream.ToArray(); + } +} diff --git a/Ivy/Services/MemoryStreamUploadHandler.cs b/Ivy/Services/MemoryStreamUploadHandler.cs new file mode 100644 index 0000000000..871db81696 --- /dev/null +++ b/Ivy/Services/MemoryStreamUploadHandler.cs @@ -0,0 +1,161 @@ +using System.Collections.Immutable; +using System.Text; +using Ivy.Core.Helpers; +using Ivy.Core.Hooks; + +namespace Ivy.Services; + +public static class MemoryStreamUploadHandler +{ + /// + /// Creates an upload handler from an IAnyState by automatically detecting the state type. + /// Supports: FileUpload<byte[]>?, FileUpload<string>?, ImmutableArray<FileUpload<byte[]>>, ImmutableArray<FileUpload<string>> + /// + public static IUploadHandler Create(IAnyState anyState, Encoding? encoding = null, int chunkSize = 8192, float progressThreshold = 0.05f) + { + var stateType = anyState.GetStateType(); + + // Handle nullable value types - unwrap to get the underlying type + var underlyingType = Nullable.GetUnderlyingType(stateType) ?? stateType; + + // Check for FileUpload + if (underlyingType.IsGenericType && underlyingType.GetGenericTypeDefinition() == typeof(FileUpload<>)) + { + var contentType = underlyingType.GetGenericArguments()[0]; + + if (contentType == typeof(byte[])) + { + return Create(anyState.As?>(), chunkSize, progressThreshold); + } + + if (contentType == typeof(string)) + { + return Create(anyState.As?>(), encoding, chunkSize, progressThreshold); + } + } + + // Check for ImmutableArray> + if (underlyingType.IsGenericType && underlyingType.GetGenericTypeDefinition() == typeof(ImmutableArray<>)) + { + var elementType = underlyingType.GetGenericArguments()[0]; + + if (elementType.IsGenericType && elementType.GetGenericTypeDefinition() == typeof(FileUpload<>)) + { + var contentType = elementType.GetGenericArguments()[0]; + + if (contentType == typeof(byte[])) + { + return Create(anyState.As>>(), chunkSize, progressThreshold); + } + if (contentType == typeof(string)) + { + return Create(anyState.As>>(), encoding, chunkSize, progressThreshold); + } + } + } + + throw new ArgumentException( + $@"Unsupported state type: {stateType}. Supported types are: FileUpload?, FileUpload?, ImmutableArray>, ImmutableArray>", + nameof(anyState)); + } + + public static IUploadHandler Create(IState?> singleState, int chunkSize = 8192, float progressThreshold = 0.05f) + => new MemoryStreamUploadHandlerImpl(new SingleFileSink(singleState), bytes => bytes, chunkSize, progressThreshold); + + public static IUploadHandler Create(IState?> singleState, Encoding? encoding = null, int chunkSize = 8192, float progressThreshold = 0.05f) + => new MemoryStreamUploadHandlerImpl( + new SingleFileSink(singleState), + bytes => (encoding ?? Encoding.UTF8).GetString(bytes), + chunkSize, + progressThreshold); + + public static IUploadHandler Create(IState>> manyState, int chunkSize = 8192, float progressThreshold = 0.05f) + => new MemoryStreamUploadHandlerImpl(new MultipleFileSink(manyState), bytes => bytes, chunkSize, progressThreshold); + + public static IUploadHandler Create(IState>> manyState, Encoding? encoding = null, int chunkSize = 8192, float progressThreshold = 0.05f) + => new MemoryStreamUploadHandlerImpl( + new MultipleFileSink(manyState), + bytes => (encoding ?? Encoding.UTF8).GetString(bytes), + chunkSize, + progressThreshold); +} + +internal sealed class MemoryStreamUploadHandlerImpl : IUploadHandler +{ + private readonly IFileUploadSink _sink; + private readonly int _chunkSize; + private readonly Func _converter; + private readonly float _progressThreshold; + + internal MemoryStreamUploadHandlerImpl(IFileUploadSink sink, Func converter, int chunkSize = 8192, float progressThreshold = 0.05f) + { + _sink = sink; + _chunkSize = chunkSize; + _converter = converter; + _progressThreshold = progressThreshold; + } + + + public async Task HandleUploadAsync(FileUpload fileUpload, Stream stream, CancellationToken cancellationToken) + { + Guid key = fileUpload.Id; + try + { + key = _sink.Start(fileUpload); + + var (bytes, _) = await ReadAllWithProgressAsync( + stream, + _chunkSize, + fileUpload.Length, + p => _sink.Progress(key, p), + _progressThreshold, + cancellationToken + ); + + var content = _converter(bytes); + _sink.Complete(key, content); + } + catch (OperationCanceledException) + { + _sink.Aborted(key); + throw; + } + catch (Exception) + { + _sink.Failed(key); + throw; + } + } + + private static async Task<(byte[] bytes, long totalRead)> ReadAllWithProgressAsync( + Stream stream, + int chunkSize, + long totalLength, + Action onProgress, + float progressThreshold, + CancellationToken ct) + { + using var memoryStream = new MemoryStream(); + var buffer = new byte[chunkSize]; + long processedBytes = 0L; + int bytesRead; + float lastReportedProgress = 0f; + + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, ct)) > 0) + { + ct.ThrowIfCancellationRequested(); + await memoryStream.WriteAsync(buffer, 0, bytesRead, ct); + processedBytes += bytesRead; + var progress = totalLength > 0 ? (float)processedBytes / totalLength : 0f; + + // Only report progress if it changed by the configured threshold + if (progress - lastReportedProgress >= progressThreshold) + { + onProgress(progress); + lastReportedProgress = progress; + } + } + + return (memoryStream.ToArray(), processedBytes); + } +} \ No newline at end of file diff --git a/Ivy/Services/UploadService.cs b/Ivy/Services/UploadService.cs index 73f0b360cd..1545ba80da 100644 --- a/Ivy/Services/UploadService.cs +++ b/Ivy/Services/UploadService.cs @@ -1,11 +1,324 @@ using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; using System.Reactive.Disposables; +using System.Text.Json.Serialization; +using Ivy.Client; +using Ivy.Core; +using Ivy.Core.Hooks; +using Ivy.Views.Builders; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace Ivy.Services; +/// +/// Context for an upload endpoint created by UseUpload, providing the client-facing URL +/// and a server-side cancel function to abort an in-flight upload by fileId. +/// +public record UploadContext(string UploadUrl, Action Cancel) +{ + /// Gets or sets the accepted file types using MIME types or file extensions. + public string? Accept { get; init; } + + /// Gets or sets the maximum file size in bytes. + public long? MaxFileSize { get; init; } + + /// Gets or sets the maximum number of files that can be uploaded. + public int? MaxFiles { get; init; } +} + +/// +/// Extension methods for configuring UploadContext. +/// +public static class UploadContextExtensions +{ + /// Sets the accepted file types for the upload state using MIME types or file extensions. + /// The upload context state to configure. + /// A comma-separated list of accepted file types (e.g., "image/*", ".pdf,.doc", "text/plain"). + public static Core.Hooks.IState Accept(this Core.Hooks.IState state, string accept) + { + state.Set(state.Value with { Accept = accept }); + return state; + } + + /// Sets the maximum file size in bytes for the upload state. + /// The upload context state to configure. + /// The maximum file size in bytes. + public static Core.Hooks.IState MaxFileSize(this Core.Hooks.IState state, long maxFileSize) + { + state.Set(state.Value with { MaxFileSize = maxFileSize }); + return state; + } + + /// Sets the maximum number of files that can be uploaded for the upload state. + /// The upload context state to configure. + /// The maximum number of files allowed. + public static Core.Hooks.IState MaxFiles(this Core.Hooks.IState state, int maxFiles) + { + state.Set(state.Value with { MaxFiles = maxFiles }); + return state; + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FileUploadStatus +{ + Pending, + Aborted, + Loading, + Failed, + 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 : IFileUpload +{ + /// 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; + + /// 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; +} + +/// +/// 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] + [ScaffoldColumn(false)] + public T? Content { get; init; } + + public DetailsBuilder> ToDetails() + { + return new DetailsBuilder>(this) + .Builder(e => e.Length, e => e.Func((long x) => Utils.FormatBytes(x))) + .Builder(e => e.Progress, e => e.Func((float x) => x.ToString("P0"))) + .Remove(e => e.Id); + } +} + +/// +/// 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) + { + 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); + + +/// +/// Interface for managing file upload state. +/// Sinks are simple state controllers that update FileUpload state for single or multiple files. +/// Cleanup logic should be handled by the upload handler, not the sink. +/// +public interface IFileUploadSink +{ + Guid Start(FileUpload file); + void Progress(Guid key, float progress); + void Complete(Guid key, TContent content); + void Aborted(Guid key); + void Failed(Guid key); +} + +public sealed class SingleFileSink(IState?> state) : IFileUploadSink +{ + public Guid Start(FileUpload file) + { + var typed = new FileUpload + { + Id = file.Id, + FileName = file.FileName, + ContentType = file.ContentType, + Length = file.Length, + Status = FileUploadStatus.Loading, + Progress = 0f + }; + state.Set(typed); + return file.Id; + } + + public void Progress(Guid key, float progress) + { + state.SetProgress(progress); + } + + public void Complete(Guid key, T content) + { + var current = state.Value; + if (current != null && current.Id == key) + { + state.Set(current with { Content = content, Status = FileUploadStatus.Finished, Progress = 1f }); + } + else if (current == null) + { + // Fallback if state was cleared + state.Set(new FileUpload + { + Id = key, + Content = content, + Status = FileUploadStatus.Finished, + Progress = 1f + }); + } + } + + public void Aborted(Guid key) => state.SetStatus(FileUploadStatus.Aborted); + public void Failed(Guid key) => state.SetStatus(FileUploadStatus.Failed); +} + +public sealed class MultipleFileSink(IState>> state) : IFileUploadSink +{ + private static int IndexOfById(ImmutableArray> list, Guid key) + { + for (int i = 0; i < list.Length; i++) + { + if (list[i].Id == key) return i; + } + return -1; + } + + public Guid Start(FileUpload file) + { + var typed = new FileUpload + { + Id = file.Id, + FileName = file.FileName, + ContentType = file.ContentType, + Length = file.Length, + Status = FileUploadStatus.Loading, + Progress = 0f + }; + state.Set(list => list.Add(typed)); + return file.Id; + } + + public void Progress(Guid key, float progress) + { + state.Set(list => + { + var idx = IndexOfById(list, key); + if (idx >= 0) + { + var updated = list[idx] with { Progress = progress }; + return list.SetItem(idx, updated); + } + return list; + }); + } + + public void Complete(Guid key, T content) + { + state.Set(list => + { + var idx = IndexOfById(list, key); + if (idx >= 0) + { + var updated = list[idx] with { Content = content, Status = FileUploadStatus.Finished, Progress = 1f }; + return list.SetItem(idx, updated); + } + return list; + }); + } + + public void Aborted(Guid key) + { + state.Set(list => + { + var idx = IndexOfById(list, key); + if (idx >= 0) + { + var updated = list[idx] with { Status = FileUploadStatus.Aborted }; + return list.SetItem(idx, updated); + } + return list; + }); + } + + public void Failed(Guid key) + { + state.Set(list => + { + var idx = IndexOfById(list, key); + if (idx >= 0) + { + var updated = list[idx] with { Status = FileUploadStatus.Failed }; + return list.SetItem(idx, updated); + } + return list; + }); + } +} + [ApiController] [Route("upload")] public class UploadController(AppSessionStore sessionStore) : Controller @@ -22,10 +335,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(); @@ -35,18 +344,36 @@ public async Task Upload([FromRoute] string connectionId, [FromRo } } -public class UploadService(string connectionId) : IUploadService, IDisposable +public class UploadService(string connectionId, IClientProvider clientProvider) : IUploadService, IDisposable { - private readonly ConcurrentDictionary handler, string mimeType, string fileName)> _uploads = new(); + private readonly ConcurrentDictionary getValidation)> _uploads = new(); + private readonly ConcurrentDictionary _inflightUploads = new(); - public (IDisposable cleanup, string url) AddUpload(Func handler, string mimeType, string fileName) + public (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, string? defaultContentType = null, string? defaultFileName = null, string? accept = null, long? maxFileSize = null) { var uploadId = Guid.NewGuid(); - _uploads[uploadId] = (handler, mimeType, fileName); + var cts = new CancellationTokenSource(); + _uploads[uploadId] = (handler, cts, defaultContentType, defaultFileName, () => (accept, maxFileSize)); var cleanup = Disposable.Create(() => { - _uploads.TryRemove(uploadId, out _); + _uploads.TryRemove(uploadId, out var upload); + upload.cts?.Dispose(); + }); + + return (cleanup, $"/upload/{connectionId}/{uploadId}"); + } + + public (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, Func<(string? accept, long? maxFileSize)> getValidation, string? defaultContentType = null, string? defaultFileName = null) + { + var uploadId = Guid.NewGuid(); + var cts = new CancellationTokenSource(); + _uploads[uploadId] = (handler, cts, defaultContentType, defaultFileName, getValidation); + + var cleanup = Disposable.Create(() => + { + _uploads.TryRemove(uploadId, out var upload); + upload.cts?.Dispose(); }); return (cleanup, $"/upload/{connectionId}/{uploadId}"); @@ -59,29 +386,86 @@ public async Task Upload(string uploadId, IFormFile file) return new BadRequestObjectResult($"Invalid or unknown uploadId: '{uploadId}'."); } - var (handler, expectedContentType, expectedFileName) = upload; + var (handler, cts, defaultContentType, defaultFileName, getValidation) = upload; + var (accept, maxFileSize) = getValidation(); - 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(); + // 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.NewGuid(), + FileName = actualFileName, + ContentType = actualMimeType, + Length = file.Length + }; - await handler(fileBytes); + // Validate file size + if (maxFileSize.HasValue) + { + var sizeValidation = Widgets.Inputs.FileInputValidation.ValidateFileSize(fileUpload, maxFileSize); + if (!sizeValidation.IsValid) + { + // Send toast notification for file size error + clientProvider.Toast(sizeValidation.ErrorMessage ?? "File is too large", "File too large"); + return new OkResult(); // Return OK to prevent frontend error handling + } + } + + // Validate file type + if (!string.IsNullOrWhiteSpace(accept)) + { + var typeValidation = Widgets.Inputs.FileInputValidation.ValidateFileType(fileUpload, accept); + if (!typeValidation.IsValid) + { + // Send toast notification for file type error + clientProvider.Toast(typeValidation.ErrorMessage ?? "File type is not allowed", "Invalid file type"); + return new OkResult(); // Return OK to prevent frontend error handling + } + } + + // Ensure request stream is disposed deterministically to avoid leaking handles + try + { + await 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; + + await handler(fileUpload, uploadStream, linkedCts.Token); + } + finally + { + // Remove tracking for this fileId + _inflightUploads.TryRemove(fileUpload.Id, out _); + } return new OkResult(); } + public void Cancel(Guid fileId) + { + if (_inflightUploads.TryGetValue(fileId, out var cts)) + { + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // If already disposed, ignore + } + } + } + public void Dispose() { _uploads.Clear(); @@ -90,7 +474,13 @@ public void Dispose() public interface IUploadService { - (IDisposable cleanup, string url) AddUpload(Func handler, string mimeType, string fileName); + (IDisposable cleanup, string url) AddUpload(UploadDelegate handler, Func<(string? accept, long? maxFileSize)> getValidation, string? defaultContentType = null, string? defaultFileName = null); Task Upload(string uploadId, IFormFile file); -} \ No newline at end of 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 Cancel(Guid fileId); +} diff --git a/Ivy/Utils.cs b/Ivy/Utils.cs index 97cb62f918..d1b7896819 100644 --- a/Ivy/Utils.cs +++ b/Ivy/Utils.cs @@ -158,7 +158,8 @@ public static async Task> ToListAsync2( return list; } - return Task.FromResult(source.ToList()).Result; + // Synchronous fallback without blocking on Task.Result + return source.ToList(); } public static async Task ToArrayAsync( @@ -729,4 +730,19 @@ public static string LabelFor(string name, Type? type) } return SplitPascalCase(name) ?? name; } -} \ No newline at end of file + + 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]}"; + } +} 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/Builders/BuilderFactory.cs b/Ivy/Views/Builders/BuilderFactory.cs index ba2f056d6f..892f6db974 100644 --- a/Ivy/Views/Builders/BuilderFactory.cs +++ b/Ivy/Views/Builders/BuilderFactory.cs @@ -29,4 +29,9 @@ public static IBuilder CopyToClipboard(this IBuilderFactory(); } + + public static IBuilder Func(this IBuilderFactory factory, Func func) + { + return new FuncBuilder(func); + } } diff --git a/Ivy/Views/Builders/DetailsBuilder.cs b/Ivy/Views/Builders/DetailsBuilder.cs index 9d6e179bf6..5bf6cd4c7a 100644 --- a/Ivy/Views/Builders/DetailsBuilder.cs +++ b/Ivy/Views/Builders/DetailsBuilder.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; using System.Reflection; using Ivy.Core; @@ -60,10 +61,12 @@ private void _Scaffold() var fields = type .GetFields() + .Where(f => f.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, Type = e.FieldType, FieldInfo = e, PropertyInfo = (PropertyInfo)null! }) .Union( type .GetProperties() + .Where(p => p.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, Type = e.PropertyType, FieldInfo = (FieldInfo)null!, PropertyInfo = e }) ) .ToList(); @@ -157,6 +160,6 @@ public static DetailsBuilder ToDetails(this TModel model) public static DetailsBuilder ToDetails(this IState model) { - return new DetailsBuilder(model.Value); + return model.Value.ToDetails(); } } \ No newline at end of file diff --git a/Ivy/Views/Builders/FuncBuilder.cs b/Ivy/Views/Builders/FuncBuilder.cs new file mode 100644 index 0000000000..d4852c3309 --- /dev/null +++ b/Ivy/Views/Builders/FuncBuilder.cs @@ -0,0 +1,10 @@ +namespace Ivy.Views.Builders; + +public class FuncBuilder(Func func) : IBuilder +{ + public object? Build(object? value, TModel record) + { + if (record == null) return null; + return func((TIn)value!); + } +} \ No newline at end of file diff --git a/Ivy/Views/Builders/IBuilder.cs b/Ivy/Views/Builders/IBuilder.cs index 45b81672c7..c50a990793 100644 --- a/Ivy/Views/Builders/IBuilder.cs +++ b/Ivy/Views/Builders/IBuilder.cs @@ -3,4 +3,28 @@ namespace Ivy.Views.Builders; public interface IBuilder { public object? Build(object? value, TModel record); -} \ No newline at end of file +} + +// public static class BuilderExtensions +// { +// +// public static IBuilder ToBuilder(this Func func) +// { +// +// } +// +// } + + +// var x = (long e) => ""; // Func +// var builder = x.ToBuilder() // Func,IBuilder> + + + +// public class TextBuilder : IBuilder +// { +// public object? Build(object? value, TModel record) +// { +// return value == null ? null : Text.Literal(value.ToString() ?? string.Empty); +// } +// } diff --git a/Ivy/Views/DataTables/DataTableBuilder.cs b/Ivy/Views/DataTables/DataTableBuilder.cs index eff8b64255..dac6da09a3 100644 --- a/Ivy/Views/DataTables/DataTableBuilder.cs +++ b/Ivy/Views/DataTables/DataTableBuilder.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; using System.Reflection; using Ivy.Core; @@ -79,10 +80,12 @@ private void _Scaffold() var fields = type .GetFields() + .Where(f => f.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, Type = e.FieldType, FieldInfo = e, PropertyInfo = (PropertyInfo)null! }) .Union( type .GetProperties() + .Where(p => p.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, Type = e.PropertyType, FieldInfo = (FieldInfo)null!, PropertyInfo = e }) ) .ToList(); diff --git a/Ivy/Views/DemoView.cs b/Ivy/Views/DemoView.cs index 0a32c606f6..e7e135890c 100644 --- a/Ivy/Views/DemoView.cs +++ b/Ivy/Views/DemoView.cs @@ -25,7 +25,7 @@ public class DemoView : ViewBase /// This parameter is automatically populated by the compiler using the /// CallerArgumentExpression attribute. [OverloadResolutionPriority(1)] - public DemoView(FuncBuilder content, [CallerArgumentExpression(nameof(content))] string? code = null) + public DemoView(FuncViewBuilder content, [CallerArgumentExpression(nameof(content))] string? code = null) { _content = content; _code = FormatExpression(code!); diff --git a/Ivy/Views/Forms/FormBuilder.cs b/Ivy/Views/Forms/FormBuilder.cs index 66854774a7..0b47caaad3 100644 --- a/Ivy/Views/Forms/FormBuilder.cs +++ b/Ivy/Views/Forms/FormBuilder.cs @@ -1,9 +1,12 @@ +using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; using System.Reflection; +using Ivy.Client; using Ivy.Core; using Ivy.Core.Helpers; using Ivy.Core.Hooks; using Ivy.Hooks; +using Ivy.Services; using Ivy.Shared; using Ivy.Widgets.Inputs; @@ -25,7 +28,7 @@ public FormBuilderField( string name, string label, int order, - Func? inputFactory, + Func? inputFactory, FieldInfo? fieldInfo, PropertyInfo? propertyInfo, bool required) @@ -106,8 +109,8 @@ public FormBuilderField( /// Optional help text displayed as tooltip on info icon next to label. public string? Help { get; set; } - /// Factory function creating input control for this field. - public Func? InputFactory { get; set; } + /// Factory function creating input control for this field with access to view context. + public Func? InputFactory { get; set; } /// Whether field has been removed from form and should not be rendered. public bool Removed { get; set; } @@ -154,6 +157,7 @@ private void _Scaffold() var fields = type .GetFields() + .Where(f => f.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, @@ -165,6 +169,7 @@ private void _Scaffold() .Union( type .GetProperties() + .Where(p => p.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, @@ -197,33 +202,54 @@ private void _Scaffold() } } - private Func? ScaffoldEditor(string name, Type type) + private Func? ScaffoldEditor(string name, Type type) { Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; - if (type == typeof(FileInput)) + static bool IsFileUploadType(Type t) { - return (state) => state.ToFileInput().Size(Size); + if (t == typeof(FileUpload)) return true; + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(FileUpload<>)) return true; + return typeof(IFileUpload).IsAssignableFrom(t); + } + + // FileUpload fields are not auto-scaffolded - use .Builder() to configure them manually + if (IsFileUploadType(nonNullableType)) + { + return null; + } + + // 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 null; + } + } } if (name.EndsWith("Id") && (type == typeof(Guid) || type == typeof(int) || type == typeof(string))) { - return (state) => state.ToReadOnlyInput().Size(Size); + return (state, _) => state.ToReadOnlyInput().Size(Size); } if (name.EndsWith("Email") && nonNullableType == typeof(string)) { - return (state) => state.ToEmailInput().Size(Size); + return (state, _) => state.ToEmailInput().Size(Size); } if ((name.EndsWith("Color") || name.EndsWith("Colour")) && nonNullableType == typeof(string)) { - return (state) => state.ToColorInput().Size(Size); + return (state, _) => state.ToColorInput().Size(Size); } if (nonNullableType == typeof(bool)) { - return (state) => + return (state, _) => { var input = state.ToBoolInput(); // Only apply scaffold defaults if no custom label was set @@ -245,48 +271,57 @@ private void _Scaffold() { if (name.EndsWith("Password")) { - return (state) => state.ToPasswordInput().Size(Size); + return (state, _) => state.ToPasswordInput().Size(Size); } - return (state) => state.ToTextInput().Size(Size); + return (state, _) => state.ToTextInput().Size(Size); } if (nonNullableType.IsEnum) { - return (state) => state.ToSelectInput().Size(Size); + return (state, _) => state.ToSelectInput().Size(Size); } if (type.IsCollectionType() && type.GetCollectionTypeParameter() is { IsEnum: true }) { - return (state) => state.ToSelectInput().List().Size(Size); + return (state, _) => state.ToSelectInput().List().Size(Size); } if (type.IsNumeric()) { - return (state) => state.ToNumberInput().ScaffoldDefaults(name, type).Size(Size); + return (state, _) => state.ToNumberInput().ScaffoldDefaults(name, type).Size(Size); } if (type.IsDate()) { - return (state) => state.ToDateTimeInput().Size(Size); + return (state, _) => state.ToDateTimeInput().Size(Size); } return null; } - /// Configures custom input factory for specified field with automatic scaffolding wrapper. + /// Configures custom input factory for specified field (convenience overload without view context). /// Expression identifying field to configure. /// Input factory function to use for creating input control. /// Form builder instance for method chaining. public FormBuilder Builder(Expression> field, Func factory) + { + return Builder(field, (state, _) => factory(state)); + } + + /// Configures custom input factory for specified field with automatic scaffolding wrapper. + /// Expression identifying field to configure. + /// Input factory function that receives both state and view context. + /// Form builder instance for method chaining. + public FormBuilder Builder(Expression> field, Func factory) { var hint = GetField(field); - Func ScaffoldWrapper(Func inner) + Func ScaffoldWrapper(Func inner) { - return (state) => + return (state, context) => { - var input = inner(state); + var input = inner(state, context); if (input is IAnyBoolInput boolInput) { // Only apply scaffold defaults if no custom label was set @@ -313,15 +348,24 @@ Func ScaffoldWrapper(Func inner) return this; } - /// Configures custom input factory for all fields of specified type. + /// Configures custom input factory for all fields of specified type (convenience overload without view context). /// Type of fields to configure. /// Input factory function to use for all fields of this type. /// Form builder instance for method chaining. public FormBuilder Builder(Func input) + { + return Builder((state, _) => input(state)); + } + + /// Configures custom input factory for all fields of specified type. + /// Type of fields to configure. + /// Input factory function that receives both state and view context. + /// Form builder instance for method chaining. + public FormBuilder Builder(Func input) { foreach (var hint in _fields.Values.Where(e => e.Type is TU)) { - hint.InputFactory = (state) => input(state).Size(Size); + hint.InputFactory = (state, context) => input(state, context).Size(Size); } return this; @@ -612,22 +656,25 @@ private Expression> CreateSelector(string name) var fields = _fields .Values - .Where(e => e is { Removed: false, InputFactory: not null }) - .Select(e => new FormFieldBinding( - CreateSelector(e.Name), - e.InputFactory!, - () => e.Visible(currentModel.Value), - updateSignal, - e.Label, - e.Description, - e.Required, - new FormFieldLayoutOptions(e.RowKey, e.Column, e.Order, e.Group), - e.Validators.ToArray(), - ValidationStrategy, - Size, - e.Help - )) - .Cast>() + .Where(e => e is { Removed: false } && e.InputFactory != null) + .Select(e => + { + IFormFieldBinding binding = new FormFieldBinding( + CreateSelector(e.Name), + e.InputFactory!, + () => e.Visible(currentModel.Value), + updateSignal, + e.Label, + e.Description, + e.Required, + new FormFieldLayoutOptions(e.RowKey, e.Column, e.Order, e.Group), + e.Validators.ToArray(), + ValidationStrategy, + Size, + e.Help + ); + return binding; + }) .ToArray(); async Task OnSubmit() @@ -676,19 +723,92 @@ async ValueTask HandleSubmitEvent(Event
_) { (Func> onSubmit, IView formView, IView validationView, bool submitting) = UseForm(this.Context); + // Track upload state to disable submit button + var hasUploading = UseState(false); + var client = UseService(); + + UseEffect(() => + { + hasUploading.Set(CheckForLoadingUploads(_model.Value)); + }, _model); + async ValueTask HandleSubmit() { + if (hasUploading.Value) + { + client.Toast( + "File uploads are still in progress. Please wait for them to complete.", + "Uploads in Progress" + ); + return; + } await onSubmit(); } return Layout.Vertical() | formView | Layout.Horizontal(new Button(SubmitTitle).HandleClick(HandleSubmit) - .Loading(submitting).Disabled(submitting).Size(Size), validationView); + .Loading(submitting).Disabled(submitting || hasUploading.Value).Size(Size), validationView); } 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 + + /// Recursively checks if any FileUpload fields in the model are currently uploading. + private static bool CheckForLoadingUploads(object? obj) + { + if (obj == null) return false; + + // Check single file upload + if (obj is IFileUpload file) + return file.Status == FileUploadStatus.Loading; + + // Check collection of uploads + if (obj is IEnumerable files) + return files.Any(f => f.Status == FileUploadStatus.Loading); + + // Recursively check all properties + var type = obj.GetType(); + + // Skip primitive types and strings + if (type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(DateTimeOffset)) + return false; + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Skip indexed properties + if (prop.GetIndexParameters().Length > 0) + continue; + + try + { + var value = prop.GetValue(obj); + if (CheckForLoadingUploads(value)) + return true; + } + catch + { + // Skip properties that can't be read + } + } + + // Check fields as well + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + try + { + var value = field.GetValue(obj); + if (CheckForLoadingUploads(value)) + return true; + } + catch + { + // Skip fields that can't be read + } + } + + return false; + } +} diff --git a/Ivy/Views/Forms/FormExtensions.cs b/Ivy/Views/Forms/FormExtensions.cs index 8a74a008a6..246e959c11 100644 --- a/Ivy/Views/Forms/FormExtensions.cs +++ b/Ivy/Views/Forms/FormExtensions.cs @@ -1,4 +1,5 @@ using Ivy.Core.Hooks; +using Ivy.Services; namespace Ivy.Views.Forms; @@ -24,7 +25,7 @@ public static class FormExtensions /// any IState<T> object into a FormBuilder<T>. The resulting form builder automatically /// inspects the model type using reflection to discover all public fields and properties, /// then creates appropriate input controls based on intelligent heuristics. - /// + /// /// Automatic Features: /// /// Field Discovery: Automatically finds all public fields and properties @@ -33,13 +34,20 @@ public static class FormExtensions /// Validation Setup: Applies required field validation based on attributes /// Layout Defaults: Provides sensible default field ordering and layout /// - /// + /// /// Usage Examples: /// /// // Simple form with automatic scaffolding /// var userState = UseState(new User()); /// return userState.ToForm(); - /// + /// + /// // Form with file upload (use .Builder() with context-aware factory) + /// return userState.ToForm() + /// .Builder(x => x.Avatar, (state, view) => { + /// var uploadContext = view.UseUpload(handler).Accept("image/*"); + /// return state.ToFileInput(uploadContext); + /// }); + /// /// // Form with customization /// return userState.ToForm() /// .Label(x => x.FirstName, "Given Name") @@ -47,7 +55,7 @@ public static class FormExtensions /// .Place(0, x => x.FirstName, x => x.LastName) /// .Group("Contact", x => x.Email, x => x.Phone); /// - /// + /// /// The form builder supports extensive customization through its fluent API while /// maintaining the convenience of automatic scaffolding for rapid development. /// diff --git a/Ivy/Views/Forms/FormView.cs b/Ivy/Views/Forms/FormView.cs index ac72da33ac..a5e04d82a9 100644 --- a/Ivy/Views/Forms/FormView.cs +++ b/Ivy/Views/Forms/FormView.cs @@ -47,22 +47,50 @@ public enum FormValidationStrategy } /// Form field view with validation, data binding, and visibility control. -public class FormFieldView( - IAnyState bindingState, - Func inputFactory, - Func visible, - ISignalSender updateSender, - string? label, - string? description, - bool required, - FormFieldLayoutOptions? layoutOptions, - Func[]? validators, - FormValidationStrategy validationStrategy, - Sizes size = Sizes.Medium, - string? help = null) : ViewBase, IFormFieldView +public class FormFieldView : ViewBase, IFormFieldView { + private readonly IAnyState bindingState; + private readonly Func inputFactory; + private readonly Func visible; + private readonly ISignalSender updateSender; + private readonly string? label; + private readonly string? description; + private readonly bool required; + private readonly Func[]? validators; + private readonly FormValidationStrategy validationStrategy; + private readonly Sizes size; + private readonly string? help; + /// Layout configuration for positioning this field in the form. - public FormFieldLayoutOptions Layout { get; } = layoutOptions ?? new FormFieldLayoutOptions(Guid.NewGuid()); + public FormFieldLayoutOptions Layout { get; } + + public FormFieldView( + IAnyState bindingState, + Func inputFactory, + Func visible, + ISignalSender updateSender, + string? label = null, + string? description = null, + bool required = false, + FormFieldLayoutOptions? layoutOptions = null, + Func[]? validators = null, + FormValidationStrategy validationStrategy = FormValidationStrategy.OnBlur, + Sizes size = Sizes.Medium, + string? help = null) + { + this.bindingState = bindingState; + this.inputFactory = inputFactory; + this.visible = visible; + this.updateSender = updateSender; + this.label = label; + this.description = description; + this.required = required; + this.Layout = layoutOptions ?? new FormFieldLayoutOptions(Guid.NewGuid()); + this.validators = validators; + this.validationStrategy = validationStrategy; + this.size = size; + this.help = help; + } /// Validates the field value using configured validators. private bool Validate(T value, IState invalid) @@ -130,7 +158,7 @@ void OnBlur(Event _) blurOnceState.Set(true); } - var input = inputFactory(inputState).Invalid(invalidState.Value); + var input = inputFactory(inputState, Context).Invalid(invalidState.Value); if (validationStrategy == FormValidationStrategy.OnBlur) { input.HandleBlur(OnBlur); @@ -150,7 +178,7 @@ public record FormFieldLayoutOptions(Guid RowKey, int Column = 0, int Order = 0, /// Binds a form field to a model property with validation and layout configuration. public class FormFieldBinding( Expression> selector, - Func factory, + Func factory, Func visible, ISignalSender updateSignal, string? label = null, diff --git a/Ivy/Views/FuncView.cs b/Ivy/Views/FuncView.cs index 4098c1d36d..e197c32c9a 100644 --- a/Ivy/Views/FuncView.cs +++ b/Ivy/Views/FuncView.cs @@ -10,13 +10,13 @@ namespace Ivy.Views; /// The current view context providing access to /// state management, services, and other view-related functionality. /// The view content object that should be displayed. -public delegate object? FuncBuilder(IViewContext context); +public delegate object? FuncViewBuilder(IViewContext context); /// /// Represents a function-based view that dynamically creates content /// using a view factory function. /// -public class FuncView(FuncBuilder viewFactory) : ViewBase +public class FuncView(FuncViewBuilder viewFactory) : ViewBase { /// /// Builds the view content by invoking the view factory function diff --git a/Ivy/Views/Layout.cs b/Ivy/Views/Layout.cs index 4ded852ee1..2fa66ff30f 100644 --- a/Ivy/Views/Layout.cs +++ b/Ivy/Views/Layout.cs @@ -130,4 +130,9 @@ public static LayoutView WithMargin(this object anything, int left, int top, int { return Layout.Horizontal(anything).Margin(left, top, right, bottom); } + + public static LayoutView WithLayout(this object anything) + { + return Layout.Vertical(anything); + } } \ No newline at end of file diff --git a/Ivy/Views/Tables/TableBuilder.cs b/Ivy/Views/Tables/TableBuilder.cs index 2885cd5846..c90b03c475 100644 --- a/Ivy/Views/Tables/TableBuilder.cs +++ b/Ivy/Views/Tables/TableBuilder.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; using System.Reflection; using Ivy.Core; @@ -86,10 +87,12 @@ private void _Scaffold() var fields = type .GetFields() + .Where(f => f.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, Type = e.FieldType, FieldInfo = e, PropertyInfo = (PropertyInfo)null! }) .Union( type .GetProperties() + .Where(p => p.GetCustomAttribute()?.Scaffold != false) .Select(e => new { e.Name, Type = e.PropertyType, FieldInfo = (FieldInfo)null!, PropertyInfo = e }) ) .ToList(); diff --git a/Ivy/Views/TaskView.cs b/Ivy/Views/TaskView.cs index 950c6d1fba..58339f8477 100644 --- a/Ivy/Views/TaskView.cs +++ b/Ivy/Views/TaskView.cs @@ -21,8 +21,8 @@ public class TaskView(Task task) : ViewBase UseEffect(async () => { - await task; - taskResult.Set(task.Result!); + var result = await task; + taskResult.Set(result!); }); return taskResult.Value; @@ -49,4 +49,4 @@ public static ViewBase FromTask(Task task) .CreateInstance(taskViewType, [task]); return (ViewBase)taskViewInstance!; } -} \ No newline at end of file +} diff --git a/Ivy/Widgets/AudioRecorder.cs b/Ivy/Widgets/AudioRecorder.cs index ece3b0258c..1d0c2db1cf 100644 --- a/Ivy/Widgets/AudioRecorder.cs +++ b/Ivy/Widgets/AudioRecorder.cs @@ -1,4 +1,5 @@ using Ivy.Core; +using Ivy.Services; using Ivy.Shared; // ReSharper disable once CheckNamespace @@ -7,20 +8,20 @@ namespace Ivy; /// Audio recorder control allowing users to upload audio using microphone with configurable upload intervals. public record AudioRecorder : WidgetBase { - /// Initializes AudioRecorder with basic configuration. + /// Initializes AudioRecorder with upload context and optional configuration. + /// Upload context for automatic audio file uploads (from UseUpload). /// Label text displayed when no audio is recording. /// Label text displayed when audio is recording. /// Mime type of recorded audio data (e.g., "audio/webm"). /// Chunk size in milliseconds for continuous uploads. If null, uploads when recording stops. - /// Upload URL for automatic audio file uploads. /// Whether widget should be disabled initially. - public AudioRecorder(string? label = null, string? recordingLabel = null, string mimeType = "audio/webm", int? chunkInterval = null, string? uploadUrl = null, bool disabled = false) + public AudioRecorder(UploadContext upload, string? label = null, string? recordingLabel = null, string mimeType = "audio/webm", int? chunkInterval = null, bool disabled = false) { + UploadUrl = upload.UploadUrl; Label = label; RecordingLabel = recordingLabel; MimeType = mimeType; ChunkInterval = chunkInterval; - UploadUrl = uploadUrl; Disabled = disabled; } @@ -114,4 +115,4 @@ public static AudioRecorder Small(this AudioRecorder widget) { return widget.Size(Sizes.Small); } -} \ No newline at end of file +} diff --git a/Ivy/Widgets/Inputs/FileInput.cs b/Ivy/Widgets/Inputs/FileInput.cs index c3e2299481..98a2856c0d 100644 --- a/Ivy/Widgets/Inputs/FileInput.cs +++ b/Ivy/Widgets/Inputs/FileInput.cs @@ -1,38 +1,14 @@ using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.Tasks; using Ivy.Core; using Ivy.Core.Helpers; using Ivy.Core.Hooks; +using Ivy.Services; using Ivy.Shared; using Ivy.Widgets.Inputs; // ReSharper disable once CheckNamespace namespace Ivy; -/// -/// Represents a file uploaded through a file input control. -/// -public record FileInput -{ - /// 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; } - - /// Gets the size of the uploaded file in bytes. - public int Size { get; init; } - - /// Gets the last modified date of the uploaded file. - public DateTime LastModified { get; init; } - - /// Gets the binary content of the uploaded file. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public byte[]? Content { get; init; } -} - /// /// Defines the visual variants available for file input controls. /// @@ -74,6 +50,9 @@ public abstract record FileInputBase : WidgetBase, IAnyFileInput /// Gets or sets the accepted file types using MIME types or file extensions. [Prop] public string? Accept { get; set; } + /// Gets or sets the maximum file size in bytes. + [Prop] public long? MaxFileSize { get; set; } + /// Gets or sets whether multiple files can be selected. [Prop] public bool Multiple { get; set; } @@ -89,6 +68,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 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. /// @@ -102,11 +84,17 @@ public ValidationResult ValidateValue(object? value) { if (value == null) return ValidationResult.Success(); - if (value is FileInput file) + if (value is IFileUpload file) { - return FileInputValidation.ValidateFileType(file, Accept); + // Validate file type + var typeValidation = FileInputValidation.ValidateFileType(file, Accept); + if (!typeValidation.IsValid) return typeValidation; + + // Validate file size + return FileInputValidation.ValidateFileSize(file, MaxFileSize); } - else if (value is IEnumerable files) + + if (value is IEnumerable files) { var filesList = files.ToList(); @@ -120,10 +108,18 @@ public ValidationResult ValidateValue(object? value) } } - // Then validate file types if Accept is set + // Validate file types if Accept is set if (!string.IsNullOrWhiteSpace(Accept)) { - return FileInputValidation.ValidateFileTypes(filesList, Accept); + var typeValidation = FileInputValidation.ValidateFileTypes(filesList, Accept); + if (!typeValidation.IsValid) return typeValidation; + } + + // Validate file sizes + foreach (var f in filesList) + { + var sizeValidation = FileInputValidation.ValidateFileSize(f, MaxFileSize); + if (!sizeValidation.IsValid) return sizeValidation; } } @@ -133,6 +129,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 @@ -150,55 +147,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) + public FileInput(TValue value, 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) - : this(placeholder, disabled, variant) - { - OnChange = onChange == null ? null : e => { onChange(e); return ValueTask.CompletedTask; }; Value = value; } @@ -221,8 +182,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; } /// @@ -230,66 +191,141 @@ 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) + [Obsolete("ToFileInput now requires an UploadContext. Use state.ToFileInput(uploadContext, ...).", true)] + public static FileInputBase ToFileInput(this IAnyState state, string? placeholder = null, bool disabled = false, FileInputs variant = FileInputs.Drop) { - if (file.Content == null) - { - return null; - } - return file.Content.Length switch - { - 0 => null, - _ => Encoding.UTF8.GetString(file.Content) - }; + throw new NotSupportedException("ToFileInput now requires an UploadContext. Use state.ToFileInput(uploadContext, ...)."); } /// - /// Creates a file input from a state object. + /// Creates a file input that automatically uploads files using the provided upload context + /// and wires cancellation with state reset. /// - /// The state object to bind to. + /// The state to bind the file input to. + /// The upload context state from UseUpload hook. /// Optional placeholder text displayed when no files are selected. /// Whether the input should be disabled initially. /// The visual variant of the file input. - public static FileInputBase ToFileInput(this IAnyState state, string? placeholder = null, bool disabled = false, FileInputs variant = FileInputs.Drop) + public static FileInputBase ToFileInput(this IAnyState state, IState uploadContext, string? placeholder = null, bool disabled = false, FileInputs variant = FileInputs.Drop) { - var type = state.GetStateType(); + 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); + } - //Check that type is FileInput, FileInput? or IEnumerable - var isCollection = type.IsGenericType && - type.GetGenericTypeDefinition() == typeof(IEnumerable<>) && - type.GetGenericArguments()[0] == typeof(FileInput); - var isValid = type == typeof(FileInput) || isCollection; + static 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); + } + 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; + } + + static FileUpload Project(IFileUpload f) => new() + { + Id = f.Id, + FileName = f.FileName, + ContentType = f.ContentType, + Length = f.Length, + Progress = f.Progress, + Status = f.Status + }; - if (!isValid) + var stateType = state.GetStateType(); + var isCollection = IsEnumerableOfFileUpload(stateType); + + FileInputBase input; + if (isCollection) + { + var valueObj = state.As().Value; + IEnumerable projected = Array.Empty(); + if (valueObj is IEnumerable list) + { + projected = list.Select(Project).ToArray(); + } + input = new FileInput>(projected, placeholder, disabled, variant) with { Multiple = true }; + } + else { - throw new Exception("Invalid type for FileInput"); + var valueObj = state.As().Value; + FileUpload? single = valueObj is IFileUpload f ? Project(f) : null; + input = new FileInput(single, placeholder, disabled, variant); } - Type genericType = typeof(FileInput<>).MakeGenericType(type); - FileInputBase input = (FileInputBase)Activator.CreateInstance(genericType, state, placeholder, disabled, variant)!; + var ctx = uploadContext.Value; + input = input with + { + UploadUrl = ctx.UploadUrl, + Accept = ctx.Accept ?? input.Accept, + MaxFileSize = ctx.MaxFileSize, + MaxFiles = ctx.MaxFiles ?? input.MaxFiles + }; - // Set Multiple based on type - input = input with { Multiple = isCollection }; + input = input with + { + OnCancel = e => + { + var fileId = e.Value; + uploadContext.Value.Cancel(fileId); - return input; - } + try + { + // Handle common immutable collection cases by removing the canceled file + if (stateType == typeof(System.Collections.Immutable.ImmutableArray)) + { + var s = state.As>(); + s.Set(list => + { + var builder = System.Collections.Immutable.ImmutableArray.CreateBuilder(list.Length); + foreach (var f in list) + { + if (f.Id != fileId) builder.Add(f); + } + return builder.ToImmutable(); + }); + } + else if (stateType == typeof(System.Collections.Immutable.ImmutableArray>)) + { + var s = state.As>>(); + s.Set(list => + { + var builder = System.Collections.Immutable.ImmutableArray.CreateBuilder>(list.Length); + foreach (var f in list) + { + if (f.Id != fileId) builder.Add(f); + } + return builder.ToImmutable(); + }); + } + else + { + // Fallback: reset state (works for single-file or unsupported collections) + state.As().Reset(); + } + } + catch + { + // As a last resort, reset + state.As().Reset(); + } + + return ValueTask.CompletedTask; + } + }; - /// - /// Creates a file input that automatically uploads files to the specified upload URL. - /// - /// The state to bind the file input to. - /// The upload URL state from UseUpload hook. - /// Optional placeholder text displayed when no files are selected. - /// Whether the input should be disabled initially. - /// The visual variant of the file input. - public static FileInputBase ToFileInput(this IAnyState state, IState uploadUrl, string? placeholder = null, bool disabled = false, FileInputs variant = FileInputs.Drop) - { - var input = state.ToFileInput(placeholder, disabled, variant); - input = input with { UploadUrl = uploadUrl.Value }; return input; } @@ -347,6 +383,14 @@ public static FileInputBase MaxFiles(this FileInputBase widget, int maxFiles) return widget with { MaxFiles = maxFiles }; } + /// Sets the maximum file size in bytes for the file input. + /// The file input to configure. + /// The maximum file size in bytes. + public static FileInputBase MaxFileSize(this FileInputBase widget, long maxFileSize) + { + return widget with { MaxFileSize = maxFileSize }; + } + /// Sets the upload URL for automatic file uploads. /// The file input to configure. /// The upload URL where files should be automatically uploaded. @@ -382,7 +426,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, IFileUpload file) { return FileInputValidation.ValidateFileType(file, widget.Accept); } @@ -392,7 +436,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(); @@ -407,7 +451,6 @@ public static ValidationResult ValidateFiles(this FileInputBase widget, IEnumera return FileInputValidation.ValidateFileTypes(filesList, widget.Accept); } - /// /// Sets the blur event handler for the file input. /// @@ -438,4 +481,35 @@ public static FileInputBase HandleBlur(this FileInputBase widget, Action onBlur) { return widget.HandleBlur(_ => { onBlur(); return ValueTask.CompletedTask; }); } -} \ No newline at end of file + + /// + /// Sets the cancel event handler for the file input. + /// + /// The file input to configure. + /// The event handler to call when a file is canceled, receives the FileUpload.Id. + [OverloadResolutionPriority(1)] + public static FileInputBase HandleCancel(this FileInputBase widget, Func, ValueTask> onCancel) + { + return widget with { OnCancel = onCancel }; + } + + /// + /// Sets the cancel event handler for the file input. + /// + /// The file input to configure. + /// 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.HandleCancel(onCancel.ToValueTask()); + } + + /// + /// Sets a simple cancel event handler for the file input. + /// + /// The file input to configure. + /// 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.HandleCancel(e => { onCancel(e.Value); return ValueTask.CompletedTask; }); + } +} diff --git a/Ivy/Widgets/Inputs/FileInputValidation.cs b/Ivy/Widgets/Inputs/FileInputValidation.cs index 07344dac29..a7c4f49fcb 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(); @@ -38,7 +39,7 @@ public static ValidationResult ValidateFileTypes(IEnumerable files, s { if (!IsFileTypeAllowed(file, allowedPatterns)) { - invalidFiles.Add(file.Name); + invalidFiles.Add(file.FileName ?? "unknown"); } } @@ -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(IFileUpload file, string? accept) { if (string.IsNullOrWhiteSpace(accept)) return ValidationResult.Success(); @@ -64,7 +65,26 @@ 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(); + } + + /// + /// Validates file size against maximum allowed size + /// + /// The file to validate + /// Maximum file size in bytes + public static ValidationResult ValidateFileSize(IFileUpload file, long? maxFileSize) + { + if (maxFileSize == null) return ValidationResult.Success(); + + if (file.Length > maxFileSize.Value) + { + var maxSizeFormatted = Utils.FormatBytes(maxFileSize.Value); + var fileSizeFormatted = Utils.FormatBytes(file.Length); + return ValidationResult.Error($"File '{file.FileName}' is too large ({fileSizeFormatted}). Maximum allowed size is {maxSizeFormatted}."); } return ValidationResult.Success(); @@ -78,7 +98,7 @@ private static List ParseAcceptPattern(string accept) .ToList(); } - private static bool IsFileTypeAllowed(FileInput file, List allowedPatterns) + private static bool IsFileTypeAllowed(IFileUpload file, List allowedPatterns) { foreach (var pattern in allowedPatterns) { @@ -90,8 +110,14 @@ private static bool IsFileTypeAllowed(FileInput file, List allowedPatter return false; } - private static bool IsFileTypeMatch(FileInput file, string pattern) + private static bool IsFileTypeMatch(IFileUpload file, string pattern) { + // Handle special case: accept all files + if (pattern == "*/*" || pattern == "*") + { + return true; + } + // Handle MIME type patterns (e.g., "image/*", "text/plain") if (pattern.Contains("/")) { @@ -99,24 +125,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) ?? false; } 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 @@ -140,4 +166,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 +} diff --git a/frontend/src/components/TextShimmer.tsx b/frontend/src/components/TextShimmer.tsx index 5e64ac94de..a84f7d60bf 100644 --- a/frontend/src/components/TextShimmer.tsx +++ b/frontend/src/components/TextShimmer.tsx @@ -11,27 +11,17 @@ interface TextShimmerProps { spread?: number; } -// Create motion components outside of render -const MotionP = motion('p'); -const MotionSpan = motion('span'); -const MotionDiv = motion('div'); -const MotionH1 = motion('h1'); -const MotionH2 = motion('h2'); -const MotionH3 = motion('h3'); -const MotionH4 = motion('h4'); -const MotionH5 = motion('h5'); -const MotionH6 = motion('h6'); - +// Use component properties instead of string factory (avoids deprecation warnings) const motionComponents = { - p: MotionP, - span: MotionSpan, - div: MotionDiv, - h1: MotionH1, - h2: MotionH2, - h3: MotionH3, - h4: MotionH4, - h5: MotionH5, - h6: MotionH6, + p: motion.p, + span: motion.span, + div: motion.div, + h1: motion.h1, + h2: motion.h2, + h3: motion.h3, + h4: motion.h4, + h5: motion.h5, + h6: motion.h6, } as const; export function TextShimmer({ @@ -42,7 +32,9 @@ export function TextShimmer({ spread = 2, }: TextShimmerProps) { const MotionComponent = - motionComponents[Component as keyof typeof motionComponents] || MotionP; + (motionComponents as Record)[ + (Component as unknown as string) || 'p' + ] ?? motion.p; const dynamicSpread = useMemo(() => { return children.length * spread; diff --git a/frontend/src/components/ui/input/file-input-variants.ts b/frontend/src/components/ui/input/file-input-variants.ts index 5b156d9d83..e86dcae7d6 100644 --- a/frontend/src/components/ui/input/file-input-variants.ts +++ b/frontend/src/components/ui/input/file-input-variants.ts @@ -6,9 +6,9 @@ export const fileInputVariants = cva( { variants: { size: { - Small: 'min-h-[80px] p-2 border-2', - Medium: 'min-h-[100px] p-4 border-2', - Large: 'min-h-[120px] p-6 border-3', + Small: 'min-h-[80px] max-h-[200px] p-2 border-2', + Medium: 'min-h-[100px] max-h-[300px] p-4 border-2', + Large: 'min-h-[120px] max-h-[400px] p-6 border-3', }, }, defaultVariants: { diff --git a/frontend/src/routing-constants.json b/frontend/src/routing-constants.json index 4e10fb684e..7471a51dd3 100644 --- a/frontend/src/routing-constants.json +++ b/frontend/src/routing-constants.json @@ -3,6 +3,7 @@ "/messages", "/webhook", "/auth", + "/upload", "/assets", "/fonts", "/_framework", diff --git a/frontend/src/widgets/article/GitHubContributors.tsx b/frontend/src/widgets/article/GitHubContributors.tsx index 9d9da40536..9eac5ac8be 100644 --- a/frontend/src/widgets/article/GitHubContributors.tsx +++ b/frontend/src/widgets/article/GitHubContributors.tsx @@ -36,7 +36,7 @@ interface GitHubContributorsProps { const IVY_TEAM_MEMBERS: Record = { ArtemKhvorostianyi: 'Engineer', rorychatt: 'Founding Engineer', - nielsbosma: 'CEO', + nielsbosma: 'Founder', zachwolfe: 'Software Developer', // Add more team members as needed }; diff --git a/frontend/src/widgets/inputs/FileInputWidget.tsx b/frontend/src/widgets/inputs/FileInputWidget.tsx index 1f3af59304..27d8867be5 100644 --- a/frontend/src/widgets/inputs/FileInputWidget.tsx +++ b/frontend/src/widgets/inputs/FileInputWidget.tsx @@ -1,24 +1,34 @@ 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 { toast } from '@/hooks/use-toast'; import { fileInputVariants, uploadIconVariants, textVariants, } from '@/components/ui/input/file-input-variants'; +enum FileInputStatus { + Pending = 'Pending', + Aborted = 'Aborted', + Loading = 'Loading', + Failed = 'Failed', + Finished = 'Finished', +} + interface FileInput { - name: string; - size: number; - type: string; - lastModified: Date; - content?: string; + id: string; + fileName: string; + contentType: string; + length: number; + progress: number; + status: FileInputStatus; } interface FileInputWidgetProps { @@ -29,6 +39,7 @@ interface FileInputWidgetProps { events: string[]; width?: string; accept?: string; + maxFileSize?: number; multiple?: boolean; maxFiles?: number; placeholder?: string; @@ -41,8 +52,10 @@ export const FileInputWidget: React.FC = ({ value, disabled, invalid, + events, width, accept, + maxFileSize, multiple = false, maxFiles, placeholder, @@ -53,10 +66,44 @@ export const FileInputWidget: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const inputRef = useRef(null); + // Be defensive in case events is undefined at runtime + const hasCancelHandler = Array.isArray(events) && events.includes('OnCancel'); + + const formatBytes = (bytes: number): string => { + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) return '0 B'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const size = bytes / Math.pow(1024, i); + return `${size.toFixed(size >= 10 ? 0 : 2)} ${sizes[i]}`; + }; + + const validateFile = useCallback( + (file: File): boolean => { + // Validate file size + if (maxFileSize && file.size > maxFileSize) { + const maxSizeFormatted = formatBytes(maxFileSize); + const fileSizeFormatted = formatBytes(file.size); + toast({ + title: 'File too large', + description: `File '${file.name}' is ${fileSizeFormatted}. Maximum allowed size is ${maxSizeFormatted}.`, + variant: 'destructive', + }); + return false; + } + return true; + }, + [maxFileSize] + ); + const uploadFile = useCallback( async (file: File): Promise => { if (!uploadUrl) return; + // Validate file before upload - show toast on error + if (!validateFile(file)) { + return; + } + // Get the correct host from meta tag or use relative URL const getUploadUrl = () => { const ivyHostMeta = document.querySelector('meta[name="ivy-host"]'); @@ -84,29 +131,7 @@ export const FileInputWidget: React.FC = ({ console.error('File upload error:', error); } }, - [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, not file content - return { - name: file.name, - size: file.size, - type: file.type, - lastModified: new Date(file.lastModified), - // Don't include content - it's handled by UploadService - }; - }, - [uploadFile, uploadUrl] + [uploadUrl, validateFile] ); const handleChange = useCallback( @@ -114,30 +139,50 @@ export const FileInputWidget: React.FC = ({ const files = e.target.files; if (!files) return; - // Check max files limit - 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]); + // Check max files limit (including already uploaded files) + const currentFileCount = Array.isArray(value) + ? value.length + : value + ? 1 + : 0; + if (maxFiles && currentFileCount + files.length > maxFiles) { + const remaining = maxFiles - currentFileCount; + toast({ + title: 'Too many files', + description: + remaining > 0 + ? `You can only upload ${remaining} more file${remaining !== 1 ? 's' : ''}. Maximum is ${maxFiles} files total.` + : `Maximum of ${maxFiles} file${maxFiles !== 1 ? 's' : ''} already reached.`, + variant: 'destructive', + }); + e.target.value = ''; return; } - const selectedFiles = multiple - ? await Promise.all(Array.from(files).map(convertFileToUploadFile)) - : await convertFileToUploadFile(files[0]); + if (multiple) { + await Promise.all(Array.from(files).map(uploadFile)); + } else { + await uploadFile(files[0]); + } - handleEvent('OnChange', id, [selectedFiles]); + // Reset the input so selecting the same file again triggers onChange + e.target.value = ''; }, - [id, multiple, handleEvent, convertFileToUploadFile, maxFiles] + [multiple, uploadFile, maxFiles, value] ); - const handleClear = useCallback(() => { - handleEvent('OnChange', id, [null]); - }, [id, handleEvent]); + const handleCancel = useCallback( + (fileId: string) => { + if (hasCancelHandler) { + handleEvent('OnCancel', id, [fileId]); + } + // Also clear file input to allow re-selecting same file + if (inputRef.current) { + inputRef.current.value = ''; + } + }, + [hasCancelHandler, handleEvent, id] + ); const handleDragEnter = useCallback( (e: React.DragEvent) => { @@ -172,38 +217,94 @@ export const FileInputWidget: React.FC = ({ const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; - // Check max files limit - 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]); + // Check max files limit (including already uploaded files) + const currentFileCount = Array.isArray(value) + ? value.length + : value + ? 1 + : 0; + if (maxFiles && currentFileCount + files.length > maxFiles) { + const remaining = maxFiles - currentFileCount; + toast({ + title: 'Too many files', + description: + remaining > 0 + ? `You can only upload ${remaining} more file${remaining !== 1 ? 's' : ''}. Maximum is ${maxFiles} files total.` + : `Maximum of ${maxFiles} file${maxFiles !== 1 ? 's' : ''} already reached.`, + variant: 'destructive', + }); return; } - const selectedFiles = multiple - ? await Promise.all(files.map(convertFileToUploadFile)) - : await convertFileToUploadFile(files[0]); + if (multiple) { + await Promise.all(files.map(uploadFile)); + } else { + await uploadFile(files[0]); + } + }, + [multiple, disabled, uploadFile, maxFiles, value] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + // Don't trigger file selection if clicking on a file item or button + const target = e.target as HTMLElement; + if (target.closest('button') || target.closest('[data-file-item]')) { + return; + } - handleEvent('OnChange', id, [selectedFiles]); + if (!disabled && inputRef.current) { + inputRef.current.click(); + } }, - [id, multiple, handleEvent, disabled, convertFileToUploadFile, maxFiles] + [disabled] ); - const handleClick = useCallback(() => { - if (!disabled && inputRef.current) { - inputRef.current.click(); - } - }, [disabled]); + // Render individual file item for multiple files view + const renderFileItem = (file: FileInput) => { + const isFileLoading = file.status === FileInputStatus.Loading; + const fileProgress = file.progress ?? 0; + + return ( +
+
+

{file.fileName}

+ {isFileLoading && ( +
+
+
+
+
+ )} +
+ {hasCancelHandler && ( + + )} +
+ ); + }; - const displayValue = value - ? Array.isArray(value) - ? value.map(f => f.name).join(', ') - : value.name - : ''; + // 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 (
= ({ onDragOver={handleDragOver} onDrop={handleDrop} > - {/* Invalid icon in top right corner, above input */} + {/* Invalid icon in top right corner - only for required field validation */} {invalid && (
@@ -226,7 +327,9 @@ export const FileInputWidget: React.FC = ({ 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' : 'flex items-center justify-center', + 'p-4' )} onClick={handleClick} > @@ -240,27 +343,23 @@ export const FileInputWidget: React.FC = ({ disabled={disabled} className="hidden" /> -
+ + {/* Always show upload icon */} +
-

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

+ {!hasFiles && ( +

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

+ )}
- {value && !disabled && ( - + + {/* Show file list when files are present */} + {hasFiles && ( +
+ {fileList.map(file => renderFileItem(file))} +
)}
diff --git a/frontend/src/widgets/layouts/TabsLayoutWidget.tsx b/frontend/src/widgets/layouts/TabsLayoutWidget.tsx index 1c456c0b47..79a107f672 100644 --- a/frontend/src/widgets/layouts/TabsLayoutWidget.tsx +++ b/frontend/src/widgets/layouts/TabsLayoutWidget.tsx @@ -200,6 +200,22 @@ export const TabsLayoutWidget = ({ }); const activeTabIdRef = React.useRef(activeTabId); const eventHandler = useEventHandler(); + const safeEvent = React.useCallback( + ( + name: + | 'OnSelect' + | 'OnClose' + | 'OnRefresh' + | 'OnReorder' + | 'OnAddButtonClick', + args: unknown[] + ) => { + if (Array.isArray(events) && events.includes(name)) { + eventHandler(name, id, args); + } + }, + [events, eventHandler, id] + ); const containerRef = React.useRef(null); const tabsListRef = React.useRef(null); const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]); @@ -569,13 +585,13 @@ export const TabsLayoutWidget = ({ // Update activeIndex for Content variant animation const newIndex = tabOrder.indexOf(tabId); setActiveIndex(newIndex); - eventHandler('OnSelect', id, [newIndex]); + if (events?.includes('OnSelect')) safeEvent('OnSelect', [newIndex]); }; const handleMouseDown = (e: React.MouseEvent, index: number) => { if (e.button === 1) { e.preventDefault(); - eventHandler('OnClose', id, [index]); + safeEvent('OnClose', [index]); } }; @@ -625,7 +641,7 @@ export const TabsLayoutWidget = ({ ) { // Mark as user-initiated to prevent backend sync from interfering isUserInitiatedChangeRef.current = true; - eventHandler('OnReorder', id, [reorderMapping]); + safeEvent('OnReorder', [reorderMapping]); } else { console.error('Tab reorder aborted: Invalid mapping', { reorderMapping, @@ -646,7 +662,7 @@ export const TabsLayoutWidget = ({ } isDraggingRef.current = false; }, - [tabOrder, activeTabId, eventHandler, id, tabWidgets] + [tabOrder, activeTabId, safeEvent, tabWidgets] ); React.useEffect(() => { @@ -654,7 +670,8 @@ export const TabsLayoutWidget = ({ const customEvent = e as CustomEvent<{ id: string }>; if (!customEvent.detail?.id) return; const idx = tabOrderRef.current.indexOf(customEvent.detail.id); - if (idx !== -1) eventHandlerRef.current(eventType, id, [idx]); + if (idx !== -1 && events?.includes(eventType)) + eventHandlerRef.current(eventType, id, [idx]); }; const closeHandler = handleTabEvent('OnClose'); @@ -667,7 +684,7 @@ export const TabsLayoutWidget = ({ window.removeEventListener('tab-close', closeHandler); window.removeEventListener('tab-refresh', refreshHandler); }; - }, [id]); + }, [id, events]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), @@ -714,7 +731,7 @@ export const TabsLayoutWidget = ({ e.stopPropagation(); // Mark as user-initiated to prevent sync issues isUserInitiatedChangeRef.current = true; - eventHandler('OnRefresh', id, [tabOrder.indexOf(tabId)]); + safeEvent('OnRefresh', [tabOrder.indexOf(tabId)]); }} className="opacity-60 p-1 rounded-full border border-transparent hover:border-border hover:bg-accent hover:opacity-100 transition-colors cursor-pointer" > @@ -727,7 +744,7 @@ export const TabsLayoutWidget = ({ e.stopPropagation(); // Mark as user-initiated since close affects selection isUserInitiatedChangeRef.current = true; - eventHandler('OnClose', id, [tabOrder.indexOf(tabId)]); + safeEvent('OnClose', [tabOrder.indexOf(tabId)]); }} className="opacity-60 p-1 rounded-full border border-transparent hover:border-border hover:bg-accent hover:opacity-100 transition-colors cursor-pointer" > @@ -864,7 +881,7 @@ export const TabsLayoutWidget = ({ addToLoadedTabs(tabId); setActiveIndex(index); setActiveTabId(tabId); - eventHandler('OnSelect', id, [index]); + safeEvent('OnSelect', [index]); }} >
@@ -876,7 +893,7 @@ export const TabsLayoutWidget = ({ {addButtonText && (