Step-by-step instructions for creating frontend CRUD pages in the AppProject .NET Blazor WebAssembly template, including observable models, Refit API clients, search/listing pages, form pages, dropdown components, and shared components. Use when the user wants to create frontend pages, Blazor components, Refit clients, or web models for a new entity.
Follow these steps to create a complete frontend CRUD for a new entity using Blazor WebAssembly. Use the General module (Country, State, City) as the reference.
Location: src/AppProject.Web.Models.<Module>/
Web models mirror the API DTOs but implement INotifyPropertyChanged via ObservableModel:
using System;
using AppProject.Models;
namespace AppProject.Web.Models.<Module>;
public class <EntityName> : ObservableModel, IEntity
{
private Guid? id;
private string name = default!;
private byte[]? rowVersion;
public Guid? Id { get => this.id; set => this.Set(ref this.id, value); }
public string Name { get => this.name; set => this.Set(ref this.name, value); }
// Add other properties with backing fields and Set()...
public byte[]? RowVersion { get => this.rowVersion; set => this.Set(ref this.rowVersion, value); }
}
Rules:
this.Set(ref field, value) in settersObservableModel and IEntityGuid (required) or Guid? (optional)public ICollection<CreateOrUpdateRequest<ChildEntity>>? ChangedChildRequests { get; set; }
public ICollection<DeleteRequest<Guid>>? DeletedChildRequests { get; set; }
Location: src/AppProject.Web.Models/<Module>/ (shared) or src/AppProject.Web.Models.<Module>/
using System;
using AppProject.Models;
namespace AppProject.Web.Models.<Module>;
public class <EntityName>Summary : ISummary
{
public Guid Id { get; set; }
public string Name { get; set; } = default!;
// Add aggregated display fields
}
Location: src/AppProject.Web.ApiClient.<Module>/
Routes MUST match the controller routes exactly:
using System;
using AppProject.Models;
using AppProject.Web.Models.<Module>;
using Refit;
namespace AppProject.Web.ApiClient.<Module>;
public interface I<EntityName>Client
{
[Get("/api/<module_lowercase>/<EntityName>/Get")]
public Task<EntityResponse<<EntityName>>> GetAsync([Query] GetByIdRequest<Guid> request, CancellationToken cancellationToken = default);
[Post("/api/<module_lowercase>/<EntityName>/Post")]
public Task<KeyResponse<Guid>> PostAsync([Body] CreateOrUpdateRequest<<EntityName>> request, CancellationToken cancellationToken = default);
[Put("/api/<module_lowercase>/<EntityName>/Put")]
public Task<KeyResponse<Guid>> PutAsync([Body] CreateOrUpdateRequest<<EntityName>> request, CancellationToken cancellationToken = default);
[Delete("/api/<module_lowercase>/<EntityName>/Delete")]
public Task<EmptyResponse> DeleteAsync([Query] DeleteRequest<Guid> request, CancellationToken cancellationToken = default);
}
Location: src/AppProject.Web.ApiClient/<Module>/ (shared)
using System;
using AppProject.Models;
using AppProject.Web.Models.<Module>;
using Refit;
namespace AppProject.Web.ApiClient.<Module>;
public interface I<EntityName>SummaryClient
{
[Get("/api/<module_lowercase>/<EntityName>Summary/GetSummaries")]
public Task<SummariesResponse<<EntityName>Summary>> GetSummariesAsync([Query] SearchRequest request, CancellationToken cancellationToken = default);
[Get("/api/<module_lowercase>/<EntityName>Summary/GetSummary")]
public Task<SummaryResponse<<EntityName>Summary>> GetSummaryAsync([Query] GetByIdRequest<Guid> request, CancellationToken cancellationToken = default);
}
If using a custom SearchRequest, replace SearchRequest with <EntityName>SummarySearchRequest.
Location: src/AppProject.Web.<Module>/Pages/
Search pages inherit from SearchPage<TRequest, TSummary>:
@page "/<module_lowercase>/<plural_entity_lowercase>"
@attribute [Authorize]
@inherits SearchPage<SearchRequest, <EntityName>Summary>
<SearchControl TRequest="SearchRequest" [email protected]
[email protected]("<Module>_<EntityName>SummaryPage_Title") @[email protected]
@[email protected] [email protected]
[email protected]>
@* Optional: Advanced filters go here *@
<DataGridControl TItem="<EntityName>Summary" [email protected] @[email protected]
[email protected] [email protected] OnDeleteItem=@OnDeleteItemAsync>
<RadzenDataGridColumn TItem="<EntityName>Summary"
[email protected]("<Module>_<EntityName>SummaryPage_NameColumn_Title")
Property=@nameof(<EntityName>Summary.Name) />
@* Add more columns as needed *@
</DataGridControl>
</SearchControl>
@code {
[Inject]
private I<EntityName>SummaryClient <EntityName>SummaryClient { get; set; } = default!;
[Inject]
private I<EntityName>Client <EntityName>Client { get; set; } = default!;
protected override async Task<IEnumerable<<EntityName>Summary>> FetchDataAsync()
{
var summariesResponse = await this.GetResultOrHandleExceptionAsync<SummariesResponse<<EntityName>Summary>>(
() => this.<EntityName>SummaryClient.GetSummariesAsync(this.Request));
return summariesResponse?.Summaries ?? Enumerable.Empty<<EntityName>Summary>();
}
private async Task OnNewItemAsync()
{
await this.OpenDialogAsync<<EntityName>FormPage, <EntityName>>(
title: StringResource.GetStringByKey("<Module>_<EntityName>FormPage_Title"));
await this.ExecuteSearchAsync();
}
private async Task OnEditItemAsync()
{
var selectedId = this.SelectedItems.FirstOrDefault()?.Id;
if (selectedId.HasValue)
{
await this.OpenDialogAsync<<EntityName>FormPage, <EntityName>>(
title: StringResource.GetStringByKey("<Module>_<EntityName>FormPage_Title"),
parameters: new Dictionary<string, object>() { { "Id", selectedId } });
await this.ExecuteSearchAsync();
}
}
private async Task OnDeleteItemAsync()
{
var selectedIds = this.SelectedItems.Select(x => x.Id);
if (selectedIds.Any() && await this.ConfirmAsync(StringResource.GetStringByKey("Dialog_Confirm_Delete_Message")))
{
foreach (var selectedId in selectedIds)
{
await this.HandleExceptionAsync(() => this.<EntityName>Client.DeleteAsync(new DeleteRequest<Guid> { Id = selectedId }));
}
await this.ExecuteSearchAsync();
}
}
}
Add filters inside SearchControl using AdvancedFilters:
<AdvancedFilters>
<ParentSummaryDropDownDataGridControl @[email protected]
Style="width: 300px;" />
</AdvancedFilters>
Location: src/AppProject.Web.<Module>/Pages/
Form pages inherit from ModelFormPage<TModel>:
@attribute [Authorize]
@inherits ModelFormPage<<EntityName>>
<ModelFormControl TModel="<EntityName>" [email protected] [email protected] [email protected]>
<FieldsetControl [email protected]("<Module>_<EntityName>FormPage_GeneralFieldset_Title")>
<RadzenRow>
<RadzenColumn>
<RadzenText TextStyle="TextStyle.Subtitle2">@StringResource.GetStringByKey("<Module>_<EntityName>FormPage_GeneralFieldset_IdField_Text", this.Model.Id)</RadzenText>
</RadzenColumn>
</RadzenRow>
<RadzenRow>
<RadzenColumn>
<RadzenStack Orientation="Orientation.Horizontal" Gap="1rem" Wrap="FlexWrap.Wrap">
<RadzenFormField [email protected]("<Module>_<EntityName>FormPage_GeneralFieldset_NameField_Label")>
<RadzenTextBox Name="NameField" @[email protected] />
<RadzenRequiredValidator Component="NameField" [email protected]("<Module>_<EntityName>FormPage_GeneralFieldset_NameField_Required") />
<RadzenLengthValidator Component="NameField" Max="200" [email protected]("<Module>_<EntityName>FormPage_GeneralFieldset_NameField_InvalidLength") />
</RadzenFormField>
@* Add more fields following the same pattern *@
</RadzenStack>
</RadzenColumn>
</RadzenRow>
</FieldsetControl>
</ModelFormControl>
@code {
[Parameter]
public Guid? Id { get; set; }
[Inject]
private I<EntityName>Client <EntityName>Client { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
if (this.Id.HasValue)
{
var entityResponse = await this.GetResultOrHandleExceptionAsync<EntityResponse<<EntityName>>>(
() => this.<EntityName>Client.GetAsync(new GetByIdRequest<Guid>{ Id = this.Id.Value }));
if (entityResponse is not null)
{
this.SetModel(entityResponse.Entity);
}
}
}
private async Task OnSaveAsync()
{
var keyResponse = await this.GetResultOrHandleExceptionAsync<KeyResponse<Guid>>(
async () =>
{
if (this.Model.Id.GetValueOrDefault() != Guid.Empty)
{
return await this.<EntityName>Client.PutAsync(new CreateOrUpdateRequest<<EntityName>> { Entity = this.Model });
}
return await this.<EntityName>Client.PostAsync(new CreateOrUpdateRequest<<EntityName>> { Entity = this.Model });
});
if (keyResponse is not null)
{
this.Model.Id = keyResponse.Id;
await this.CloseDialogAsync(this.Model);
}
}
private Task OnCancelAsync() => this.CloseDialogAsync();
}
For forms that require selecting a parent entity, use a DropDown component:
<ParentSummaryDropDownDataGridControl @[email protected] />
<RadzenCustomValidator Component="ParentEntityField"
Validator=@(() => this.Model.ParentEntityId != Guid.Empty)
[email protected]("...Required") />
Location: src/AppProject.Web.Shared/<Module>/Components/
@inherits DropDownDataGridControl<<EntityName>Summary, Guid>
<RadzenDropDownDataGrid ... />
@code {
[Inject]
private I<EntityName>SummaryClient Client { get; set; } = default!;
// See CountrySummaryDropDownDataGridControl.razor for full pattern
}
Add all UI text keys to the three .resx files:
<Module>_<EntityName>SummaryPage_Title, <Module>_<EntityName>FormPage_Title<Module>_<EntityName>SummaryPage_<Column>Column_Title<Module>_<EntityName>FormPage_GeneralFieldset_<Field>Field_Label..._Required, ..._InvalidLength<Module>_<EntityName>FormPage_GeneralFieldset_Title<Module>_<EntityName>FormPage_GeneralFieldset_IdField_TextLocation: src/AppProject.Web/Layout/NavMenu.razor
Add the navigation entry with the appropriate permission check.
Location: src/AppProject.Web/App.razor
Add the assembly to OnNavigateAsync() conditions for the module's route prefix.
ShowNewAction / ShowEditAction / ShowDeleteAction — toggle button visibilityPreferAddOverNew — changes "New" button text to "Add" (for child items)PreferOpenOverEdit — changes "Edit" to "Open" (read-only)PreferExecuteOverSave — changes "Save" to "Execute"PreferCloseOverCancel — applies closing style to cancel buttonGlobalActions — slot for custom action buttonsObservableModel, IEntity, backing fieldsISummarySearchPage base, SearchControl, DataGridControlModelFormPage base, ModelFormControl, FieldsetControl.resx filesNavMenu.razorApp.razor (if new module)WebBootstrap.GetApiClientAssemblies()