diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 9b4e1d6..e2dfdda 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -15,6 +15,7 @@ on: - "aspnet-core/caching-outputcache-redis-invalidation/**" - "aspnet-core/minimal-apis-real-world/**" - "blazor/create-interactive-ui-csharp-12/**" + - "blazor/forms-validation-masterclass/**" - ".github/workflows/build-samples.yml" pull_request: @@ -30,6 +31,7 @@ on: - "aspnet-core/caching-outputcache-redis-invalidation/**" - "aspnet-core/minimal-apis-real-world/**" - "blazor/create-interactive-ui-csharp-12/**" + - "blazor/forms-validation-masterclass/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -387,3 +389,35 @@ jobs: blazor/create-interactive-ui-csharp-12/BlazorTodoMinimal.slnx --configuration Release --no-build + + test-blazor-profile-validation: + name: Test Blazor profile validation sample + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + blazor/forms-validation-masterclass/BlazorProfileValidation.slnx + + - name: Build + run: > + dotnet build + blazor/forms-validation-masterclass/BlazorProfileValidation.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + blazor/forms-validation-masterclass/BlazorProfileValidation.slnx + --configuration Release + --no-build diff --git a/README.md b/README.md index 3380003..6f2586b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`aspnet-core/caching-outputcache-redis-invalidation`](aspnet-core/caching-outputcache-redis-invalidation/) | Minimal .NET 10 Catalog API demonstrating Output Cache policies, query and route variation, tag eviction, write-path invalidation, and integration testing | [ASP.NET Core Caching: Output Cache, Redis & Invalidation Strategies That Actually Work](https://www.dotnet-guide.com/tutorials/aspnet-core/caching-outputcache-redis-invalidation/) | | [`aspnet-core/minimal-apis-real-world`](aspnet-core/minimal-apis-real-world/) | Focused .NET 10 Orders API demonstrating endpoint filters, FluentValidation, URL-segment versioning, typed results, and partitioned rate limiting | [ASP.NET Core / Minimal APIs in the Real World: Filters, Validation, Versioning & Rate Limiting](https://www.dotnet-guide.com/tutorials/aspnet-core/minimal-apis-real-world/) | | [`blazor/create-interactive-ui-csharp-12`](blazor/create-interactive-ui-csharp-12/) | Interactive .NET 10 Todo Dashboard demonstrating Razor components, form binding, DataAnnotations validation, scoped state, EventCallback communication, filtering, and bUnit component tests | [Blazor Web Development: Create Interactive UIs with C# 12](https://www.dotnet-guide.com/tutorials/blazor/create-interactive-ui-csharp-12/) | +| [`blazor/forms-validation-masterclass`](blazor/forms-validation-masterclass/) | Focused .NET 10 Profile Settings form demonstrating manual EditContext management, DataAnnotations, FluentValidation, backend field-error mapping, accessible inputs, dirty state, and bUnit testing | [Blazor .NET 8 Forms & Validation: EditForm, FluentValidation & Server Error Handling](https://www.dotnet-guide.com/tutorials/blazor/forms-validation-masterclass/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -124,27 +125,55 @@ tutorials/ | |-- TransactionalOutboxMinimal.Tests.csproj | `-- OutboxFlowTests.cs |-- blazor/ -| `-- create-interactive-ui-csharp-12/ -| |-- BlazorTodoMinimal.slnx +| |-- create-interactive-ui-csharp-12/ +| | |-- BlazorTodoMinimal.slnx +| | |-- README.md +| | |-- src/ +| | | `-- BlazorTodoMinimal/ +| | | |-- BlazorTodoMinimal.csproj +| | | |-- Program.cs +| | | |-- TodoState.cs +| | | `-- Components/ +| | | |-- _Imports.razor +| | | |-- App.razor +| | | |-- Routes.razor +| | | |-- Pages/ +| | | | |-- NotFound.razor +| | | | `-- Todos.razor +| | | `-- Shared/ +| | | `-- TodoList.razor +| | `-- tests/ +| | `-- BlazorTodoMinimal.Tests/ +| | |-- BlazorTodoMinimal.Tests.csproj +| | `-- TodoDashboardTests.cs +| `-- forms-validation-masterclass/ +| |-- BlazorProfileValidation.slnx | |-- README.md | |-- src/ -| | `-- BlazorTodoMinimal/ -| | |-- BlazorTodoMinimal.csproj +| | `-- BlazorProfileValidation/ +| | |-- BlazorProfileValidation.csproj | | |-- Program.cs -| | |-- TodoState.cs +| | |-- Models/ +| | | `-- ProfileModel.cs +| | |-- Services/ +| | | `-- ProfileService.cs +| | |-- Validation/ +| | | `-- ProfileValidator.cs | | `-- Components/ | | |-- _Imports.razor | | |-- App.razor | | |-- Routes.razor | | |-- Pages/ | | | |-- NotFound.razor -| | | `-- Todos.razor -| | `-- Shared/ -| | `-- TodoList.razor +| | | `-- ProfileSettings.razor +| | |-- Shared/ +| | | `-- FormTextField.razor +| | `-- Validation/ +| | `-- FluentValidationBridge.razor | `-- tests/ -| `-- BlazorTodoMinimal.Tests/ -| |-- BlazorTodoMinimal.Tests.csproj -| `-- TodoDashboardTests.cs +| `-- BlazorProfileValidation.Tests/ +| |-- BlazorProfileValidation.Tests.csproj +| `-- ProfileSettingsTests.cs |-- aspnet-core/ | |-- api-security-in-practice/ | | |-- ApiSecurityMinimal.slnx diff --git a/blazor/forms-validation-masterclass/BlazorProfileValidation.slnx b/blazor/forms-validation-masterclass/BlazorProfileValidation.slnx new file mode 100644 index 0000000..1ba075b --- /dev/null +++ b/blazor/forms-validation-masterclass/BlazorProfileValidation.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/blazor/forms-validation-masterclass/README.md b/blazor/forms-validation-masterclass/README.md new file mode 100644 index 0000000..46a1c41 --- /dev/null +++ b/blazor/forms-validation-masterclass/README.md @@ -0,0 +1,216 @@ +# Blazor Profile Validation Pipeline — Minimal Sample + +A focused .NET 10 Interactive Server companion demonstrating manual +`EditContext` ownership, DataAnnotations, FluentValidation, backend field-error +mapping, accessible reusable inputs, dirty-state tracking, save/discard behavior, +and bUnit component tests. + +## Full tutorial + +[Blazor .NET 8 Forms & Validation: EditForm, FluentValidation & Server Error Handling](https://www.dotnet-guide.com/tutorials/blazor/forms-validation-masterclass/) + +## Framework note + +The tutorial explains Blazor forms on .NET 8. + +This companion targets .NET 10 so it can use the DOTNET GUIDE repository's +current SDK and CI toolchain. The `EditContext`, `ValidationMessageStore`, +DataAnnotations, FluentValidation, field-error mapping, dirty-state, +reusable-input, and component-testing patterns demonstrated here are the same +core Blazor concepts. + +## What this sample demonstrates + +- a Blazor Web App using Interactive Server; +- a manually created `EditContext`; +- DataAnnotations validation; +- FluentValidation 12 rules; +- a local FluentValidation-to-EditContext bridge; +- whole-model validation on submit; +- property-specific validation on field change; +- backend-returned field errors; +- `ValidationMessageStore`; +- clearing stale backend errors after field edits; +- reusable accessible text inputs; +- `aria-invalid` and `aria-describedby`; +- dirty-state detection with `IsModified()`; +- save and discard behavior; +- `MarkAsUnmodified()` after successful save; +- eight bUnit tests; +- one ASP.NET Core unknown-route integration test. + +## Why this sample does not use Blazored.FluentValidation + +FluentValidation doesn't provide first-party Blazor integration. + +The formerly common `Blazored.FluentValidation` adapter is archived. + +This sample keeps the integration visible by using a small local component based +on: + +- `EditContext.OnValidationRequested`; +- `EditContext.OnFieldChanged`; +- `ValidationMessageStore`; +- `IValidator`; +- `IncludeProperties`. + +The bridge supports synchronous FluentValidation rules only. + +## Validation sources + +```text +DataAnnotations + required, length, format + +FluentValidation + conditional and cross-field rules + +ProfileService + backend-only reserved username and blocked email-domain rules +``` + +Each source owns a separate validation-message store. + +## Backend boundary + +`ProfileService` is an in-process backend simulation. + +It returns a dictionary of field names and messages so the component can +demonstrate backend-error mapping. + +It does not make HTTP requests or deserialize RFC 7807 responses. + +## Render-mode boundary + +The sample uses Interactive Server. + +The browser must maintain an active Blazor circuit. + +## State boundary + +The profile service is scoped and stores data only in process memory. + +Restarting the application resets the saved profile. + +## Prerequisite + +- .NET 10 SDK +- a modern browser for optional manual interaction checks + +## Restore, build, and test + +```powershell +dotnet restore ` + .\BlazorProfileValidation.slnx + +dotnet build ` + .\BlazorProfileValidation.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\BlazorProfileValidation.slnx ` + --configuration Release ` + --no-build +``` + +## Run + +```powershell +dotnet run ` + --project .\src\BlazorProfileValidation\BlazorProfileValidation.csproj ` + --urls http://localhost:5144 +``` + +Open: + +```text +http://localhost:5144/ +``` + +## Demonstration backend errors + +Use these valid client-side values to trigger backend-only errors: + +```text +Username: reserved +Email: any-address@blocked.example +``` + +## Testing boundary + +The test suite contains: + +- eight bUnit component tests; +- one ASP.NET Core integration test for direct unknown-route handling. + +The local FluentValidation bridge supports explicitly declared cross-field +dependencies (Username changes revalidate DisplayName). Dependencies are +declared in the page component, not inferred automatically. + +The tests do not launch a graphical browser or establish a real browser-driven +SignalR session. + +## Project structure + +```text +BlazorProfileValidation.slnx +README.md +src/ +└── BlazorProfileValidation/ + ├── BlazorProfileValidation.csproj + ├── Program.cs + ├── Models/ + │ └── ProfileModel.cs + ├── Services/ + │ └── ProfileService.cs + ├── Validation/ + │ └── ProfileValidator.cs + └── Components/ + ├── _Imports.razor + ├── App.razor + ├── Routes.razor + ├── Pages/ + │ ├── NotFound.razor + │ └── ProfileSettings.razor + ├── Shared/ + │ └── FormTextField.razor + └── Validation/ + └── FluentValidationBridge.razor +tests/ +└── BlazorProfileValidation.Tests/ + ├── BlazorProfileValidation.Tests.csproj + └── ProfileSettingsTests.cs +``` + +## Deliberately omitted + +- third-party Blazor validation adapters; +- async validation; +- username API calls; +- debounce; +- JavaScript focus management; +- navigation guards; +- optimistic UI; +- authentication; +- databases; +- WebAssembly; +- browser automation; +- Docker; +- production persistence. + +These topics remain in the complete tutorial. + +## Verification + +- Companion target framework: .NET 10 +- Tutorial framework: .NET 8 +- Render mode: Interactive Server +- FluentValidation integration: local bridge +- External services required: none +- Database required: none +- API keys required: none +- Expected tests: 9 +- Last reviewed: 2026-08-02 + +This sample is educational and should be reviewed before production use. \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/BlazorProfileValidation.csproj b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/BlazorProfileValidation.csproj new file mode 100644 index 0000000..31f4c94 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/BlazorProfileValidation.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/App.razor b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/App.razor new file mode 100644 index 0000000..ec49565 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/App.razor @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Pages/NotFound.razor b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Pages/NotFound.razor new file mode 100644 index 0000000..78f0249 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Pages/NotFound.razor @@ -0,0 +1,15 @@ +@page "/not-found" + +Not Found + +
+

