From 569d32192b14d81870b229ed6c3c39917968d237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Mon, 17 Aug 2026 17:53:50 +0200 Subject: [PATCH 1/3] Add .NET 10/11 validation samples and align FieldCssClassProvider style Adds two sample projects that back code references in the Blazor forms validation articles, and aligns three existing provider samples with the style used across the validation docs. New: 10.0/BlazorWebAppRemoteValidation A three-project solution for the remote validation walkthrough: * BlazorSample: server project with the server form validator and the proxy endpoint. * BlazorSample.Client: model, validation abstraction, client form validator, CustomValidation component, and the Starship10 form. * MinimalApiJwt: the remote validation endpoint. New: 11.0/BlazorSample_BlazorWebApp Covers validation features introduced in .NET 11: * StartsWithAttribute: a custom validation attribute that also supplies a client-side rule for static SSR forms. * A JavaScript initializer that registers the matching client validator. * UsernameUniquenessValidator: asynchronous validation registered both for the whole form and per field. * AsyncValidation page: displays pending and faulted validation state. Changed: CustomFieldClassProvider and CustomFieldClassProvider2 (8.0, 9.0, 10.0) The three provider samples form a progression, so they now share one shape, which makes the difference between them the only thing that varies. Verification Both solutions build with no errors or warnings. The remote validation endpoint was exercised for a valid model, a business-rule failure, and data annotations failures. The .NET 11 static SSR form was checked to confirm the client validation metadata, including the custom rule, and that both the per-form and global opt-outs suppress it while server-side validation continues to run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 608096b5-db3e-4006-adaa-b4da0309ff87 --- .../CustomFieldClassProvider.cs | 10 +- .../CustomFieldClassProvider2.cs | 5 +- .../BlazorSample.Client.csproj | 15 +++ .../BlazorSample.Client/CustomValidation.cs | 50 ++++++++ .../Pages/Starship10.razor | 114 ++++++++++++++++++ .../BlazorSample.Client/Program.cs | 12 ++ .../Starship/ClientFormValidation.cs | 39 ++++++ .../Starship/IFormValidation.cs | 7 ++ .../Starship/StarshipModel.cs | 25 ++++ .../BlazorSample.Client/_Imports.razor | 9 ++ .../BlazorSample/BlazorSample.csproj | 17 +++ .../BlazorSample/Components/App.razor | 16 +++ .../Components/Layout/MainLayout.razor | 5 + .../BlazorSample/Components/Routes.razor | 7 ++ .../BlazorSample/Components/_Imports.razor | 9 ++ .../BlazorSample/Program.cs | 47 ++++++++ .../Starship/ServerFormValidation.cs | 75 ++++++++++++ .../BlazorSample/wwwroot/app.css | 1 + .../BlazorWebAppRemoteValidation.slnx | 5 + .../MinimalApiJwt/MinimalApiJwt.csproj | 13 ++ .../MinimalApiJwt/Program.cs | 45 +++++++ .../MinimalApiJwt/StarshipModel.cs | 25 ++++ .../BlazorSample.csproj | 9 ++ .../Components/App.razor | 12 ++ .../Components/Pages/AsyncValidation.razor | 45 +++++++ .../Components/Routes.razor | 5 + .../UsernameUniquenessValidator.razor | 71 +++++++++++ .../Components/_Imports.razor | 9 ++ 11.0/BlazorSample_BlazorWebApp/Program.cs | 17 +++ .../Validation/RegistrationModel.cs | 9 ++ .../Validation/ShipModel.cs | 12 ++ .../Validation/StartsWithAttribute.cs | 36 ++++++ .../wwwroot/BlazorSample.lib.module.js | 14 +++ .../CustomFieldClassProvider.cs | 10 +- .../CustomFieldClassProvider2.cs | 5 +- .../CustomFieldClassProvider.cs | 10 +- .../CustomFieldClassProvider2.cs | 5 +- 37 files changed, 805 insertions(+), 15 deletions(-) create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/BlazorSample.Client.csproj create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/CustomValidation.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Pages/Starship10.razor create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Program.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/ClientFormValidation.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/IFormValidation.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/StarshipModel.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/_Imports.razor create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/BlazorSample.csproj create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/App.razor create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Layout/MainLayout.razor create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Routes.razor create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/_Imports.razor create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/Program.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/Starship/ServerFormValidation.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorSample/wwwroot/app.css create mode 100644 10.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx create mode 100644 10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/MinimalApiJwt.csproj create mode 100644 10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/Program.cs create mode 100644 10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/StarshipModel.cs create mode 100644 11.0/BlazorSample_BlazorWebApp/BlazorSample.csproj create mode 100644 11.0/BlazorSample_BlazorWebApp/Components/App.razor create mode 100644 11.0/BlazorSample_BlazorWebApp/Components/Pages/AsyncValidation.razor create mode 100644 11.0/BlazorSample_BlazorWebApp/Components/Routes.razor create mode 100644 11.0/BlazorSample_BlazorWebApp/Components/UsernameUniquenessValidator.razor create mode 100644 11.0/BlazorSample_BlazorWebApp/Components/_Imports.razor create mode 100644 11.0/BlazorSample_BlazorWebApp/Program.cs create mode 100644 11.0/BlazorSample_BlazorWebApp/Validation/RegistrationModel.cs create mode 100644 11.0/BlazorSample_BlazorWebApp/Validation/ShipModel.cs create mode 100644 11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs create mode 100644 11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js diff --git a/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs b/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs index 4adab8609..158681e1f 100644 --- a/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs +++ b/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs @@ -4,7 +4,11 @@ namespace BlazorSample; public class CustomFieldClassProvider : FieldCssClassProvider { - public override string GetFieldCssClass( - EditContext editContext, in FieldIdentifier fieldIdentifier) => - editContext.IsValid(fieldIdentifier) ? "validField" : "invalidField"; + public override string GetFieldCssClass(EditContext editContext, + in FieldIdentifier fieldIdentifier) + { + var isValid = editContext.IsValid(fieldIdentifier); + + return isValid ? "validField" : "invalidField"; + } } diff --git a/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs b/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs index 290dc8d3f..e228f20a2 100644 --- a/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs +++ b/10.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs @@ -9,8 +9,9 @@ public override string GetFieldCssClass(EditContext editContext, { if (fieldIdentifier.FieldName == "Name") { - return editContext.IsValid(fieldIdentifier) ? - "validField" : "invalidField"; + var isValid = editContext.IsValid(fieldIdentifier); + + return isValid ? "validField" : "invalidField"; } return string.Empty; diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/BlazorSample.Client.csproj b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/BlazorSample.Client.csproj new file mode 100644 index 000000000..f3ce47fb2 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/BlazorSample.Client.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/CustomValidation.cs b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/CustomValidation.cs new file mode 100644 index 000000000..b19fefacb --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/CustomValidation.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; + +namespace BlazorSample.Client; + +public class CustomValidation : ComponentBase +{ + private ValidationMessageStore? messageStore; + + [CascadingParameter] + private EditContext? CurrentEditContext { get; set; } + + protected override void OnInitialized() + { + if (CurrentEditContext is null) + { + throw new InvalidOperationException( + $"{nameof(CustomValidation)} requires a cascading " + + $"parameter of type {nameof(EditContext)}. " + + $"For example, you can use {nameof(CustomValidation)} " + + $"inside an {nameof(EditForm)}."); + } + + messageStore = new(CurrentEditContext); + + CurrentEditContext.OnValidationRequested += (s, e) => + messageStore?.Clear(); + CurrentEditContext.OnFieldChanged += (s, e) => + messageStore?.Clear(e.FieldIdentifier); + } + + public void DisplayErrors(IDictionary errors) + { + if (CurrentEditContext is not null) + { + foreach (var err in errors) + { + messageStore?.Add(CurrentEditContext.Field(err.Key), err.Value); + } + + CurrentEditContext.NotifyValidationStateChanged(); + } + } + + public void ClearErrors() + { + messageStore?.Clear(); + CurrentEditContext?.NotifyValidationStateChanged(); + } +} diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Pages/Starship10.razor b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Pages/Starship10.razor new file mode 100644 index 000000000..b598ca568 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Pages/Starship10.razor @@ -0,0 +1,114 @@ +@page "/starship-10" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@using BlazorSample.Client.Starship +@attribute [Authorize] +@inject IFormValidation FormValidation +@inject ILogger Logger + +

Starfleet Starship Database

+ +

New Ship Entry Form

+ + + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ @message +
+
+ +@code { + private CustomValidation? customValidation; + private bool disabled; + private string? message; + private string messageStyles = "visibility:hidden"; + + [SupplyParameterFromForm] + private StarshipModel? Model { get; set; } + + protected override void OnInitialized() => + Model ??= new() { ProductionDate = DateTime.UtcNow }; + + private async Task Submit(EditContext editContext) + { + customValidation?.ClearErrors(); + + try + { + var validationProblemDetails = + await FormValidation.ValidateStarshipFormAsync( + (StarshipModel)editContext.Model); + + if (validationProblemDetails?.Count > 0) + { + customValidation?.DisplayErrors(validationProblemDetails); + } + else + { + disabled = true; + messageStyles = "color:green"; + message = "The form has been processed."; + } + } + catch (AccessTokenNotAvailableException ex) + { + ex.Redirect(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Form processing error."); + disabled = true; + messageStyles = "color:red"; + message = "There was an error processing the form."; + } + } +} diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Program.cs b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Program.cs new file mode 100644 index 000000000..5ea5444e2 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Program.cs @@ -0,0 +1,12 @@ +using BlazorSample.Client.Starship; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +// The .Client project registers an HttpClient for HTTP POST requests to the Minimal API. +builder.Services.AddHttpClient(httpClient => +{ + httpClient.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); +}); + +await builder.Build().RunAsync(); diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/ClientFormValidation.cs b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/ClientFormValidation.cs new file mode 100644 index 000000000..11d5a98dd --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/ClientFormValidation.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Json; + +namespace BlazorSample.Client.Starship; + +internal sealed class ClientFormValidation(HttpClient httpClient) : IFormValidation +{ + public async Task> ValidateStarshipFormAsync( + StarshipModel starship) + { + Dictionary genericError = new() + { + { + "Validation Error", + ["An unexpected client error occurred during validation."] + } + }; + + try + { + using var response = await httpClient.PostAsJsonAsync( + "/starship-validation", starship); + + if (response.IsSuccessStatusCode) + { + var deserializedResponseContent = + await response.Content.ReadFromJsonAsync + >(); + + return deserializedResponseContent ?? genericError; + } + } + catch (Exception) + { + // Log the exception + } + + return genericError; + } +} diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/IFormValidation.cs b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/IFormValidation.cs new file mode 100644 index 000000000..1d550e3eb --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/IFormValidation.cs @@ -0,0 +1,7 @@ +namespace BlazorSample.Client.Starship; + +public interface IFormValidation +{ + Task> ValidateStarshipFormAsync( + StarshipModel starship); +} diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/StarshipModel.cs b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/StarshipModel.cs new file mode 100644 index 000000000..abfc1ba1b --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/Starship/StarshipModel.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace BlazorSample.Client.Starship; + +public class StarshipModel +{ + [Required] + [StringLength(16, ErrorMessage = "Identifier too long (16 character limit).")] + public string? Id { get; set; } + + public string? Description { get; set; } + + [Required] + public string? Classification { get; set; } + + [Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000).")] + public int MaximumAccommodation { get; set; } + + [Required] + [Range(typeof(bool), "true", "true", ErrorMessage = "Approval required.")] + public bool IsValidatedDesign { get; set; } + + [Required] + public DateTime ProductionDate { get; set; } +} diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/_Imports.razor b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/_Imports.razor new file mode 100644 index 000000000..0bbc1c9d8 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample.Client/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using BlazorSample.Client +@using BlazorSample.Client.Starship diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/BlazorSample.csproj b/10.0/BlazorWebAppRemoteValidation/BlazorSample/BlazorSample.csproj new file mode 100644 index 000000000..73173d45c --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/BlazorSample.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/App.razor b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/App.razor new file mode 100644 index 000000000..16c215390 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/App.razor @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Layout/MainLayout.razor b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Layout/MainLayout.razor new file mode 100644 index 000000000..724fc91b6 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Layout/MainLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + +
+ @Body +
diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Routes.razor b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Routes.razor new file mode 100644 index 000000000..bafa183bb --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/Routes.razor @@ -0,0 +1,7 @@ + + + + + + diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/_Imports.razor b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/_Imports.razor new file mode 100644 index 000000000..efcdfbd21 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Components/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using BlazorSample +@using BlazorSample.Components +@using BlazorSample.Components.Layout diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/Program.cs b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Program.cs new file mode 100644 index 000000000..8d11d8aa3 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Program.cs @@ -0,0 +1,47 @@ +using BlazorSample.Client.Starship; +using BlazorSample.Components; +using BlazorSample.Starship; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddHttpClient(); +builder.Services.AddHttpContextAccessor(); + +// Register the server form validator for the IFormValidation interface. The server form +// validator is used on the server to call ValidateStarshipFormAsync for form validation. +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); + +app.MapStaticAssets(); + +// Server-side endpoint that proxies form validation for components rendered on the server. +app.MapPost("/starship-validation", (IFormValidation formValidator, + StarshipModel model) => +{ + return formValidator.ValidateStarshipFormAsync(model); +}).RequireAuthorization(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(BlazorSample.Client._Imports).Assembly); + +app.Run(); diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/Starship/ServerFormValidation.cs b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Starship/ServerFormValidation.cs new file mode 100644 index 000000000..de4d13e99 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/Starship/ServerFormValidation.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using BlazorSample.Client.Starship; + +namespace BlazorSample.Starship; + +internal sealed class ServerFormValidation( + IHttpContextAccessor httpContextAccessor, IHttpClientFactory httpClientFactory) + : IFormValidation +{ + public async Task> ValidateStarshipFormAsync( + StarshipModel starship) + { + Dictionary genericError = new() + { + { + "Validation Error", + ["An unexpected server error occurred during validation."] + } + }; + + try + { + if (httpContextAccessor.HttpContext is null) + { + throw new Exception("HttpContext not available"); + } + + var request = new HttpRequestMessage(HttpMethod.Post, + "https://localhost:7277/api-starship-validation") + { + Content = new StringContent(JsonSerializer.Serialize(starship), + System.Text.Encoding.UTF8, "application/json") + }; + + var accessToken = + await httpContextAccessor.HttpContext.GetTokenAsync("access_token"); + + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + using var httpClient = httpClientFactory.CreateClient(); + + var response = await httpClient.SendAsync(request); + + if (response?.StatusCode == HttpStatusCode.NoContent) + { + return new Dictionary(); + } + + if (response?.StatusCode == HttpStatusCode.BadRequest) + { + var content = await response.Content.ReadAsStringAsync(); + + var deserialized = + JsonSerializer.Deserialize( + content, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + return deserialized?.Errors ?? genericError; + } + + return genericError; + } + catch (Exception) + { + // Log the exception + } + + return genericError; + } +} diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorSample/wwwroot/app.css b/10.0/BlazorWebAppRemoteValidation/BlazorSample/wwwroot/app.css new file mode 100644 index 000000000..1542a3a75 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorSample/wwwroot/app.css @@ -0,0 +1 @@ +main { font-family: sans-serif; } diff --git a/10.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx b/10.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx new file mode 100644 index 000000000..f93270d21 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/MinimalApiJwt.csproj b/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/MinimalApiJwt.csproj new file mode 100644 index 000000000..04a85c4b9 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/MinimalApiJwt.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/Program.cs b/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/Program.cs new file mode 100644 index 000000000..d2e9e602d --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/Program.cs @@ -0,0 +1,45 @@ +using MinimalApiJwt.Models; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication().AddJwtBearer(); +builder.Services.AddAuthorization(); + +// Enables Microsoft.Extensions.Validation so data annotations on StarshipModel are +// validated before the endpoint handler runs. +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Remote validation endpoint. Data annotations on StarshipModel are validated by the +// framework. This handler adds the business rule that a 'Defense' ship requires a +// description, which is only validated on the server. +app.MapPost("/api-starship-validation", ( + StarshipModel model, ILogger logger) => +{ + Dictionary errors = []; + + if (model.Classification == "Defense" && string.IsNullOrEmpty(model.Description)) + { + errors.Add(nameof(model.Description), + ["For a 'Defense' ship, 'Description' is required."]); + } + + if (errors.Count > 0) + { + return Results.ValidationProblem( + errors: errors, + detail: "One or more validation errors occurred.", + instance: typeof(Program).Assembly.GetName().Name, + title: "Validation Errors", + type: "https://tools.ietf.org/html/rfc9110#section-15.5.1"); + } + + return Results.NoContent(); + +}).RequireAuthorization(); + +app.Run(); diff --git a/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/StarshipModel.cs b/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/StarshipModel.cs new file mode 100644 index 000000000..673a99ee8 --- /dev/null +++ b/10.0/BlazorWebAppRemoteValidation/MinimalApiJwt/StarshipModel.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MinimalApiJwt.Models; + +public class StarshipModel +{ + [Required] + [StringLength(16, ErrorMessage = "Identifier too long (16 character limit).")] + public string? Id { get; set; } + + public string? Description { get; set; } + + [Required] + public string? Classification { get; set; } + + [Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000).")] + public int MaximumAccommodation { get; set; } + + [Required] + [Range(typeof(bool), "true", "true", ErrorMessage = "Approval required.")] + public bool IsValidatedDesign { get; set; } + + [Required] + public DateTime ProductionDate { get; set; } +} diff --git a/11.0/BlazorSample_BlazorWebApp/BlazorSample.csproj b/11.0/BlazorSample_BlazorWebApp/BlazorSample.csproj new file mode 100644 index 000000000..5086cb484 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/BlazorSample.csproj @@ -0,0 +1,9 @@ + + + + net11.0 + enable + enable + + + diff --git a/11.0/BlazorSample_BlazorWebApp/Components/App.razor b/11.0/BlazorSample_BlazorWebApp/Components/App.razor new file mode 100644 index 000000000..3b7bb3e1a --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/App.razor @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/11.0/BlazorSample_BlazorWebApp/Components/Pages/AsyncValidation.razor b/11.0/BlazorSample_BlazorWebApp/Components/Pages/AsyncValidation.razor new file mode 100644 index 000000000..8cea861fc --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/Pages/AsyncValidation.razor @@ -0,0 +1,45 @@ +@page "/async-validation" +@rendermode InteractiveServer + +@* Displays pending and faulted validation state for a field that's validated + asynchronously. *@ + +

Register

+ + + + +
+ + + + @if (editContext!.IsValidationPending(() => Model.Username)) + { + Checking… + } + else if (editContext.IsValidationFaulted(() => Model.Username)) + { + + Validation could not be completed. + + } +
+ + @* Disabled while any field in the form has validation in flight. *@ + +
+ +@code { + private EditContext? editContext; + + private RegistrationModel Model { get; set; } = new(); + + protected override void OnInitialized() => editContext = new(Model); + + private Task RegisterAsync() => Task.CompletedTask; +} diff --git a/11.0/BlazorSample_BlazorWebApp/Components/Routes.razor b/11.0/BlazorSample_BlazorWebApp/Components/Routes.razor new file mode 100644 index 000000000..06570ce63 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/Routes.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/11.0/BlazorSample_BlazorWebApp/Components/UsernameUniquenessValidator.razor b/11.0/BlazorSample_BlazorWebApp/Components/UsernameUniquenessValidator.razor new file mode 100644 index 000000000..8474d855f --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/UsernameUniquenessValidator.razor @@ -0,0 +1,71 @@ +@implements IDisposable +@inject HttpClient Http + +@* A validator component that runs asynchronous validation, both when the whole form is + validated on submit and per field as the user edits the Username field. *@ + +@code { + [CascadingParameter] + private EditContext? CurrentEditContext { get; set; } + + [Parameter, EditorRequired] + public RegistrationModel Model { get; set; } = default!; + + private ValidationMessageStore? _messages; + + protected override void OnInitialized() + { + ArgumentNullException.ThrowIfNull(CurrentEditContext); + _messages = new ValidationMessageStore(CurrentEditContext); + CurrentEditContext.OnValidationRequested += OnValidationRequested; + CurrentEditContext.OnFieldChanged += OnFieldChanged; + } + + // Registers asynchronous work for the whole form. EditForm awaits it before invoking + // OnValidSubmit. + private void OnValidationRequested( + object? sender, ValidationRequestedEventArgs e) => + e.AddAsyncValidator(ValidateUsernameAsync); + + // Registers asynchronous work for a single field. A new registration supersedes and + // cancels any validation already in flight for the field. + private void OnFieldChanged(object? sender, FieldChangedEventArgs e) + { + if (e.FieldIdentifier.FieldName != nameof(RegistrationModel.Username)) + { + return; + } + + CurrentEditContext!.RegisterAsyncFieldValidator( + e.FieldIdentifier, + token => CheckAsync(e.FieldIdentifier, token)); + } + + private Task ValidateUsernameAsync(CancellationToken token) => + CheckAsync(CurrentEditContext!.Field(nameof(Model.Username)), token); + + private async Task CheckAsync(FieldIdentifier field, CancellationToken token) + { + _messages!.Clear(field); + + var available = await Http.GetFromJsonAsync( + $"api/usernames/available?value={Uri.EscapeDataString(Model.Username)}", + token); + + if (!available) + { + _messages.Add(field, "The username is already taken."); + } + + CurrentEditContext!.NotifyValidationStateChanged(); + } + + public void Dispose() + { + if (CurrentEditContext is not null) + { + CurrentEditContext.OnValidationRequested -= OnValidationRequested; + CurrentEditContext.OnFieldChanged -= OnFieldChanged; + } + } +} diff --git a/11.0/BlazorSample_BlazorWebApp/Components/_Imports.razor b/11.0/BlazorSample_BlazorWebApp/Components/_Imports.razor new file mode 100644 index 000000000..1de0bf6a0 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using BlazorSample +@using BlazorSample.Components +@using BlazorSample.Validation diff --git a/11.0/BlazorSample_BlazorWebApp/Program.cs b/11.0/BlazorSample_BlazorWebApp/Program.cs new file mode 100644 index 000000000..0c28de61e --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Program.cs @@ -0,0 +1,17 @@ +using BlazorSample.Components; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddHttpClient(); +builder.Services.AddScoped(sp => + new HttpClient { BaseAddress = new Uri("https://localhost/") }); + +var app = builder.Build(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/11.0/BlazorSample_BlazorWebApp/Validation/RegistrationModel.cs b/11.0/BlazorSample_BlazorWebApp/Validation/RegistrationModel.cs new file mode 100644 index 000000000..f35d5a155 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Validation/RegistrationModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace BlazorSample.Validation; + +public class RegistrationModel +{ + [Required] + public string Username { get; set; } = string.Empty; +} diff --git a/11.0/BlazorSample_BlazorWebApp/Validation/ShipModel.cs b/11.0/BlazorSample_BlazorWebApp/Validation/ShipModel.cs new file mode 100644 index 000000000..d3f3fd853 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Validation/ShipModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace BlazorSample.Validation; + +// Model for the static SSR client-side validation sample. The StartsWith attribute +// contributes a client-side rule enforced in the browser before the form is submitted. +public class ShipModel +{ + [Required] + [StartsWith("NCC-")] + public string? Registry { get; set; } +} diff --git a/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs b/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs new file mode 100644 index 000000000..ef04f5b2f --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Components.Forms; + +namespace BlazorSample.Validation; + +// A custom validation attribute that also contributes a client-side rule for static SSR +// forms. The rule name ('startswith') must match the validator registered on the client +// with Blazor.formValidation.addValidator. +public sealed class StartsWithAttribute : ValidationAttribute, IClientValidationRuleProvider +{ + private readonly string prefix; + + public StartsWithAttribute(string prefix) + { + this.prefix = prefix; + ErrorMessage = $"The value must start with '{prefix}'."; + } + + protected override ValidationResult? IsValid(object? value, + ValidationContext validationContext) + { + if (value is string text && !text.StartsWith(prefix, StringComparison.Ordinal)) + { + return new ValidationResult(ErrorMessage, [validationContext.MemberName!]); + } + + return ValidationResult.Success; + } + + public IEnumerable GetClientValidationRules() + { + yield return new ClientValidationRule( + "startswith", + new Dictionary { ["prefix"] = prefix }); + } +} diff --git a/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js b/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js new file mode 100644 index 000000000..c1a7d8efc --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js @@ -0,0 +1,14 @@ +// JavaScript initializer for the app. The Blazor.formValidation service is created while +// Blazor starts, so custom client-side validators are registered from afterWebStarted. +export function afterWebStarted(blazor) { + blazor.formValidation.addValidator('startswith', (context) => { + const value = context.value; + + // An empty value is valid. Use [Required] to require a value. + if (!value) { + return { success: true }; + } + + return { success: value.startsWith(context.params.prefix) }; + }); +} diff --git a/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs b/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs index 4adab8609..158681e1f 100644 --- a/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs +++ b/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs @@ -4,7 +4,11 @@ namespace BlazorSample; public class CustomFieldClassProvider : FieldCssClassProvider { - public override string GetFieldCssClass( - EditContext editContext, in FieldIdentifier fieldIdentifier) => - editContext.IsValid(fieldIdentifier) ? "validField" : "invalidField"; + public override string GetFieldCssClass(EditContext editContext, + in FieldIdentifier fieldIdentifier) + { + var isValid = editContext.IsValid(fieldIdentifier); + + return isValid ? "validField" : "invalidField"; + } } diff --git a/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs b/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs index 290dc8d3f..e228f20a2 100644 --- a/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs +++ b/8.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs @@ -9,8 +9,9 @@ public override string GetFieldCssClass(EditContext editContext, { if (fieldIdentifier.FieldName == "Name") { - return editContext.IsValid(fieldIdentifier) ? - "validField" : "invalidField"; + var isValid = editContext.IsValid(fieldIdentifier); + + return isValid ? "validField" : "invalidField"; } return string.Empty; diff --git a/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs b/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs index 4adab8609..158681e1f 100644 --- a/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs +++ b/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider.cs @@ -4,7 +4,11 @@ namespace BlazorSample; public class CustomFieldClassProvider : FieldCssClassProvider { - public override string GetFieldCssClass( - EditContext editContext, in FieldIdentifier fieldIdentifier) => - editContext.IsValid(fieldIdentifier) ? "validField" : "invalidField"; + public override string GetFieldCssClass(EditContext editContext, + in FieldIdentifier fieldIdentifier) + { + var isValid = editContext.IsValid(fieldIdentifier); + + return isValid ? "validField" : "invalidField"; + } } diff --git a/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs b/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs index 290dc8d3f..e228f20a2 100644 --- a/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs +++ b/9.0/BlazorSample_BlazorWebApp/CustomFieldClassProvider2.cs @@ -9,8 +9,9 @@ public override string GetFieldCssClass(EditContext editContext, { if (fieldIdentifier.FieldName == "Name") { - return editContext.IsValid(fieldIdentifier) ? - "validField" : "invalidField"; + var isValid = editContext.IsValid(fieldIdentifier); + + return isValid ? "validField" : "invalidField"; } return string.Empty; From 28a5f979ce5175d7b7f7eca6f401a443e8760243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Wed, 16 Sep 2026 19:48:55 +0200 Subject: [PATCH 2/3] Rework Blazor and MEV validation docs --- .../Components/App.razor | 4 +- .../Components/Pages/ClientValidation.razor | 34 +++++++ 11.0/BlazorSample_BlazorWebApp/Program.cs | 1 + .../Properties/launchSettings.json | 23 +++++ .../Validation/StartsWithAttribute.cs | 8 +- .../wwwroot/BlazorSample.lib.module.js | 4 +- ...BlazorWebAppRemoteValidation.Client.csproj | 16 ++++ .../CustomValidation.razor | 53 +++++++++++ .../Layout/MainLayout.razor | 9 ++ .../Layout/MainLayout.razor.css | 20 +++++ .../Models/StarshipModel.cs | 16 ++++ .../Pages/Home.razor | 88 +++++++++++++++++++ .../Pages/NotFound.razor | 5 ++ .../Program.cs | 10 +++ .../Routes.razor | 6 ++ .../Validation/ClientValidationExtensions.cs | 10 +++ .../_Imports.razor | 10 +++ .../BlazorWebAppRemoteValidation.slnx | 4 + .../BlazorWebAppRemoteValidation.csproj | 15 ++++ .../Components/App.razor | 20 +++++ .../Components/Pages/Error.razor | 40 +++++++++ .../Components/_Imports.razor | 11 +++ .../BlazorWebAppRemoteValidation/Program.cs | 46 ++++++++++ .../Properties/launchSettings.json | 14 +++ .../appsettings.Development.json | 8 ++ .../appsettings.json | 9 ++ .../wwwroot/app.css | 38 ++++++++ 11.0/BlazorWebAppRemoteValidation/README.md | 20 +++++ 28 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 11.0/BlazorSample_BlazorWebApp/Components/Pages/ClientValidation.razor create mode 100644 11.0/BlazorSample_BlazorWebApp/Properties/launchSettings.json create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/BlazorWebAppRemoteValidation.Client.csproj create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/CustomValidation.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor.css create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Models/StarshipModel.cs create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/NotFound.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Routes.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Validation/ClientValidationExtensions.cs create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/_Imports.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.csproj create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/App.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/Pages/Error.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/_Imports.razor create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Properties/launchSettings.json create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.Development.json create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.json create mode 100644 11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/wwwroot/app.css create mode 100644 11.0/BlazorWebAppRemoteValidation/README.md diff --git a/11.0/BlazorSample_BlazorWebApp/Components/App.razor b/11.0/BlazorSample_BlazorWebApp/Components/App.razor index 3b7bb3e1a..3d5bc565e 100644 --- a/11.0/BlazorSample_BlazorWebApp/Components/App.razor +++ b/11.0/BlazorSample_BlazorWebApp/Components/App.razor @@ -2,7 +2,9 @@ - + + + diff --git a/11.0/BlazorSample_BlazorWebApp/Components/Pages/ClientValidation.razor b/11.0/BlazorSample_BlazorWebApp/Components/Pages/ClientValidation.razor new file mode 100644 index 000000000..6d87d46c0 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/Pages/ClientValidation.razor @@ -0,0 +1,34 @@ +@page "/client-validation" + +

Client-side validation

+ +@if (submitted) +{ +

The form was submitted successfully.

+} + + + + + +
+ + +
+ + +
+ +@code { + private bool submitted; + + [SupplyParameterFromForm] + private ShipModel? Model { get; set; } + + protected override void OnInitialized() => Model ??= new(); + + private void Submit() => submitted = true; +} diff --git a/11.0/BlazorSample_BlazorWebApp/Program.cs b/11.0/BlazorSample_BlazorWebApp/Program.cs index 0c28de61e..5502cc4ca 100644 --- a/11.0/BlazorSample_BlazorWebApp/Program.cs +++ b/11.0/BlazorSample_BlazorWebApp/Program.cs @@ -11,6 +11,7 @@ var app = builder.Build(); +app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); diff --git a/11.0/BlazorSample_BlazorWebApp/Properties/launchSettings.json b/11.0/BlazorSample_BlazorWebApp/Properties/launchSettings.json new file mode 100644 index 000000000..3b22ddddd --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7219;http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs b/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs index ef04f5b2f..33d3b09ff 100644 --- a/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs +++ b/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs @@ -19,7 +19,13 @@ public StartsWithAttribute(string prefix) protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { - if (value is string text && !text.StartsWith(prefix, StringComparison.Ordinal)) + // Leave empty values to RequiredAttribute, matching the client-side validator. + if (value is not string text || text.Length == 0) + { + return ValidationResult.Success; + } + + if (!text.StartsWith(prefix, StringComparison.Ordinal)) { return new ValidationResult(ErrorMessage, [validationContext.MemberName!]); } diff --git a/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js b/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js index c1a7d8efc..12eb7e8b5 100644 --- a/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js +++ b/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js @@ -1,5 +1,5 @@ -// JavaScript initializer for the app. The Blazor.formValidation service is created while -// Blazor starts, so custom client-side validators are registered from afterWebStarted. +// A JavaScript initializer works with automatic or manual Blazor startup. The validation service +// is available when afterWebStarted runs, even if the current page contains no validated form. export function afterWebStarted(blazor) { blazor.formValidation.addValidator('startswith', (context) => { const value = context.value; diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/BlazorWebAppRemoteValidation.Client.csproj b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/BlazorWebAppRemoteValidation.Client.csproj new file mode 100644 index 000000000..284c7b8d2 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/BlazorWebAppRemoteValidation.Client.csproj @@ -0,0 +1,16 @@ + + + + net11.0 + enable + enable + true + Default + true + + + + + + + diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/CustomValidation.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/CustomValidation.razor new file mode 100644 index 000000000..c37be601c --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/CustomValidation.razor @@ -0,0 +1,53 @@ +@implements IDisposable + +@code { + [CascadingParameter] + private EditContext? CurrentEditContext { get; set; } + + private ValidationMessageStore _messages = default!; + + protected override void OnInitialized() + { + ArgumentNullException.ThrowIfNull(CurrentEditContext); + + _messages = new(CurrentEditContext); + CurrentEditContext.OnValidationRequested += HandleValidationRequested; + CurrentEditContext.OnFieldChanged += HandleFieldChanged; + } + + public void DisplayErrors(IDictionary errors) + { + foreach (var (fieldName, messages) in errors) + { + _messages.Add(CurrentEditContext!.Field(fieldName), messages); + } + + CurrentEditContext!.NotifyValidationStateChanged(); + } + + public void ClearErrors() + { + _messages.Clear(); + CurrentEditContext!.NotifyValidationStateChanged(); + } + + private void HandleValidationRequested( + object? sender, ValidationRequestedEventArgs e) => + ClearErrors(); + + private void HandleFieldChanged(object? sender, FieldChangedEventArgs e) + { + _messages.Clear(e.FieldIdentifier); + CurrentEditContext!.NotifyValidationStateChanged(); + } + + public void Dispose() + { + if (CurrentEditContext is not null) + { + CurrentEditContext.OnValidationRequested -= + HandleValidationRequested; + CurrentEditContext.OnFieldChanged -= HandleFieldChanged; + } + } +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor new file mode 100644 index 000000000..96fbbe6cc --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor.css b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor.css new file mode 100644 index 000000000..60cec92d5 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Models/StarshipModel.cs b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Models/StarshipModel.cs new file mode 100644 index 000000000..b6363faab --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Models/StarshipModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Validation; + +namespace BlazorWebAppRemoteValidation.Client.Models; + +[ValidatableType] +public sealed class StarshipModel +{ + [Required] + public string? Identifier { get; set; } + + [Required] + public string? Classification { get; set; } + + public string? Description { get; set; } +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor new file mode 100644 index 000000000..00233ab7a --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor @@ -0,0 +1,88 @@ +@page "/" +@using System.Net +@using System.Net.Http.Json +@using BlazorWebAppRemoteValidation.Client.Models +@inject HttpClient Http + +Remote validation + +

Remote validation

+ +

Local validation runs before the form calls the remote endpoint. The endpoint validates the model again and applies a private business rule that requires a description for defense ships.

+ + + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+ +@if (_accepted) +{ +

The server accepted the form.

+} + +@code { + private StarshipModel Model { get; } = new(); + private CustomValidation? _remoteErrors; + private bool _accepted; + + private async Task Submit() + { + _accepted = false; + + using var response = await Http.PostAsJsonAsync( + "api/starships/validate", Model); + + if (response.IsSuccessStatusCode) + { + _accepted = true; + return; + } + + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var problem = await response.Content + .ReadFromJsonAsync() + ?? throw new InvalidOperationException( + "The validation response didn't contain a response body."); + + _remoteErrors.DisplayErrors(problem.Errors); + return; + } + + response.EnsureSuccessStatusCode(); + } + + private sealed record ValidationProblemResponse( + Dictionary Errors); +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/NotFound.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/NotFound.razor new file mode 100644 index 000000000..091b6b8bc --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs new file mode 100644 index 000000000..8a2f8f295 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using BlazorWebAppRemoteValidation.Client.Validation; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddScoped(sp => + new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddClientModelValidation(); + +await builder.Build().RunAsync(); diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Routes.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Routes.razor new file mode 100644 index 000000000..105855d45 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Validation/ClientValidationExtensions.cs b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Validation/ClientValidationExtensions.cs new file mode 100644 index 000000000..a87677a81 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Validation/ClientValidationExtensions.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace BlazorWebAppRemoteValidation.Client.Validation; + +public static class ClientValidationExtensions +{ + public static IServiceCollection AddClientModelValidation( + this IServiceCollection services) => + services.AddValidation(); +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/_Imports.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/_Imports.razor new file mode 100644 index 000000000..c6a97b714 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using BlazorWebAppRemoteValidation.Client +@using BlazorWebAppRemoteValidation.Client.Layout diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx new file mode 100644 index 000000000..3de70be30 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.csproj b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.csproj new file mode 100644 index 000000000..0e1b76406 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.csproj @@ -0,0 +1,15 @@ + + + + net11.0 + enable + enable + true + + + + + + + + diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/App.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/App.razor new file mode 100644 index 000000000..369684935 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/App.razor @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/Pages/Error.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/Pages/Error.razor new file mode 100644 index 000000000..3e5f5c98b --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/Pages/Error.razor @@ -0,0 +1,40 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + [PersistentState] + public string? RequestId { get; set; } + + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() + { + RequestId ??= Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + } +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/_Imports.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/_Imports.razor new file mode 100644 index 000000000..6516c25c4 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using BlazorWebAppRemoteValidation +@using BlazorWebAppRemoteValidation.Client +@using BlazorWebAppRemoteValidation.Components diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs new file mode 100644 index 000000000..fc2a6e682 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs @@ -0,0 +1,46 @@ +using BlazorWebAppRemoteValidation.Components; +using BlazorWebAppRemoteValidation.Client.Models; +using BlazorWebAppRemoteValidation.Client.Validation; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); +builder.Services.AddClientModelValidation(); +builder.Services.AddValidation(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.MapStaticAssets(); + +app.MapPost("/api/starships/validate", (StarshipModel model) => +{ + Dictionary errors = []; + + if (model.Classification == "Defense" && + string.IsNullOrWhiteSpace(model.Description)) + { + errors[nameof(model.Description)] = + ["A defense ship requires a description."]; + } + + if (errors.Count > 0) + { + return Results.ValidationProblem(errors); + } + + return Results.NoContent(); +}); + +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(BlazorWebAppRemoteValidation.Client._Imports).Assembly); + +app.Run(); diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Properties/launchSettings.json b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Properties/launchSettings.json new file mode 100644 index 000000000..7d2665004 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5257", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.Development.json b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.json b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/wwwroot/app.css b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/wwwroot/app.css new file mode 100644 index 000000000..2db4212ee --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/wwwroot/app.css @@ -0,0 +1,38 @@ +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} diff --git a/11.0/BlazorWebAppRemoteValidation/README.md b/11.0/BlazorWebAppRemoteValidation/README.md new file mode 100644 index 000000000..0b3b43b62 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/README.md @@ -0,0 +1,20 @@ +# Blazor Web App remote validation + +This sample uses an Interactive WebAssembly form and a Minimal API in the Blazor Web App host to demonstrate remote form validation. + +The form: + +1. Runs data annotations validation locally. +1. Sends locally valid input to the Minimal API. +1. Displays field-keyed errors returned by the endpoint. +1. Proceeds only when local and remote validation succeed. + +The endpoint validates data annotations before its handler runs and applies a private business rule in the handler. + +WebAssembly prerendering is disabled so the form's `HttpClient` dependency only needs to be registered in the `.Client` project. + +Run the sample from the solution directory: + +```dotnetcli +dotnet run --project BlazorWebAppRemoteValidation +``` From db7ba5ab6c79e6d01abd2a94e772b3149e5def1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Thu, 17 Sep 2026 16:45:42 +0200 Subject: [PATCH 3/3] Add sample-backed validation snippets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1616697-b97e-43c8-bba6-eadce0a68d2c --- .../Components/AppManualStartup.razor | 7 +++++++ .../wwwroot/js/custom-validation.js | 17 +++++++++++++++++ .../Pages/Home.razor | 2 +- .../Program.cs | 2 ++ .../BlazorWebAppRemoteValidation/Program.cs | 2 ++ 5 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 11.0/BlazorSample_BlazorWebApp/Components/AppManualStartup.razor create mode 100644 11.0/BlazorSample_BlazorWebApp/wwwroot/js/custom-validation.js diff --git a/11.0/BlazorSample_BlazorWebApp/Components/AppManualStartup.razor b/11.0/BlazorSample_BlazorWebApp/Components/AppManualStartup.razor new file mode 100644 index 000000000..568eb1cf0 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/AppManualStartup.razor @@ -0,0 +1,7 @@ + + + diff --git a/11.0/BlazorSample_BlazorWebApp/wwwroot/js/custom-validation.js b/11.0/BlazorSample_BlazorWebApp/wwwroot/js/custom-validation.js new file mode 100644 index 000000000..0a3f7d4c3 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/wwwroot/js/custom-validation.js @@ -0,0 +1,17 @@ +function registerCustomValidators(blazor) { + blazor.formValidation.addValidator('startswith', (context) => { + const value = context.value; + + if (!value) { + return { success: true }; + } + + return { success: value.startsWith(context.params.prefix) }; + }); +} + +// Automatic startup initializes formValidation before this script runs. +// Manual startup calls registerCustomValidators after Blazor.start completes. +if (Blazor.formValidation) { + registerCustomValidators(Blazor); +} diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor index 00233ab7a..b237522cf 100644 --- a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Pages/Home.razor @@ -76,7 +76,7 @@ ?? throw new InvalidOperationException( "The validation response didn't contain a response body."); - _remoteErrors.DisplayErrors(problem.Errors); + _remoteErrors!.DisplayErrors(problem.Errors); return; } diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs index 8a2f8f295..dbc58c3c8 100644 --- a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs @@ -3,8 +3,10 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args); +// builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +// builder.Services.AddClientModelValidation(); await builder.Build().RunAsync(); diff --git a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs index fc2a6e682..2740aa8ef 100644 --- a/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs @@ -20,6 +20,7 @@ app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.MapStaticAssets(); +// app.MapPost("/api/starships/validate", (StarshipModel model) => { Dictionary errors = []; @@ -38,6 +39,7 @@ return Results.NoContent(); }); +// app.MapRazorComponents() .AddInteractiveWebAssemblyRenderMode()