umbrella-blazor-scaffold-manage-page
Scaffold a Blazor manage page (.razor + .razor.cs) for create/edit operations, following the Umbrella EditForm pattern with UmbrellaModelLayoutStateView, breadcrumb, auth policy, and concurrency handl
Files2
View on GitHubSKILL.md.ai-shared/skills/umbrella-blazor-scaffold-manage-page/SKILL.md
GitHubScaffold Blazor Manage Page
Purpose
Add a Blazor manage page that handles both create and edit for a feature. The page uses EditForm with UmbrellaModelLayoutStateView to manage loading states, and dispatches to Repository.CreateAsync or Repository.UpdateAsync based on whether an ID parameter is present.
Prerequisite: A client data service interface (I<Name>Service) must exist with CreateAsync and UpdateAsync methods (i.e. the service interface extends IGenericDataService with non-NoOp create/update model types). The concrete create and update models must exist and derive from a shared CreateUpdate<Name>ModelBase used by the combined form.
Discovery (read these before writing anything)
- Read 2–3 existing manage pages under
Web\<AppName>.Web.Client\Pages\Admin\to understand the form field patterns, file upload handling if applicable, and navigation after save. - Confirm the project-specific client component base class name (e.g.
IndyRecordsClientComponentBase).Mapper(IUmbrellaMapper) is aprotectedproperty onUmbrellaComponentBase(the Umbrella framework base) — it is available in all Blazor components in the hierarchy without any injection. - Read
Web\<AppName>.Web.Shared\Security\Policies\<AppName>PolicyNames.csorSharedPolicyNames.csfor the correct auth policy constant. - Read the create/update model hierarchy for the feature to know which properties to include as form fields and identify the exact shared create/update base name. Use their exact discovered names and follow the repository's convention (for example,
CreateUpdateManageIndustryModelBase); do not introduce an input interface as a substitute. If the base is missing, useumbrella-dotnet-scaffold-api-server-modelsto add the shared model hierarchy first. - Check
Web\<AppName>.Web.Client.Data\Mappings\Api\for an existing<Name>Mapper.cs. You will need client-side mappers for:IUmbrellaMapperlyNewInstanceMapper<<Name>Model, Update<Name>Model>(to populate the edit form) andIUmbrellaMapperlyExistingInstanceMapper<Update<Name>ResultModel, Update<Name>Model>(to refresh the concurrency stamp after save). If they do not exist, use theumbrella-dotnet-scaffold-mapperly-factoriesskill to create them first.
Request failure handling
Read Blazor request failures and component cancellation before implementing load, save, or upload handlers. Pass the inherited lifetime token to requests, distinguish expected navigation cancellation from an uncancelled timeout, and complete the load state transition with an inline error and retry. Use the application's existing error messages/resources in place of the example literals below where available.
Step 1 -- Create Manage.razor
File: Web\<AppName>.Web.Client\Pages\Admin\<Name>Management\Manage.razor
@inherits ManageBase
@using Umbrella.AspNetCore.Blazor.Components.StateViews
@page "/admin/<route-plural>/manage"
@page "/admin/<route-plural>/manage/{Id:int}"
@{
string title = "Manage <Names>";
string subTitle = Id.HasValue ? "Edit" : "Create";
}
<<AppName>PageTitle>@title</<AppName>PageTitle>
<UmbrellaBreadcrumb>
<UmbrellaBreadcrumbItem Name="@title" Url="/admin/<route-plural>" />
<UmbrellaBreadcrumbItem Name="@subTitle" />
</UmbrellaBreadcrumb>
<div class="management-page">
<h1>@title</h1>
<h4>@subTitle</h4>
<hr />
<section>
<UmbrellaModelLayoutStateView CurrentState="CurrentState" Model="CreateUpdateModel" ReloadCallback="ReloadAsync">
<Success>
<UmbrellaValidationSummary ValidationResults="ValidationResults" />
<EditForm Model="CreateUpdateModel" OnValidSubmit="SubmitFormAsync" novalidate>
<ObjectGraphDataAnnotationsValidator />
<div class="form-group form-floating">
<UmbrellaInputText class="form-control" @bind-Value="CreateUpdateModel!.Name" />
<LabelText ForTarget="() => CreateUpdateModel!.Name" />
<ValidationMessage For="() => CreateUpdateModel.Name" />
</div>
<div class="form-group form-group--buttons">
<button type="submit" class="btn btn-primary">@(Id.HasValue ? "Save Changes" : "Create <Name>")</button>
<a href="/admin/<route-plural>" class="btn btn-secondary">Cancel</a>
</div>
</EditForm>
</Success>
<Error>
<ErrorStateView Message="@LoadErrorMessage" OnReloadButtonClick="ReloadAsync" />
</Error>
</UmbrellaModelLayoutStateView>
</section>
</div>
Rules:
- Two
@pagedirectives: the create route (no ID) and the edit route ({Id:int}). @inherits ManageBaseonly — no C# logic in the.razorbeyond the@{...}title block.UmbrellaModelLayoutStateViewwraps the form — always present; it handles the loading/error/success state machine.CurrentStateandReloadAsynccome from the base class — do not define them. Bind the layout view and its custom error fragment toReloadAsync; the fragment usesLoadErrorMessagefrom the code-behind. Reuse an existingErrorStateViewimport if it is already provided by_Imports.razor.- Form fields: add one
<div class="form-group">per genuinely user-editable property. Useform-floatingfor text inputs. Check existing manage pages for the right component per field type (UmbrellaInputText,UmbrellaInputTextArea,UmbrellaInputSelect, etc.). A file-provider filename is not a raw text field: either implement the complete upload/preview workflow below or omit it from this page and explicitly report that file editing is out of scope. - Cancel button always links back to the index route.
- Submit button label changes based on
Id.HasValue.
File upload field pattern
When the feature includes image/file upload:
<div class="form-group">
<LabelText ForTarget="() => CreateUpdateModel.<FilePropertyName>" />
<UmbrellaFileImagePreviewUpload @ref="ImagePreviewUpload"
OnRequestUpload="UploadFileToTempDirectoryAsync"
OnDeleteImage="OnDeleteImage"
Accept="@GlobalFileSystemConstants.<Name>FileExtensions"
MaxFileSizeBytes="@GlobalFileSystemConstants.<Name>MaxSizeBytes"
WidthRequest="400"
HeightRequest="400"
Url="@Model?.ImageUrl"
VersionToken="@Model?.ImageVersionToken" />
<ValidationMessage For="() => CreateUpdateModel.<FilePropertyName>" />
</div>
Step 2 -- Create Manage.razor.cs
File: Web\<AppName>.Web.Client\Pages\Admin\<Name>Management\Manage.razor.cs
using <AppName>.Web.Client.Data.Services.Abstractions;
using <AppName>.Web.Shared.Models.Api.<Feature>;
using Umbrella.Utilities.Http.Abstractions;
namespace <AppName>.Web.Client.Pages.Admin.<Name>Management;
[Authorize(<AppName>PolicyNames.<Policy>)]
public abstract class ManageBase : <AppName>ClientComponentBase
{
[Parameter]
public int? Id { get; set; }
[Inject]
private I<Name>Service Repository { get; set; } = null!;
private const string LoadFailureMessage = "The page could not be loaded. Please try again.";
private const string TimeoutFailureMessage = "The server took too long to respond. Please try again.";
protected string LoadErrorMessage { get; private set; } = LoadFailureMessage;
protected <Name>Model? Model { get; private set; }
protected CreateUpdate<Name>ModelBase? CreateUpdateModel { get; private set; }
protected IReadOnlyCollection<ValidationResult>? ValidationResults { get; private set; }
protected override async Task OnInitializedAsync()
{
CurrentState = LayoutState.Loading;
LoadErrorMessage = LoadFailureMessage;
try
{
if (!Id.HasValue)
{
CreateUpdateModel = new Create<Name>Model();
CurrentState = LayoutState.Success;
return;
}
var result = await Repository.FindByIdAsync(Id.Value, cancellationToken: CancellationToken);
if (result.IsSuccess && result.Result is not null)
{
Model = result.Result;
CreateUpdateModel = await Mapper.MapAsync<<Name>Model, Update<Name>Model>(result.Result, cancellationToken: CancellationToken);
CurrentState = LayoutState.Success;
return;
}
LoadErrorMessage = result is IHttpOperationResult httpResult && httpResult.ProblemDetails?.Status is 408 or 504
? TimeoutFailureMessage
: LoadFailureMessage;
CurrentState = LayoutState.Error;
}
catch (Exception exc) when (
!(exc is OperationCanceledException && CancellationToken.IsCancellationRequested)
&& Logger.WriteError(exc, new { Id }, ignoreCancellationExceptions: false))
{
LoadErrorMessage = exc is OperationCanceledException or TimeoutException
? TimeoutFailureMessage
: LoadFailureMessage;
CurrentState = LayoutState.Error;
}
}
protected async Task SubmitFormAsync()
{
try
{
if (CreateUpdateModel is null)
throw new InvalidOperationException("The CU model is null");
if (Id.HasValue && Model is null)
throw new InvalidOperationException("The model is null");
if (CreateUpdateModel is Create<Name>Model createModel)
{
var result = await Repository.CreateAsync(createModel, cancellationToken: CancellationToken);
if (result.IsSuccess)
{
await DialogUtility.ShowSuccessMessageAsync("The <Name> has been created successfully.");
Navigation.NavigateTo("/admin/<route-plural>");
}
else
{
await ShowOperationResultErrorMessageAsync(result);
}
}
else if (CreateUpdateModel is Update<Name>Model updateModel)
{
var result = await Repository.UpdateAsync(updateModel, cancellationToken: CancellationToken);
if (result.IsSuccess && result.Result is not null)
{
await DialogUtility.ShowSuccessMessageAsync("The <Name> has been updated successfully.");
// Preferred: map the update result back onto the existing model to refresh the
// concurrency stamp and any server-computed fields — avoids a full page reload.
// Requires IUmbrellaMapperlyExistingInstanceMapper<Update<Name>ResultModel, Update<Name>Model>
// in Client.Data. If that mapper does not exist yet, use ReloadAsync() as a fallback.
_ = await Mapper.MapAsync(result.Result, updateModel, cancellationToken: CancellationToken);
// Fallback (use when the client-side result mapper has not been created yet):
// await ReloadAsync();
}
else
{
await ShowOperationResultErrorMessageAsync(result);
}
}
}
catch (UmbrellaConcurrencyException)
{
await DialogUtility.ShowDangerMessageAsync(ClientErrorMessages.Concurrency);
}
catch (Exception exc) when (
!(exc is OperationCanceledException && CancellationToken.IsCancellationRequested)
&& Logger.WriteError(exc, new { Model, CreateUpdateModel }, ignoreCancellationExceptions: false))
{
await DialogUtility.ShowDangerMessageAsync();
}
}
}
Rules:
public abstract class— the.razorfile inherits from it via@inherits.[Authorize(PolicyName)]on the class, not in the.razorfile.[Inject] private I<Name>Service Repository { get; set; } = null!;— the property is always namedRepositoryby convention, regardless of the type name.CreateUpdateModelis typed as the exact discovered shared create/update base (illustrated asCreateUpdate<Name>ModelBase?), so both sealed concrete create and update models can be assigned without duplicating their shared property contract.OnInitializedAsync: if noId, construct an emptyCreate<Name>Modeland setCurrentState = LayoutState.Success. IfIdis set, load fromRepository.FindByIdAsyncand useawait Mapper.MapAsync<<Name>Model, Update<Name>Model>(result.Result, cancellationToken: CancellationToken)to populateCreateUpdateModel.SubmitFormAsync: pattern-match onCreateUpdateModeltype to call the correct method. After a successful create, navigate to the index route. After a successful update, prefer_ = await Mapper.MapAsync(result.Result, updateModel, cancellationToken: CancellationToken)to refresh the concurrency stamp in place — this requiresIUmbrellaMapperlyExistingInstanceMapper<Update<Name>ResultModel, Update<Name>Model>inClient.Data. If that mapper does not exist yet, fall back toawait ReloadAsync()and leave a// TODO: Mappercomment.Mapperis aprotectedproperty onUmbrellaComponentBase(the Umbrella framework base) — no injection needed in derived components. TheMapper.MapAsynccalls require client-side mapper classes inWeb.Client.Data\Mappings\Api\:IUmbrellaMapperlyNewInstanceMapper<<Name>Model, Update<Name>Model>for load, andIUmbrellaMapperlyExistingInstanceMapper<Update<Name>ResultModel, Update<Name>Model>for post-save refresh. Use theumbrella-dotnet-scaffold-mapperly-factoriesskill to create them.- Concurrency exception is caught specifically with a dedicated user message.
System.ComponentModel.DataAnnotationsis needed ifValidationResultis referenced — add theusingif required.
Additional injections for file upload
When file upload is needed, first read client upload progress and verify the installed API and service support. The example below opts into handler-reported progress; retain stream-read mode for consumers that have not adopted the reporting contract. Set ProgressMode="UmbrellaFileUploadProgressMode.HandlerReported" on the upload component when using this callback. Add:
[Inject]
private IFileUploadService FileUploadService { get; set; } = null!;
protected UmbrellaFileImagePreviewUpload ImagePreviewUpload { get; set; } = null!;
public async Task<IOperationResult?> UploadFileToTempDirectoryAsync(UmbrellaFileUploadRequestEventArgs evt)
{
using var uploadCancellation = CancellationTokenSource.CreateLinkedTokenSource(
CancellationToken, evt.UploadCancellationToken);
try
{
if (CreateUpdateModel is null)
throw new InvalidOperationException("The model should not be null here.");
var fileUploadResult = await FileUploadService.UploadAsync(evt.Content, evt.FileName, FileUploadType.<Name>Image, evt.Type, cancellationToken: uploadCancellation.Token, uploadProgress: evt.UploadProgress);
uploadCancellation.Token.ThrowIfCancellationRequested();
if (fileUploadResult.IsSuccess)
{
CreateUpdateModel.<FilePropertyName> = fileUploadResult.Result.tempFileName;
// Updating the filename alone is not enough on an edit: the server uses this flag
// to delete the old stored file and promote the new temporary upload.
if (CreateUpdateModel is Update<Name>Model updateModel)
updateModel.ReplaceExistingImage = true;
// Temporary/no-store uploads normally have no token. If the upload contract returns one,
// pass it as the second argument so the preview emits the fingerprint directly.
ImagePreviewUpload.Update(fileUploadResult.Result.url);
}
StateHasChanged();
return fileUploadResult;
}
catch (Exception exc) when (
!(exc is OperationCanceledException && uploadCancellation.IsCancellationRequested)
&& Logger.WriteError(exc, new { evt.FileName, evt.Type }, ignoreCancellationExceptions: false))
{
await DialogUtility.ShowDangerMessageAsync();
}
return null;
}
protected void OnDeleteImage()
{
if (CreateUpdateModel is null)
throw new InvalidOperationException("The model should not be null here.");
if (CreateUpdateModel is Update<Name>Model updateModel)
updateModel.ReplaceExistingImage = true;
}
Verification
- Two
@pagedirectives — create route (no ID) and edit route ({Id:int}). [Authorize(PolicyName)]is on the code-behind class, not in the.razorfile.[Inject]property is namedRepositoryand typed as the service interface.CreateUpdateModelis typed as the shared create/update model base (CreateUpdate<Name>ModelBase?), not an input interface or one concrete request type.OnInitializedAsyncusesawait Mapper.MapAsync<<Name>Model, Update<Name>Model>(result.Result, cancellationToken: CancellationToken)to populate the edit form — no manual property assignment.OnInitializedAsyncsetsCurrentState = LayoutState.Successon both the create and edit success paths, andLayoutState.Erroron failure.SubmitFormAsyncpattern-matches onCreate<Name>ModelvsUpdate<Name>Model— navigates after create; after update calls_ = await Mapper.MapAsync(result.Result, updateModel, cancellationToken: CancellationToken)to refresh the model in place.UmbrellaConcurrencyExceptionis caught and handled withClientErrorMessages.Concurrency.UmbrellaModelLayoutStateViewwraps the form content in the.razor.- Client-side mapper classes exist in
Web.Client.Data\Mappings\Api\for the<Name>Model → Update<Name>ModelandUpdate<Name>ResultModel → Update<Name>Modelmappings. - When the displayed file uses Dynamic Image URL fingerprinting, its model declares
ImageVersionToken, mappings assign URL/token together, andUmbrellaFileImagePreviewUploadreceives and forwardsVersionToken. - A successful upload while editing sets the matching replacement flag (
ReplaceExistingImage,ReplaceExistingFile, or the feature-specific equivalent) after assigning the temporary provider filename. Do not rely only on the delete/replace button handler; image-preview upload controls can upload directly over an existing preview. - Read
.ai-shared\bundles\umbrella\analyzer-compatibility.mdand build with the installed analyzers enabled. - Load/retry starts in
LayoutState.Loading, resets the prior error message, and reachesLayoutState.Erroron handled failure; the inline error fragment offers retry and identifies timeouts. - Load, save, and async mapping calls pass the component lifetime token where supported. Uploads link it with
evt.UploadCancellationToken, forwardevt.UploadProgress, and check cancellation before applying results. Exception filters suppress token-confirmed navigation/upload cancellation and useignoreCancellationExceptions: falsefor other failures. - When changing request handling, verify timeout/retry, disposal during a pending request, and applicable transient-failure versus missing-resource behaviour as described in the shared reference.