How to Build a Blazor Business Screen with Custom Editors
An editing screen needs more than a table. Changes must be held as a draft until the user saves, a cancelled edit must leave the record untouched, some fields need their own editor, and a field that refers to another record needs a way to choose one from the full set. Written by hand, that is a considerable amount of state management for every screen.
BlazorGrid in Sphere10 Framework supplies that editing workflow over any IDataSource<T>. This article builds one page that uses it, with a custom editor for one column, a reference picker for a related record and a row command. It covers what the data source owns, how a column is defined, how the draft buffer works and what the host needs for the page to be interactive.
1. A first look
The page declares the grid with its source, columns and actions.
<BlazorGrid TItem="Person" DataSource="_source" Columns="_columns" Actions="_actions" PageSize="2" />
The columns are built in the page's code. The first displays its cell through a template, and the second replaces the built-in editor with its own input.
var name = BlazorGridColumn<Person>.For(person => person.Name);
name.ExpandsToFit = true;
name.Template = person => @<strong>@person.Name</strong>;
var notes = BlazorGridColumn<Person>.For(person => person.Notes);
notes.EditorTemplate = context => @<input aria-label="Notes" maxlength="120"
value="@context.Value" @onchange="args => context.ValueChanged.InvokeAsync(args.Value)" />;
The grid supplies the editing workflow around these declarations. The sections that follow cover the data source behind it, the column fragments, the reference picker and the row command.
2. The parts of a grid screen
Three objects cooperate. The grid component renders rows and editors. Its controller holds the current query, the selection and the edit draft. The data source reads pages and validates and persists changes. The grid does not own the data source, so the page or the DI scope that created the source remains responsible for it.
The same IDataSource<T>, DataSourceCapabilities, CrudAction and Result types back the WinForms grid, so a data source written for one front end serves the other.
3. Keep persistence in the data source
The sample's source derives from ListDataSource<Person>, an in-memory implementation, and overrides the members that define its behaviour. ReadRange receives the search term, page length, zero-based page, sort property and sort direction, and returns one page together with the total number of matches.
return new DataSourceItems<Person> {
Items = matches.Skip(page * pageLength).Take(pageLength).ToArray(),
Page = page,
TotalCount = matches.Length
};
ValidateRange receives each entity with the CrudAction about to be applied and returns a Result carrying any errors.
public override Result ValidateRange(IEnumerable<(Person entity, CrudAction action)> actions) {
var result = new Result();
foreach (var (person, action) in actions) {
if (action == CrudAction.Delete)
continue;
if (string.IsNullOrWhiteSpace(person.Name))
result.AddError("Name is required.");
if (person.Age < 0 || person.Age > 150)
result.AddError("Age must be between 0 and 150.");
if (ReferenceEquals(person, person.Manager))
result.AddError("A person cannot be their own manager.");
}
return result;
}
The grid calls validation before it persists a row. The sample's CreateRange, UpdateRange and DeleteRange overrides call the same validation again, so a caller that bypasses the grid cannot bypass the rules. Filter and sort before taking the page, and treat SortDirection.None as the source's natural order rather than as ascending.
4. Configure a custom editing page
BlazorGridColumn<T>.For, which the first look used for the Name and Notes columns, builds a column from a property expression, including its getter, setter, type and sort key. A column then accepts two optional fragments. Template controls how a cell is displayed, and EditorTemplate replaces the built-in editor while the row is being edited.
The editor template receives a BlazorGridCellEditContext<T> with the Item, the Column, the buffered Value, a ValueChanged callback and IsNewItem. The editor reads context.Value and reports changes through context.ValueChanged. It must not assign to context.Item directly, because that would write past the draft and make Cancel ineffective.
Editing a row follows one path whichever editor produced the values.
The states are those of the grid's controller, and the transitions are its methods. The Edit command, a double-click or F2 calls BeginEdit, an editor's ValueChanged reaches SetEditValue, which changes only the draft, and Cancel or Escape calls CancelEdit. SaveAsync applies the changed values to the item, validates it and then updates the source. A rejected validation restores the item's fields and keeps the draft, so the user can correct it and save again.
Create the column objects once, in a field or in OnInitialized. Replacing them while a row is being edited cancels the draft. Built-in editors already cover strings, booleans, numbers, enums, GUIDs, dates, times and durations, so a custom template is needed only where those do not fit.
5. Choose an existing related record
A column whose value is another object needs explicit accessors, because For accepts only a direct property and has no default editor for a complex type.
var manager = new BlazorGridColumn<Person> {
ColumnName = "Manager",
PropertyName = nameof(Person.Manager),
DataType = typeof(Person),
PropertyValue = person => person.Manager,
SetPropertyValue = (person, value) => person.Manager = (Person)value,
CanEditCell = true,
Width = 220,
Template = person => @<span>@(person.Manager?.Name ?? "(None)")</span>,
EditorTemplate = context => @<BlazorGridReferencePicker TItem="Person"
DataSource="_source" Columns="_managerColumns" Value="@(context.Value as Person)"
ValueChanged="person => context.ValueChanged.InvokeAsync(person)"
DisplayText="ManagerText" Label="manager" AllowNull="true" />
};
BlazorGridReferencePicker shows the current value in the cell and opens a dropdown that contains a second grid for browsing and searching the candidates.
| Parameter | Purpose |
DataSource |
The full set of eligible records. Do not pass the parent grid's current page, or records on other pages cannot be chosen. |
Columns |
The columns shown in the dropdown. |
Value, ValueChanged |
The selected object and the callback that returns a new selection to the draft. |
DisplayText |
A function that produces the text shown for the current value. |
AllowNull |
Enables clearing the reference. |
The picker masks create, edit and delete commands, since it selects an existing record and does not modify one. Choosing a record only updates the draft. The parent row's Save or Cancel decides whether the new reference is persisted. Keep the display template shallow, as above, so that rendering does not walk a chain of related objects.
6. Treat actions as application commands
A GridAction<T> adds a command to each row.
_actions = new[] { new GridAction<Person>("Details", person => person, string.Empty) {
ActionWorkAsync = ShowDetailsAsync,
IsActionAvailable = person => person.IsActive
} };
ActionWorkAsync is awaited, and IsActionAvailable enables the command for each row. An action is an application command, not a CRUD operation. It does not run the source's validation and is not gated by CanUpdate or CanDelete, so any rule it needs must be enforced by the service it calls. Errors thrown by the callback appear in the grid, and a successful action requests a refresh.
7. Host it interactively
The grid needs an interactive render mode. The page selects it with @rendermode InteractiveServer, and the host registers the matching services and endpoints.
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseStaticWebAssets();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services
.BuildBlazorApplication()
.WithTitle("People editor")
.Build();
var app = builder.Build();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<Components.App>().AddInteractiveServerRenderMode();
app.Run();
BuildBlazorApplication() registers the Framework's Blazor services. MapStaticAssets() serves the grid's collocated CSS and JavaScript, and UseStaticWebAssets() lets an unpublished build serve those package assets when it runs in Production mode. Registering the services alone does not make a statically rendered page interactive, so the render mode on the page is required as well.
8. Verify the edit boundary
The draft workflow can be exercised without a browser, because it lives in the controller. The sample's --verify switch does that with a BlazorGridController<Person>.
using var controller = new BlazorGridController<Person>();
controller.Columns = new[] { BlazorGridColumn<Person>.For(person => person.Name), notes, manager };
await controller.BindAsync(source, DataSourceCapabilities.Default, 2);
var ada = natural[0];
controller.BeginEdit(ada);
controller.SetEditValue(notes, "Discard this draft");
controller.CancelEdit();
dotnet run -c Release -- --verify
These checks cover cancel, save, ordering and a rejected validation. They do not cover focus, pointer events or the dropdown's layout, which need the running host. The sample's source is created by the page and held in memory, so its data is recreated when the page is reloaded. To use the page itself, run the host and open /people-custom.
Read the custom editor and picker guide and the complete grid quick start for every grid parameter. The Sphere10 Framework overview connects this browser UI to shared application composition, native WinForms controls and embedded storage.
9. Download the example
Add the package to a .NET 10 project.
dotnet add package Sphere10.Framework.Web.AspNetCore.Blazor
Download the complete NuGet sample project. The archive contains the project, its model files and run instructions. Keep the pinned package version when reproducing the example.
Download the complete NuGet sample project. The archive contains the project, its model files and run instructions. Keep the pinned package version when reproducing the example.
Author: Herman Schoenfeld