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)
590 lines
12 KiB
Markdown
590 lines
12 KiB
Markdown
# Blazor Forms & Validation
|
|
|
|
## EditForm Component
|
|
|
|
EditForm provides a complete form handling solution with data binding and validation.
|
|
|
|
### Basic EditForm
|
|
|
|
```csharp
|
|
@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
|
|
|
|
```csharp
|
|
<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
|
|
|
|
```csharp
|
|
@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
|
|
|
|
```csharp
|
|
<InputText @bind-Value="model.Name" class="form-control" />
|
|
<InputTextArea @bind-Value="model.Description" rows="4" />
|
|
|
|
@code {
|
|
private UserModel model = new();
|
|
}
|
|
```
|
|
|
|
### Numeric Input
|
|
|
|
```csharp
|
|
<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 places
|
|
- `C` - Currency
|
|
- `P` - Percentage
|
|
- `D` - Date
|
|
- `X` - Hexadecimal
|
|
|
|
### Date Input
|
|
|
|
```csharp
|
|
<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 time
|
|
- `InputDateType.Month` - Month and year
|
|
- `InputDateType.Time` - Time only
|
|
|
|
### Select/Dropdown
|
|
|
|
```csharp
|
|
<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
|
|
|
|
```csharp
|
|
<InputCheckbox @bind-Value="model.AgreeToTerms" />
|
|
Accept terms of service?
|
|
|
|
@code {
|
|
private bool agreeToTerms = false;
|
|
}
|
|
```
|
|
|
|
### Radio Buttons
|
|
|
|
```csharp
|
|
<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
|
|
|
|
```csharp
|
|
<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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
<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:
|
|
|
|
```csharp
|
|
<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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
@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
|
|
|
|
```csharp
|
|
<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
|
|
|
|
```csharp
|
|
@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
|
|
|
|
```csharp
|
|
<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](state-management-events.md) for data binding patterns. See [authentication-authorization.md](authentication-authorization.md) for role-based form customization.
|