Page not found

+ +

+ The requested page does not exist. +

+ + + Return to Profile Settings + +
\ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Pages/ProfileSettings.razor b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Pages/ProfileSettings.razor new file mode 100644 index 0000000..1f81974 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Pages/ProfileSettings.razor @@ -0,0 +1,274 @@ +@page "/" +@implements IDisposable +@inject ProfileService ProfileService + +Profile Settings + +
+

Profile Settings

+ +

+ A focused form-validation sample combining + DataAnnotations, FluentValidation, backend field + errors, accessible inputs, and dirty-state tracking. +

+ + + + + + +
+ +
+ + + + + + + +
+ + + +
+ + @if (_isDirty) + { +
+ + You have unsaved changes. + + + + + +
+ } + + @if (!string.IsNullOrWhiteSpace( + _statusMessage)) + { +

+ @_statusMessage +

+ } +
+
+ +@code { + private ProfileModel _model = + new(); + + private ProfileModel _savedModel = + new(); + + private EditContext _editContext = + default!; + + private ValidationMessageStore + _backendMessages = + default!; + + private bool _isDirty; + private bool _isSaving; + private string? _statusMessage; + + private static readonly IReadOnlyDictionary< + string, string[]> ValidationDependencies = + new Dictionary( + StringComparer.Ordinal) + { + [nameof(ProfileModel.Username)] = + [ + nameof(ProfileModel.DisplayName) + ] + }; + + protected override void OnInitialized() + { + _model = + ProfileService.Load(); + + _savedModel = + _model.Copy(); + + CreateEditContext(); + } + + private void CreateEditContext() + { + if (_editContext is not null) + { + _editContext.OnFieldChanged -= + HandleFieldChanged; + } + + _editContext = + new EditContext( + _model); + + _backendMessages = + new ValidationMessageStore( + _editContext); + + _editContext.OnFieldChanged += + HandleFieldChanged; + } + + private void HandleFieldChanged( + object? sender, + FieldChangedEventArgs args) + { + _backendMessages.Clear( + args.FieldIdentifier); + + _editContext + .NotifyValidationStateChanged(); + + _isDirty = + _editContext.IsModified(); + + _statusMessage = + null; + + _ = InvokeAsync( + StateHasChanged); + } + + private async Task SaveAsync() + { + _isSaving = + true; + + _statusMessage = + null; + + _backendMessages.Clear(); + + _editContext + .NotifyValidationStateChanged(); + + try + { + ProfileSaveResult result = + await ProfileService + .SaveAsync( + _model, + CancellationToken.None); + + if (!result.Succeeded) + { + MapBackendErrors( + result.Errors); + + _statusMessage = + "The profile service rejected one or more fields."; + + return; + } + + _savedModel = + _model.Copy(); + + _editContext + .MarkAsUnmodified(); + + _isDirty = + false; + + _statusMessage = + "Profile saved successfully."; + } + finally + { + _isSaving = + false; + } + } + + private void MapBackendErrors( + IReadOnlyDictionary< + string, + string[]> errors) + { + foreach (( + string fieldName, + string[] messages) + in errors) + { + FieldIdentifier field = + _editContext.Field( + fieldName); + + _backendMessages.Add( + field, + messages); + } + + _editContext + .NotifyValidationStateChanged(); + } + + private void DiscardChanges() + { + _model = + _savedModel.Copy(); + + CreateEditContext(); + + _isDirty = + false; + + _statusMessage = + "Changes discarded."; + } + + public void Dispose() + { + if (_editContext is not null) + { + _editContext.OnFieldChanged -= + HandleFieldChanged; + } + } +} \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Routes.razor b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Routes.razor new file mode 100644 index 0000000..724d5b2 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Routes.razor @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Shared/FormTextField.razor b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Shared/FormTextField.razor new file mode 100644 index 0000000..3a9075d --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Shared/FormTextField.razor @@ -0,0 +1,97 @@ +@inherits InputText + +
+ + + + + @if (HasErrors) + { + + } + else if (!string.IsNullOrWhiteSpace( + HelpText)) + { +
+ @HelpText +
+ } +
+ +@code { + [Parameter] + [EditorRequired] + public string Label + { + get; + set; + } = string.Empty; + + [Parameter] + [EditorRequired] + public string InputId + { + get; + set; + } = string.Empty; + + [Parameter] + public string Type + { + get; + set; + } = "text"; + + [Parameter] + public string? HelpText + { + get; + set; + } + + private string ErrorId => + $"{InputId}-error"; + + private string HelpId => + $"{InputId}-help"; + + private bool HasErrors => + EditContext + .GetValidationMessages( + FieldIdentifier) + .Any(); + + private string? AriaInvalid => + HasErrors + ? "true" + : null; + + private string? DescriptionId => + HasErrors + ? ErrorId + : string.IsNullOrWhiteSpace( + HelpText) + ? null + : HelpId; + + private Expression> + FieldExpression => + ValueExpression + ?? throw new InvalidOperationException( + "FormTextField requires @bind-Value."); +} \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Validation/FluentValidationBridge.razor b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Validation/FluentValidationBridge.razor new file mode 100644 index 0000000..cf0bd32 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/Validation/FluentValidationBridge.razor @@ -0,0 +1,186 @@ +@typeparam TModel where TModel : class +@implements IDisposable +@inject IValidator Validator + +@code { + [CascadingParameter] + private EditContext? CurrentEditContext + { + get; + set; + } + + [Parameter] + public IReadOnlyDictionary< + string, + string[]> DependentProperties + { + get; + set; + } = new Dictionary< + string, + string[]>(); + + private EditContext? _subscribedContext; + private ValidationMessageStore? _messages; + + protected override void OnParametersSet() + { + if (CurrentEditContext is null) + { + throw new InvalidOperationException( + $"{nameof(FluentValidationBridge)} requires a cascading EditContext."); + } + + if (ReferenceEquals( + CurrentEditContext, + _subscribedContext)) + { + return; + } + + Unsubscribe(); + + _subscribedContext = + CurrentEditContext; + + _messages = + new ValidationMessageStore( + CurrentEditContext); + + CurrentEditContext + .OnValidationRequested += + HandleValidationRequested; + + CurrentEditContext + .OnFieldChanged += + HandleFieldChanged; + } + + private void HandleValidationRequested( + object? sender, + ValidationRequestedEventArgs args) + { + if (_subscribedContext?.Model + is not TModel model + || _messages is null) + { + return; + } + + _messages.Clear(); + + ValidationResult result = + Validator.Validate( + model); + + foreach (ValidationFailure failure + in result.Errors) + { + FieldIdentifier field = + _subscribedContext.Field( + failure.PropertyName); + + _messages.Add( + field, + failure.ErrorMessage); + } + + _subscribedContext + .NotifyValidationStateChanged(); + } + + private void HandleFieldChanged( + object? sender, + FieldChangedEventArgs args) + { + if (_subscribedContext?.Model + is not TModel model + || _messages is null) + { + return; + } + + string changedField = + args.FieldIdentifier + .FieldName; + + HashSet fieldsToValidate = + new( + StringComparer.Ordinal) + { + changedField + }; + + if (DependentProperties + .TryGetValue( + changedField, + out string[]? dependents)) + { + foreach (string dependent + in dependents) + { + fieldsToValidate.Add( + dependent); + } + } + + foreach (string field + in fieldsToValidate) + { + _messages.Clear( + _subscribedContext.Field( + field)); + } + + ValidationResult result = + Validator.Validate( + model, + options => + options.IncludeProperties( + fieldsToValidate + .ToArray())); + + foreach (ValidationFailure failure + in result.Errors) + { + FieldIdentifier field = + _subscribedContext.Field( + failure.PropertyName); + + _messages.Add( + field, + failure.ErrorMessage); + } + + _subscribedContext + .NotifyValidationStateChanged(); + } + + private void Unsubscribe() + { + if (_subscribedContext is null) + { + return; + } + + _subscribedContext + .OnValidationRequested -= + HandleValidationRequested; + + _subscribedContext + .OnFieldChanged -= + HandleFieldChanged; + + _messages?.Clear(); + + _messages = + null; + + _subscribedContext = + null; + } + + public void Dispose() => + Unsubscribe(); +} \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/_Imports.razor b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/_Imports.razor new file mode 100644 index 0000000..0fe4ff6 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Components/_Imports.razor @@ -0,0 +1,15 @@ +@using System.Linq.Expressions +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using FluentValidation +@using FluentValidation.Results +@using BlazorProfileValidation +@using BlazorProfileValidation.Models +@using BlazorProfileValidation.Services +@using BlazorProfileValidation.Validation +@using BlazorProfileValidation.Components +@using BlazorProfileValidation.Components.Shared +@using BlazorProfileValidation.Components.Validation \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Models/ProfileModel.cs b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Models/ProfileModel.cs new file mode 100644 index 0000000..3caf874 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Models/ProfileModel.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; + +namespace BlazorProfileValidation.Models; + +public sealed class ProfileModel +{ + [Required( + ErrorMessage = + "Username is required.")] + [StringLength( + 30, + MinimumLength = 3, + ErrorMessage = + "Username must contain between 3 and 30 characters.")] + [RegularExpression( + "^[a-z0-9_]+$", + ErrorMessage = + "Username can contain lowercase letters, numbers, and underscores only.")] + public string Username + { + get; + set; + } = string.Empty; + + [Required( + ErrorMessage = + "Email is required.")] + [EmailAddress( + ErrorMessage = + "Enter a valid email address.")] + public string Email + { + get; + set; + } = string.Empty; + + [StringLength( + 100, + ErrorMessage = + "Display name must contain 100 characters or fewer.")] + public string DisplayName + { + get; + set; + } = string.Empty; + + public bool EmailNotifications + { + get; + set; + } = true; + + public ProfileModel Copy() => + new() + { + Username = + Username, + + Email = + Email, + + DisplayName = + DisplayName, + + EmailNotifications = + EmailNotifications + }; +} \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Program.cs b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Program.cs new file mode 100644 index 0000000..faeed3e --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Program.cs @@ -0,0 +1,38 @@ +using BlazorProfileValidation.Components; +using BlazorProfileValidation.Services; +using BlazorProfileValidation.Validation; +using FluentValidation; + +var builder = + WebApplication.CreateBuilder(args); + +builder.Services + .AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddScoped< + ProfileService>(); + +builder.Services + .AddValidatorsFromAssemblyContaining< + ProfileValidator>( + ServiceLifetime.Transient); + +var app = + builder.Build(); + +app.UseStatusCodePagesWithReExecute( + "/not-found", + createScopeForStatusCodePages: true); + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); + +public partial class Program +{ +} \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Services/ProfileService.cs b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Services/ProfileService.cs new file mode 100644 index 0000000..eb5fb84 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Services/ProfileService.cs @@ -0,0 +1,114 @@ +using BlazorProfileValidation.Models; + +namespace BlazorProfileValidation.Services; + +public sealed class ProfileService +{ + private ProfileModel _savedProfile = + new() + { + Username = + "dotnet_reader", + + Email = + "reader@example.com", + + DisplayName = + "DOTNET Reader", + + EmailNotifications = + true + }; + + public int SaveAttempts + { + get; + private set; + } + + public ProfileModel LastSavedProfile => + _savedProfile.Copy(); + + public ProfileModel Load() => + _savedProfile.Copy(); + + public Task SaveAsync( + ProfileModel profile, + CancellationToken cancellationToken) + { + cancellationToken + .ThrowIfCancellationRequested(); + + SaveAttempts++; + + var errors = + new Dictionary< + string, + string[]>( + StringComparer.Ordinal); + + if (string.Equals( + profile.Username, + "reserved", + StringComparison.OrdinalIgnoreCase)) + { + errors[ + nameof( + ProfileModel.Username)] = + [ + "This username is reserved by the profile service." + ]; + } + + if (profile.Email.EndsWith( + "@blocked.example", + StringComparison.OrdinalIgnoreCase)) + { + errors[ + nameof( + ProfileModel.Email)] = + [ + "This email domain is blocked by the profile service." + ]; + } + + if (errors.Count > 0) + { + return Task.FromResult( + ProfileSaveResult + .Rejected( + errors)); + } + + _savedProfile = + profile.Copy(); + + return Task.FromResult( + ProfileSaveResult + .Accepted()); + } +} + +public sealed record ProfileSaveResult( + bool Succeeded, + IReadOnlyDictionary< + string, + string[]> Errors) +{ + public static ProfileSaveResult + Accepted() => + new( + true, + new Dictionary< + string, + string[]>()); + + public static ProfileSaveResult + Rejected( + IReadOnlyDictionary< + string, + string[]> errors) => + new( + false, + errors); +} \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Validation/ProfileValidator.cs b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Validation/ProfileValidator.cs new file mode 100644 index 0000000..552d710 --- /dev/null +++ b/blazor/forms-validation-masterclass/src/BlazorProfileValidation/Validation/ProfileValidator.cs @@ -0,0 +1,39 @@ +using BlazorProfileValidation.Models; +using FluentValidation; + +namespace BlazorProfileValidation.Validation; + +public sealed class ProfileValidator : + AbstractValidator +{ + public ProfileValidator() + { + RuleFor( + profile => + profile.DisplayName) + .MinimumLength(2) + .When( + profile => + !string.IsNullOrWhiteSpace( + profile.DisplayName)) + .WithMessage( + "Display name must contain at least two characters."); + + RuleFor( + profile => + profile.DisplayName) + .Must( + ( + profile, + displayName) => + string.IsNullOrWhiteSpace( + displayName) + || + !string.Equals( + displayName.Trim(), + profile.Username.Trim(), + StringComparison.OrdinalIgnoreCase)) + .WithMessage( + "Display name must differ from the username."); + } +} \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/tests/BlazorProfileValidation.Tests/BlazorProfileValidation.Tests.csproj b/blazor/forms-validation-masterclass/tests/BlazorProfileValidation.Tests/BlazorProfileValidation.Tests.csproj new file mode 100644 index 0000000..228b6d8 --- /dev/null +++ b/blazor/forms-validation-masterclass/tests/BlazorProfileValidation.Tests/BlazorProfileValidation.Tests.csproj @@ -0,0 +1,53 @@ + + + + net10.0 + enable + enable + false + true + Exe + + + + + + + + + + + + + all + + runtime; + build; + native; + contentfiles; + analyzers; + buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/blazor/forms-validation-masterclass/tests/BlazorProfileValidation.Tests/ProfileSettingsTests.cs b/blazor/forms-validation-masterclass/tests/BlazorProfileValidation.Tests/ProfileSettingsTests.cs new file mode 100644 index 0000000..11b26bf --- /dev/null +++ b/blazor/forms-validation-masterclass/tests/BlazorProfileValidation.Tests/ProfileSettingsTests.cs @@ -0,0 +1,449 @@ +using System.Net; +using BlazorProfileValidation.Components.Pages; +using BlazorProfileValidation.Services; +using BlazorProfileValidation.Validation; +using Bunit; +using FluentValidation; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace BlazorProfileValidation.Tests; + +public sealed class ProfileSettingsTests +{ + [Fact] + public void + Form_renders_saved_profile_without_dirty_state() + { + using BunitContext context = + CreateContext(); + + var cut = + context.Render< + ProfileSettings>(); + + Assert.Equal( + "dotnet_reader", + cut.Find( + "#profile-username") + .GetAttribute( + "value")); + + Assert.Equal( + "reader@example.com", + cut.Find( + "#profile-email") + .GetAttribute( + "value")); + + Assert.Empty( + cut.FindAll( + "[data-testid='save-bar']")); + } + + [Fact] + public void + DataAnnotations_error_prevents_backend_save() + { + using BunitContext context = + CreateContext(); + + ProfileService service = + context.Services + .GetRequiredService< + ProfileService>(); + + var cut = + context.Render< + ProfileSettings>(); + + cut.Find( + "#profile-username") + .Input( + "x"); + + cut.Find( + "form") + .Submit(); + + cut.WaitForAssertion( + () => + { + Assert.Contains( + "Username must contain between 3 and 30 characters.", + cut.Markup, + StringComparison.Ordinal); + + Assert.Equal( + 0, + service.SaveAttempts); + }); + } + + [Fact] + public void + FluentValidation_cross_field_error_prevents_save() + { + using BunitContext context = + CreateContext(); + + ProfileService service = + context.Services + .GetRequiredService< + ProfileService>(); + + var cut = + context.Render< + ProfileSettings>(); + + cut.Find( + "#profile-display-name") + .Input( + "dotnet_reader"); + + cut.Find( + "form") + .Submit(); + + cut.WaitForAssertion( + () => + { + Assert.Contains( + "Display name must differ from the username.", + cut.Markup, + StringComparison.Ordinal); + + Assert.Equal( + 0, + service.SaveAttempts); + }); + + cut.Find( + "#profile-username") + .Input( + "different_user"); + + cut.WaitForAssertion( + () => + { + Assert.DoesNotContain( + "Display name must differ from the username.", + cut.Markup, + StringComparison.Ordinal); + + Assert.Equal( + 0, + service.SaveAttempts); + }); + + cut.Find( + "#profile-username") + .Input( + "dotnet_reader"); + + cut.WaitForAssertion( + () => + { + Assert.Contains( + "Display name must differ from the username.", + cut.Markup, + StringComparison.Ordinal); + + Assert.Equal( + 0, + service.SaveAttempts); + }); + } + + [Fact] + public void + Backend_errors_map_to_their_fields() + { + using BunitContext context = + CreateContext(); + + ProfileService service = + context.Services + .GetRequiredService< + ProfileService>(); + + var cut = + context.Render< + ProfileSettings>(); + + cut.Find( + "#profile-username") + .Input( + "reserved"); + + cut.Find( + "#profile-email") + .Input( + "reader@blocked.example"); + + cut.Find( + "form") + .Submit(); + + cut.WaitForAssertion( + () => + { + Assert.Contains( + "This username is reserved by the profile service.", + cut.Markup, + StringComparison.Ordinal); + + Assert.Contains( + "This email domain is blocked by the profile service.", + cut.Markup, + StringComparison.Ordinal); + + Assert.Equal( + 1, + service.SaveAttempts); + }); + } + + [Fact] + public void + Editing_a_field_clears_only_its_backend_error() + { + using BunitContext context = + CreateContext(); + + var cut = + context.Render< + ProfileSettings>(); + + cut.Find( + "#profile-username") + .Input( + "reserved"); + + cut.Find( + "#profile-email") + .Input( + "reader@blocked.example"); + + cut.Find( + "form") + .Submit(); + + cut.WaitForAssertion( + () => + Assert.Contains( + "This username is reserved by the profile service.", + cut.Markup, + StringComparison.Ordinal)); + + cut.Find( + "#profile-username") + .Input( + "available_user"); + + cut.WaitForAssertion( + () => + { + Assert.DoesNotContain( + "This username is reserved by the profile service.", + cut.Markup, + StringComparison.Ordinal); + + Assert.Contains( + "This email domain is blocked by the profile service.", + cut.Markup, + StringComparison.Ordinal); + }); + } + + [Fact] + public void + Dirty_state_and_discard_restore_saved_values() + { + using BunitContext context = + CreateContext(); + + var cut = + context.Render< + ProfileSettings>(); + + cut.Find( + "#profile-display-name") + .Input( + "Changed display name"); + + cut.WaitForAssertion( + () => + Assert.Single( + cut.FindAll( + "[data-testid='save-bar']"))); + + cut.Find( + "[data-action='discard']") + .Click(); + + cut.WaitForAssertion( + () => + { + Assert.Equal( + "DOTNET Reader", + cut.Find( + "#profile-display-name") + .GetAttribute( + "value")); + + Assert.Empty( + cut.FindAll( + "[data-testid='save-bar']")); + }); + } + + [Fact] + public void + Successful_save_clears_dirty_state() + { + using BunitContext context = + CreateContext(); + + ProfileService service = + context.Services + .GetRequiredService< + ProfileService>(); + + var cut = + context.Render< + ProfileSettings>(); + + cut.Find( + "#profile-display-name") + .Input( + "Updated Reader"); + + cut.Find( + "[data-action='save']") + .Click(); + + cut.WaitForAssertion( + () => + { + Assert.Contains( + "Profile saved successfully.", + cut.Find( + "[data-testid='status-message']") + .TextContent, + StringComparison.Ordinal); + + Assert.Empty( + cut.FindAll( + "[data-testid='save-bar']")); + + Assert.Equal( + "Updated Reader", + service.LastSavedProfile + .DisplayName); + }); + } + + [Fact] + public void + Invalid_field_exposes_accessible_error_metadata() + { + using BunitContext context = + CreateContext(); + + var cut = + context.Render< + ProfileSettings>(); + + cut.Find( + "#profile-username") + .Input( + "x"); + + cut.Find( + "form") + .Submit(); + + cut.WaitForAssertion( + () => + { + var input = + cut.Find( + "#profile-username"); + + Assert.Equal( + "true", + input.GetAttribute( + "aria-invalid")); + + Assert.Equal( + "profile-username-error", + input.GetAttribute( + "aria-describedby")); + + Assert.Contains( + "Username must contain between 3 and 30 characters.", + cut.Find( + "#profile-username-error") + .TextContent, + StringComparison.Ordinal); + }); + } + + [Fact] + public async Task + Unknown_path_returns_custom_not_found_page() + { + using var factory = + new WebApplicationFactory< + Program>(); + + using HttpClient client = + factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = + false + }); + + CancellationToken ct = + Xunit.TestContext.Current + .CancellationToken; + + HttpResponseMessage response = + await client.GetAsync( + "/this-page-does-not-exist", + ct); + + string body = + await response.Content + .ReadAsStringAsync( + ct); + + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + + Assert.Contains( + "Page not found", + body, + StringComparison.Ordinal); + } + + private static BunitContext + CreateContext() + { + var context = + new BunitContext(); + + context.Services.AddScoped< + ProfileService>(); + + context.Services + .AddValidatorsFromAssemblyContaining< + ProfileValidator>( + ServiceLifetime.Transient); + + return context; + } +} \ No newline at end of file