150cbcdc29
Critical fixes (review findings #1, #2, #3): - Create IEbookRepository abstraction in Application layer - Remove illegal EF Core dependency from IngestEbookCommandHandler - Create EbookRepository implementation in Infrastructure/Persistence - Create ISyncBroadcaster in Application/Abstractions/Messaging - Create SignalRSyncBroadcaster in Infrastructure/RealTime - Move UpdateReadingProgressCommandHandler from Infrastructure → Application - Add EbookId to GetReaderPageQuery and IEpubReader signature - Rewrite EpubReaderService: DB-resolved file path, remove auto-provisioning - Split EpubService.cs into EpubReaderService.cs + EpubMetadataExtractor.cs - Add CurrentEbookId to IReaderNavigationService and ReaderNavigationService - Update WasmEpubReader and /api/epub endpoint for new signature High severity fixes (#4, #6, #7, #8, #16): - Change BookStorageService registration from Singleton → Scoped - Fix empty catch{} in ReaderCanvas JS interop init — now logs warnings - Replace all Console.WriteLine with ILogger in KnowledgeService + ReaderCanvas - Cache JsonSerializerOptions as static field in KnowledgeService - Wrap SyncService Task.Run body in comprehensive try/catch with ILogger Medium/Low fixes (#11, #13, #14, #15, #18, #20): - BookIngestionModal.DisposeAsync now nullifies _epubBytes (50MB array) - KnowledgeCoordinator.OnGraphUpdated: Action<T> → Func<T, Task> - BookStorageService: Path.Combine → forward-slash string interpolation - SignalR CancellationToken passed as named parameter (not payload arg)
12 KiB
12 KiB
Blazor Forms & Validation
EditForm Component
EditForm provides a complete form handling solution with data binding and validation.
Basic EditForm
@page "/register"
<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label>Name:</label>
<InputText @bind-Value="model.Name" class="form-control" />
<ValidationMessage For="@(() => model.Name)" />
</div>
<div class="form-group">
<label>Email:</label>
<InputText @bind-Value="model.Email" class="form-control" />
<ValidationMessage For="@(() => model.Email)" />
</div>
<button type="submit" class="btn">Register</button>
</EditForm>
@code {
private RegistrationModel model = new();
private async Task HandleValidSubmit()
{
// Form is valid, process data
await Service.RegisterUserAsync(model);
}
}
public class RegistrationModel
{
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = "";
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Required]
[MinLength(8)]
public string Password { get; set; } = "";
}
EditForm Events
<EditForm Model="@model"
OnValidSubmit="@OnValidSubmit"
OnInvalidSubmit="@OnInvalidSubmit"
OnSubmit="@OnSubmit">
<!-- Form content -->
</EditForm>
@code {
private async Task OnValidSubmit()
{
// Fires when form is valid and submitted
}
private async Task OnInvalidSubmit()
{
// Fires when form is invalid and submitted
}
private async Task OnSubmit()
{
// Fires for any submit (valid or invalid)
// Useful for custom validation logic
}
}
Form State Control
@inject EditFormService FormService
<EditForm Model="@model" @ref="form">
<!-- Form content -->
</EditForm>
<button @onclick="Submit">Submit</button>
<button @onclick="Reset">Reset</button>
<button @onclick="CheckValid">Is Valid?</button>
@code {
private EditForm? form;
private UserModel model = new();
private async Task Submit()
{
// Manually trigger validation and submission
await form!.RequestValidationAsync();
// Check if valid
if (form!.EditContext.IsModified() && form!.EditContext.Validate())
{
// Process form
}
}
private void Reset()
{
// Reset all fields to default
form!.EditContext.ResetEditingItemAsync();
}
private void CheckValid()
{
bool isValid = form!.EditContext.Validate();
Console.WriteLine($"Form valid: {isValid}");
}
}
Input Components
Text Input
<InputText @bind-Value="model.Name" class="form-control" />
<InputTextArea @bind-Value="model.Description" rows="4" />
@code {
private UserModel model = new();
}
Numeric Input
<InputNumber @bind-Value="model.Age" class="form-control" />
<InputNumber @bind-Value="model.Price" @bind-Value:format="N2" />
@code {
private int age;
private decimal price;
}
Format specifiers:
N2- Number with 2 decimal placesC- CurrencyP- PercentageD- DateX- Hexadecimal
Date Input
<InputDate @bind-Value="model.BirthDate" />
<InputDate @bind-Value="model.StartTime" Type="InputDateType.DateTimeLocal" />
@code {
private DateTime birthDate;
private DateTime startTime;
}
Types:
InputDateType.Date- Date only (default)InputDateType.DateTimeLocal- Date and timeInputDateType.Month- Month and yearInputDateType.Time- Time only
Select/Dropdown
<InputSelect @bind-Value="model.Category" class="form-control">
<option value="">Select a category...</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</InputSelect>
<!-- Dynamic options from data -->
<InputSelect @bind-Value="model.CategoryId">
<option value="">Select...</option>
@foreach (var cat in categories)
{
<option value="@cat.Id">@cat.Name</option>
}
</InputSelect>
@code {
private string selectedCategory = "";
private List<Category> categories = [];
}
Checkbox
<InputCheckbox @bind-Value="model.AgreeToTerms" />
Accept terms of service?
@code {
private bool agreeToTerms = false;
}
Radio Buttons
<InputRadioGroup @bind-Value="model.Preference">
<div>
<InputRadio Value="@("option1")" />
<label>Option 1</label>
</div>
<div>
<InputRadio Value="@("option2")" />
<label>Option 2</label>
</div>
</InputRadioGroup>
@code {
private string preference = "option1";
}
File Upload
<InputFile OnChange="@HandleFileSelect" />
@code {
private async Task HandleFileSelect(InputFileChangeEventArgs e)
{
var file = e.File;
using var stream = file.OpenReadStream();
var buffer = new byte[stream.Length];
await stream.ReadAsync(buffer);
// Process file
}
}
Validation
DataAnnotations Validation
public class UserModel
{
[Required(ErrorMessage = "Name is required")]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = "";
[Required]
[EmailAddress(ErrorMessage = "Invalid email format")]
public string Email { get; set; } = "";
[Range(18, 120, ErrorMessage = "Age must be 18-120")]
public int Age { get; set; }
[Url]
public string? Website { get; set; }
[Phone]
public string? PhoneNumber { get; set; }
[CreditCard]
public string? CardNumber { get; set; }
}
Common Validators:
[Required]- Field must have value[StringLength(max)]- Max length[StringLength(max, MinimumLength = min)]- Min and max[EmailAddress]- Valid email format[Range(min, max)]- Numeric range[Url]- Valid URL format[Phone]- Valid phone format[CreditCard]- Valid credit card format[RegularExpression(pattern)]- Regex match
ValidationSummary
Shows all validation errors for the form:
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="model.Name" />
<InputText @bind-Value="model.Email" />
<button type="submit">Submit</button>
</EditForm>
Displays as:
- Name is required
- Email is required
ValidationMessage
Shows validation error for specific field:
<InputText @bind-Value="model.Name" />
<ValidationMessage For="@(() => model.Name)" />
<!-- Custom CSS class -->
<ValidationMessage For="@(() => model.Email)" class="text-danger" />
Custom Validation
Implement IValidatableObject for complex validation rules:
public class UserModel : IValidatableObject
{
public string Email { get; set; } = "";
public string ConfirmEmail { get; set; } = "";
[Range(18, 100)]
public int Age { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Compare email fields
if (Email != ConfirmEmail)
{
yield return new ValidationResult(
"Email addresses must match",
new[] { nameof(ConfirmEmail) }
);
}
// Custom age validation
if (Age > 0 && Age < 18 && HasRestrictedContent)
{
yield return new ValidationResult(
"Users under 18 cannot access this content",
new[] { nameof(Age) }
);
}
}
public bool HasRestrictedContent { get; set; }
}
Custom Validators
Create reusable custom validators:
public class MinimumAgeAttribute : ValidationAttribute
{
private readonly int _minimumAge;
public MinimumAgeAttribute(int minimumAge)
{
_minimumAge = minimumAge;
}
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is DateTime birthDate)
{
var age = DateTime.Today.Year - birthDate.Year;
if (birthDate.Date > DateTime.Today.AddYears(-age)) age--;
if (age < _minimumAge)
{
return new ValidationResult($"Minimum age is {_minimumAge}");
}
}
return ValidationResult.Success;
}
}
// Usage
public class UserModel
{
[MinimumAge(18)]
public DateTime BirthDate { get; set; }
}
Async Validation
public class UniqueEmailAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
// Can't use async in ValidationAttribute
// Use EditContext instead (see below)
return ValidationResult.Success;
}
}
// Better approach: Manual validation in component
@code {
private async Task HandleValidSubmit()
{
// Check email availability before submit
bool isUnique = await Service.IsEmailUniqueAsync(model.Email);
if (!isUnique)
{
form!.EditContext.AddValidationMessages(
FieldIdentifier.Create(() => model.Email),
new[] { "Email is already registered" }
);
return;
}
await SaveUserAsync(model);
}
}
Form Patterns
Loading State
@if (isSubmitting)
{
<p>Saving...</p>
}
else
{
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="model.Name" />
<button type="submit" disabled="@isSubmitting">
@(isSubmitting ? "Saving..." : "Submit")
</button>
</EditForm>
}
@code {
private bool isSubmitting;
private async Task SubmitAsync()
{
isSubmitting = true;
try
{
await Service.SaveAsync(model);
}
finally
{
isSubmitting = false;
}
}
}
Error Handling
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
<DataAnnotationsValidator />
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<ValidationSummary />
<InputText @bind-Value="model.Name" />
<button type="submit">Submit</button>
</EditForm>
@code {
private string? errorMessage;
private async Task SubmitAsync()
{
try
{
errorMessage = null;
await Service.SaveAsync(model);
}
catch (Exception ex)
{
errorMessage = $"Error: {ex.Message}";
}
}
}
Multi-Step Form
@page "/wizard"
@if (currentStep == 1)
{
<h2>Step 1: Basic Info</h2>
<InputText @bind-Value="model.Name" />
<button @onclick="NextStep">Next</button>
}
else if (currentStep == 2)
{
<h2>Step 2: Contact Info</h2>
<InputText @bind-Value="model.Email" />
<button @onclick="PreviousStep">Back</button>
<button @onclick="NextStep">Next</button>
}
else if (currentStep == 3)
{
<h2>Step 3: Confirm</h2>
<p>Name: @model.Name</p>
<p>Email: @model.Email</p>
<button @onclick="PreviousStep">Back</button>
<button @onclick="SubmitAsync">Submit</button>
}
@code {
private int currentStep = 1;
private UserModel model = new();
private void NextStep() => currentStep++;
private void PreviousStep() => currentStep--;
private async Task SubmitAsync()
{
await Service.RegisterAsync(model);
}
}
Real-Time Field Validation
<input @bind="email" @bind:event="oninput" @onblur="ValidateEmail" />
@if (!string.IsNullOrEmpty(emailError))
{
<span class="error">@emailError</span>
}
@code {
private string email = "";
private string? emailError;
private void ValidateEmail()
{
if (string.IsNullOrEmpty(email))
{
emailError = "Email is required";
}
else if (!email.Contains("@"))
{
emailError = "Invalid email format";
}
else
{
emailError = null;
}
}
}
Related Resources: See state-management-events.md for data binding patterns. See authentication-authorization.md for role-based form customization.