--- name: add-blazor-page description: "Use when adding a new Blazor page, dialog, or ViewModel to {{SolutionName}} Presentation layer. Covers ViewModel interface + implementation, ServiceClient, Refit client method, Model record, .razor page with code-behind, MudBlazor components, localization, and error handling. Use for: new pages, new dialogs, new list/detail views, new forms." argument-hint: "Describe the page, e.g. 'list page for prescriptions with search and add dialog'" --- # Add Blazor Page — Presentation Layer > **Scope: Blazor WASM + Client (BFF).** This skill is for the Blazor WASM project where the Presentation RCL calls the Client host API via Refit (`BffServiceClients.AddBffServiceClients()` / `CookieHandler`). For standalone WASM apps with direct `HttpClient` services (no Client host), use the **`/add-blazor-module`** skill instead. Add a new Blazor page with ViewModel, ServiceClient, and Refit integration. UI uses **MudBlazor** components and **IStringLocalizer** for localization. ## File Inventory (per page) For a new page in feature ``: | File | Location | |------|----------| | `IViewModel.cs` | `src/Presentation//ViewModels/` | | `ViewModel.cs` | `src/Presentation//ViewModels/` | | `IServiceClient.cs` | `src/Presentation//ServiceClients/` | | `ServiceClient.cs` | `src/Presentation//ServiceClients/` | | `Model.cs` | `src/Presentation//Models/` | | `.razor` | `src/Presentation//Pages/` | | `.razor.cs` | `src/Presentation//Pages/` | | Refit method on `IClient` | `src/Presentation/Shared/ServiceClients/Bff/Clients/` | For dialog pages, add: | File | Location | |------|----------| | `IDialogViewModel.cs` | `src/Presentation//ViewModels/` | | `DialogViewModel.cs` | `src/Presentation//ViewModels/` | | `Dialog.razor` | `src/Presentation//Pages/` | | `Dialog.razor.cs` | `src/Presentation//Pages/` | | `Model.cs` (with `[Required]`) | `src/Presentation//Models/` | DI: ViewModels auto-registered by `PresentationModule` (suffix `ViewModel` → Transient). ServiceClients auto-registered (suffix `ServiceClient` → Transient). --- ## Model ```csharp namespace {{NamespaceRoot}}.Presentation..Models; // Read model (immutable) public record Model(string Name, string Email); // Write/form model (mutable, with validation attributes) using System.ComponentModel.DataAnnotations; public class AddModel { [Required] public string Name { get; set; } [Required] public string Email { get; set; } } ``` --- ## ViewModel Interface ```csharp namespace {{NamespaceRoot}}.Presentation..ViewModels; using {{NamespaceRoot}}.Presentation..Models; using {{NamespaceRoot}}.Presentation.Shared; public interface IViewModel : IViewModel { bool IsBusy { get; set; } IList<Model> Items { get; set; } } ``` --- ## ViewModel Implementation ```csharp namespace {{NamespaceRoot}}.Presentation..ViewModels; using {{NamespaceRoot}}.Presentation..Models; using {{NamespaceRoot}}.Presentation..ServiceClients; using {{NamespaceRoot}}.Presentation.Shared; public class ViewModel(IServiceClient serviceClient) : IViewModel { private readonly IServiceClient _serviceClient = serviceClient; public bool IsBusy { get; set; } public IList<Model> Items { get; set; } public async Task InitializeAsync(IErrorComponent errorComponent) { IsBusy = true; try { var items = await _serviceClient.GetAllAsync(); Items = [.. items]; } catch (Exception ex) { errorComponent.ProcessError(ex); } finally { IsBusy = false; } } } ``` **Lifecycle rules**: - Set `IsBusy = true` before async work, `false` in `finally` - Catch exceptions → `errorComponent.ProcessError(ex)` - Never throw from `InitializeAsync` --- ## Dialog ViewModel (for add/edit forms) ```csharp namespace {{NamespaceRoot}}.Presentation..ViewModels; using System.ComponentModel.DataAnnotations; using {{NamespaceRoot}}.Presentation..ServiceClients; using {{NamespaceRoot}}.Presentation.Resources.; using {{NamespaceRoot}}.Presentation.Shared; public class DialogViewModel( IServiceClient serviceClient, IDistributedTraceService distributedTraceService) : IDialogViewModel { private readonly IServiceClient _serviceClient = serviceClient ?? throw new ArgumentNullException(nameof(serviceClient)); private readonly IDistributedTraceService _distributedTraceService = distributedTraceService ?? throw new ArgumentNullException(nameof(distributedTraceService)); private IErrorComponent _errorComponent; [Required(ErrorMessageResourceName = nameof(.CannotBeEmpty), ErrorMessageResourceType = typeof())] public string Name { get; set; } // ... more validated properties ... public bool IsBusy { get; set; } public async Task HandleValidSubmitAsync() { try { IsBusy = true; await _distributedTraceService.UseDistributedTrace(async (dt) => { await _serviceClient.AddAsync(Name, ...); }); } catch (Exception ex) { _errorComponent.ProcessError(ex); } finally { IsBusy = false; } } public async Task InitializeAsync(IErrorComponent errorComponent) { _errorComponent = errorComponent ?? throw new ArgumentNullException(nameof(errorComponent)); await Task.CompletedTask; } } ``` --- ## ServiceClient ```csharp namespace {{NamespaceRoot}}.Presentation..ServiceClients; using {{NamespaceRoot}}.Presentation..Models; using {{NamespaceRoot}}.Presentation.Shared.ServiceClients.Bff; using {{NamespaceRoot}}.Presentation.Shared.ServiceClients.Bff.Clients; public class ServiceClient(IClient client) : IServiceClient { private readonly IClient _client = client ?? throw new ArgumentNullException(nameof(client)); public async TaskModel>> GetAllAsync() { var items = await _client.GetAsync(null, ApiConstants.ApiVersion); return items.Select(x => new Model(x.Name, x.Email)); } public async Task> AddAsync(string name, string email) { try { await _client.AddAsync( new AddDto(name, email), ApiConstants.ApiVersion); } catch (ApiException ex) { return ex.ConvertApiExceptionToResult(); } return new Result(Unit.Default()); } } ``` **Key patterns**: - Always pass `ApiConstants.ApiVersion` to Refit calls - Catch `ApiException` → `ConvertApiExceptionToResult()` for write operations - Map API DTOs to Presentation Models in the ServiceClient --- ## Refit Client Add methods to `src/Presentation/Shared/ServiceClients/Bff/Clients/IClient.cs`: ```csharp namespace {{NamespaceRoot}}.Presentation.Shared.ServiceClients.Bff.Clients; using Refit; public interface IClient { [Get("/api/")] TaskDto>> GetAsync( [Query] string name = null, [AliasAs("api-version")][Query] string apiVersion = null, CancellationToken cancellationToken = default); [Post("/api/")] Task AddAsync( [Body] AddDto dto, [AliasAs("api-version")][Query] string apiVersion = null, CancellationToken cancellationToken = default); } ``` **Rules**: Always include `[AliasAs("api-version")][Query] string apiVersion = null` and `CancellationToken`. --- ## Razor Page (list view) ### `.razor` ```html @page "/" @using {{NamespaceRoot}}.Presentation..Models @Localizer["PageTitle"] @if (ViewModel.IsBusy) { } else { @Localizer["Add"] } ``` ### `.razor.cs` (code-behind) ```csharp namespace {{NamespaceRoot}}.Presentation..Pages; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; using MudBlazor; using {{NamespaceRoot}}.Presentation..ViewModels; using {{NamespaceRoot}}.Presentation.Shared; public partial class { [CascadingParameter] public Error Error { get; set; } [Inject] public IViewModel ViewModel { get; set; } [Inject] private IDialogService DialogService { get; set; } [Inject] private IStringLocalizer<> Localizer { get; set; } protected async override Task OnInitializedAsync() => await ViewModel.InitializeAsync(Error); private async Task OpenAddDialogAsync() { var options = new DialogOptions { CloseOnEscapeKey = true, BackdropClick = false }; var dialog = await DialogService.ShowAsync>("Add item", options); var result = await dialog.Result; if (!result.Canceled) { await ViewModel.InitializeAsync(Error); StateHasChanged(); } } } ``` **Page patterns**: - `[CascadingParameter] public Error Error { get; set; }` — cascading error component - `[Inject]` for DI — ViewModel, DialogService, Localizer - `OnInitializedAsync` → `ViewModel.InitializeAsync(Error)` - After dialog closes → re-initialize + `StateHasChanged()` --- ## Dialog Razor Page ### `Dialog.razor` ```html @Localizer["DialogTitle"] @if (ViewModel.IsBusy) { } else {
}
@Localizer["Save"] @Localizer["Cancel"]
``` ### `Dialog.razor.cs` ```csharp namespace {{NamespaceRoot}}.Presentation..Pages; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Localization; using MudBlazor; using {{NamespaceRoot}}.Presentation..ViewModels; public partial class Dialog { [Inject] private IStringLocalizer<> Localizer { get; set; } [Inject] private IDialogViewModel ViewModel { get; set; } [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } private async Task ValidSubmit(EditContext editContext) { await ViewModel.HandleValidSubmitAsync(); MudDialog.Close(DialogResult.Ok(ViewModel)); } private void Cancel() => MudDialog.Close(); } ``` --- ## Routing Add the page route to `src/Presentation/Shared/Navigation/` if using the navigation service OR add a `NavLink` in the appropriate layout. --- ## Localization Create resource files in `src/Presentation/Resources//`: - `.resx` (default/English) - `.nl.resx` (Dutch) - `.fr.resx` (French) Reference in code: `@Localizer[nameof(Resources...KeyName)]` --- ## Checklist - [ ] ViewModel implements `IViewModel` with `InitializeAsync(IErrorComponent)` - [ ] `IsBusy` guard on all async operations - [ ] `try/catch → errorComponent.ProcessError(ex)` in ViewModel - [ ] Service Client catches `ApiException → ConvertApiExceptionToResult()` - [ ] Refit Client includes `apiVersion` + `CancellationToken` - [ ] `[CascadingParameter] Error` on all pages - [ ] `[Inject]` for ViewModel, DialogService, Localizer - [ ] MudBlazor components (not raw HTML) - [ ] Localization via `IStringLocalizer` - [ ] Dialog uses `IMudDialogInstance` + `MudDialog.Close(DialogResult.Ok(...))` - [ ] Copyright header on all new `.cs` files