Files
Antigravity 5a2223a4c8 feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)
This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility.

### Key Changes
- **Infrastructure Stabilization**:
  - Implemented production-grade `EbookRepository` with PostgreSQL `EF.Functions.ILike` support.
  - Enforced `IsReadyForReading = false` state for newly added ebooks (resolves #35).
  - Updated `SignalRSyncBroadcaster` to support targeted user messaging and ingestion-specific progress updates (resolves #37).
- **WASM Client Functional Proxies**:
  - Replaced "Throwing" dummy services with `WasmEbookRepository`, `WasmSyncBroadcaster`, `WasmBookStorageService`, and `WasmEmbeddingGenerator`.
  - These services proxy requests to the backend via a new set of Minimal API endpoints in `NexusReader.Web`.
- **Domain Refinement**:
  - Added `IsReadyForReading` flag to the `Ebook` entity to manage background AI processing states.

### Related Issues
- Fixes #35
- Fixes #36
- Fixes #37

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #42
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-13 18:24:24 +00:00

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 places
  • C - Currency
  • P - Percentage
  • D - Date
  • X - 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 time
  • InputDateType.Month - Month and year
  • InputDateType.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.