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

SKILL.md

GitHub

Scaffold 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)

  1. 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.
  2. Confirm the project-specific client component base class name (e.g. IndyRecordsClientComponentBase). Mapper (IUmbrellaMapper) is a protected property on UmbrellaComponentBase (the Umbrella framework base) — it is available in all Blazor components in the hierarchy without any injection.
  3. Read Web\<AppName>.Web.Shared\Security\Policies\<AppName>PolicyNames.cs or SharedPolicyNames.cs for the correct auth policy constant.
  4. 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, use umbrella-dotnet-scaffold-api-server-models to add the shared model hierarchy first.
  5. 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) and IUmbrellaMapperlyExistingInstanceMapper<Update<Name>ResultModel, Update<Name>Model> (to refresh the concurrency stamp after save). If they do not exist, use the umbrella-dotnet-scaffold-mapperly-factories skill 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 @page directives: the create route (no ID) and the edit route ({Id:int}).
  • @inherits ManageBase only — no C# logic in the .razor beyond the @{...} title block.
  • UmbrellaModelLayoutStateView wraps the form — always present; it handles the loading/error/success state machine.
  • CurrentState and ReloadAsync come from the base class — do not define them. Bind the layout view and its custom error fragment to ReloadAsync; the fragment uses LoadErrorMessage from the code-behind. Reuse an existing ErrorStateView import if it is already provided by _Imports.razor.
  • Form fields: add one <div class="form-group"> per genuinely user-editable property. Use form-floating for 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 .razor file inherits from it via @inherits.
  • [Authorize(PolicyName)] on the class, not in the .razor file.
  • [Inject] private I<Name>Service Repository { get; set; } = null!; — the property is always named Repository by convention, regardless of the type name.
  • CreateUpdateModel is typed as the exact discovered shared create/update base (illustrated as CreateUpdate<Name>ModelBase?), so both sealed concrete create and update models can be assigned without duplicating their shared property contract.
  • OnInitializedAsync: if no Id, construct an empty Create<Name>Model and set CurrentState = LayoutState.Success. If Id is set, load from Repository.FindByIdAsync and use await Mapper.MapAsync<<Name>Model, Update<Name>Model>(result.Result, cancellationToken: CancellationToken) to populate CreateUpdateModel.
  • SubmitFormAsync: pattern-match on CreateUpdateModel type 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 requires IUmbrellaMapperlyExistingInstanceMapper<Update<Name>ResultModel, Update<Name>Model> in Client.Data. If that mapper does not exist yet, fall back to await ReloadAsync() and leave a // TODO: Mapper comment.
  • Mapper is a protected property on UmbrellaComponentBase (the Umbrella framework base) — no injection needed in derived components. The Mapper.MapAsync calls require client-side mapper classes in Web.Client.Data\Mappings\Api\: IUmbrellaMapperlyNewInstanceMapper<<Name>Model, Update<Name>Model> for load, and IUmbrellaMapperlyExistingInstanceMapper<Update<Name>ResultModel, Update<Name>Model> for post-save refresh. Use the umbrella-dotnet-scaffold-mapperly-factories skill to create them.
  • Concurrency exception is caught specifically with a dedicated user message.
  • System.ComponentModel.DataAnnotations is needed if ValidationResult is referenced — add the using if 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

  1. Two @page directives — create route (no ID) and edit route ({Id:int}).
  2. [Authorize(PolicyName)] is on the code-behind class, not in the .razor file.
  3. [Inject] property is named Repository and typed as the service interface.
  4. CreateUpdateModel is typed as the shared create/update model base (CreateUpdate<Name>ModelBase?), not an input interface or one concrete request type.
  5. OnInitializedAsync uses await Mapper.MapAsync<<Name>Model, Update<Name>Model>(result.Result, cancellationToken: CancellationToken) to populate the edit form — no manual property assignment.
  6. OnInitializedAsync sets CurrentState = LayoutState.Success on both the create and edit success paths, and LayoutState.Error on failure.
  7. SubmitFormAsync pattern-matches on Create<Name>Model vs Update<Name>Model — navigates after create; after update calls _ = await Mapper.MapAsync(result.Result, updateModel, cancellationToken: CancellationToken) to refresh the model in place.
  8. UmbrellaConcurrencyException is caught and handled with ClientErrorMessages.Concurrency.
  9. UmbrellaModelLayoutStateView wraps the form content in the .razor.
  10. Client-side mapper classes exist in Web.Client.Data\Mappings\Api\ for the <Name>Model → Update<Name>Model and Update<Name>ResultModel → Update<Name>Model mappings.
  11. When the displayed file uses Dynamic Image URL fingerprinting, its model declares ImageVersionToken, mappings assign URL/token together, and UmbrellaFileImagePreviewUpload receives and forwards VersionToken.
  12. 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.
  13. Read .ai-shared\bundles\umbrella\analyzer-compatibility.md and build with the installed analyzers enabled.
  14. Load/retry starts in LayoutState.Loading, resets the prior error message, and reaches LayoutState.Error on handled failure; the inline error fragment offers retry and identifies timeouts.
  15. Load, save, and async mapping calls pass the component lifetime token where supported. Uploads link it with evt.UploadCancellationToken, forward evt.UploadProgress, and check cancellation before applying results. Exception filters suppress token-confirmed navigation/upload cancellation and use ignoreCancellationExceptions: false for other failures.
  16. 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.