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..3d5bc565e --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Components/App.razor @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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/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/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/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..5502cc4ca --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Program.cs @@ -0,0 +1,18 @@ +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.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); 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/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..33d3b09ff --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/Validation/StartsWithAttribute.cs @@ -0,0 +1,42 @@ +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) + { + // 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!]); + } + + 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..12eb7e8b5 --- /dev/null +++ b/11.0/BlazorSample_BlazorWebApp/wwwroot/BlazorSample.lib.module.js @@ -0,0 +1,14 @@ +// 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; + + // 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/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/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..b237522cf --- /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..dbc58c3c8 --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation.Client/Program.cs @@ -0,0 +1,12 @@ +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..2740aa8ef --- /dev/null +++ b/11.0/BlazorWebAppRemoteValidation/BlazorWebAppRemoteValidation/Program.cs @@ -0,0 +1,48 @@ +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 +``` 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;