This article describes how to build a Data Editor Context for Blazor Forms with both Dirty State Management and Field Validation built in.
Introduction
This is the second of two articles looking at how to implement edit forms in Blazor.
The first article explored how to control what the user could do once a form was dirty; in essence, how to stop a user unintentionally exiting. This article describes how to build a framework that detects when the dataset is dirty and/or invalid and "locks" the application.
Many probably consider that Blazor already has enough functionality for edit data. Why do you need to re-invent the wheel is a valid question? If you fervently believe this is true, read no further: this article isn't for you. If not, then read on and make your own decision.
A little recent background. C# 9 introduced the Record
type, creating an immutable reference type. The property {get; init;}
lets us create an immutable property. These are recent language changes: Microsoft rethinking?
I'm a firm believer in maintaining the integrity of records and recordsets read from databases. What you see in your reference record or recordset is what is in the database. If you want to edit something, there's a process, don't just wade in and change the original. Make a copy, change the copy, submit the copy to the database and then refresh your reference data from the database.
The editing framework I use, described in this article, implements those principles.
Overview
This short discussion and the project uses the out-of-the-box Blazor WeatherForecast
record as our example.
DbWeatherForecast
represents the record read from the database. It's declared as a class
, not a record
: only properties that represent database fields are immutable. The editable version of DbWeatherForecast
is held in a RecordCollection
. DbWeatherForecast
has methods to build and read data from a RecordCollection
. A RecordCollection
is an IEnumerable
object containing a list of RecordFieldValue
objects. Each represents a field/property in DbWeatherForecast
. A RecordFieldValue
has its own immutable fields, Value
and FieldName
, and an EditedValue
field which can be set. IsDirty
is a boolean property that represents the edit state of RecordFieldValue
. The RecordCollection
and RecordFieldValue
classes provide controlled access to the underlying data values.
WeatherForecastEditContext
is the UI editor object for DbWeatherForecast
, exposing the editable properties of the RecordCollection
for DbWeatherForecast
. It has a symbiotic relationship with the EditContext
, tracking the edit state of the RecordCollection
and providing validation of any properties that require data validation.
In the project, WeatherForecastControllerService
is the business object that provides access to the WeatherForecast
data. The editor and viewer call GetForecastAsync(id)
to load the current DbWeatherForecast
record in WeatherForecastControllerService
. RecordData
the RecordCollection
for the DbWeatherForecast
record is populated by GetForecastAsync(id)
. When the UI initializes an instance of WeatherForecastEditContext
, it passes it the WeatherForecastControllerService
RecordData
RecordCollection
. It's important to note at this point that RecordData
isn't replaced when a new DbWeatherForecast
is loaded, it's cleared and then re-populated: the reference passed to WeatherForecastEditContext
is always valid.
Sample Code
As always, there's a GitHub Repo CEC.Blazor.Editor. CEC.Blazor.ModalEditor
is the project for this article.
Infrastructure Classes
As always, we need some supporting classes for the main show.
RecordFieldValue
As already discussed, RecordFieldValue
holds information about a field in a Record set.
Note
- The properties derived from the actual record are
{get; init;}
. They can only be set when an instance of RecordFieldValue
is created. FieldName
is property name for the field. We define it to ensure we use the same string
value throughout the application. Value
is the database value of the field. ReadOnly
is self-evident. It's for labelling derived/calculated fields. DisplayName
is the string
to use when displaying the name of the field. EditedValue
is the current value of the field in our edit context. The getter ensures that on first get
, if it hasn't already been set
, it's set to Value
. IsDirty
does the default equality check for the object type on Value
against EditedValue
to determine if the Field
is dirty. Reset
sets EditedValue
back to Value
. - The two
Clone
methods create new copies of RecordEditValue
.
using System;
namespace CEC.Blazor.Editor
{
public class RecordFieldValue
{
public string FieldName { get; init; }
public object Value { get; init; }
public bool ReadOnly { get; init; }
public string DisplayName { get; set; }
public object EditedValue
{
get
{
if (this._EditedValue is null && this.Value != null)
this._EditedValue = this.Value;
return this._EditedValue;
}
set => this._EditedValue = value;
}
private object _EditedValue { get; set; }
public bool IsDirty
{
get
{
if (Value != null && EditedValue != null) return !Value.Equals(EditedValue);
if (Value is null && EditedValue is null) return false;
return true;
}
}
public RecordFieldValue() { }
public RecordFieldValue(string field, object value)
{
this.FieldName = field;
this.Value = value;
this.EditedValue = value;
this.GUID = Guid.NewGuid();
}
public void Reset()
=> this.EditedValue = this.Value;
public RecordFieldValue Clone()
{
return new RecordFieldValue()
{
DisplayName = this.DisplayName,
FieldName = this.FieldName,
Value = this.Value,
ReadOnly = this.ReadOnly
};
}
public RecordFieldValue Clone(object value)
{
return new RecordFieldValue()
{
DisplayName = this.DisplayName,
FieldName = this.FieldName,
Value = value,
ReadOnly = this.ReadOnly
};
}
}
}
RecordCollection
RecordCollection
is a managed IEnumerable
collection of RecordFieldValue
objects.
Note
- There are lots of getters, setters, etc. for accessing and updating the individual
RecordFieldValue
objects. IsDirty
checks for any dirty items in the collection. FieldValueChanged
is an event triggered whenever an individual RecordFieldValue
is set. You can see it being invoked when SetField
is called.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace CEC.Blazor.Editor
{
public class RecordCollection :IEnumerable<RecordFieldValue>
{
private List<RecordFieldValue> _items = new List<RecordFieldValue>();
public int Count => _items.Count;
public Action<bool> FieldValueChanged;
public bool IsDirty => _items.Any(item => item.IsDirty);
public IEnumerator<RecordFieldValue> GetEnumerator()
{
foreach (var item in _items)
yield return item;
}
IEnumerator IEnumerable.GetEnumerator()
=> this.GetEnumerator();
public void ResetValues()
=> _items.ForEach(item => item.Reset());
public void Clear()
=> _items.Clear();
public T Get<T>(string FieldName)
{
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x.Value is T t) return t;
return default;
}
public T GetEditValue<T>(string FieldName)
{
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x.EditedValue is T t) return t;
return default;
}
public bool SetField(string FieldName, object value)
{
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x != default)
{
x.EditedValue = value;
this.FieldValueChanged?.Invoke(this.IsDirty);
}
else _items.Add(new RecordFieldValue(FieldName, value));
return true;
}
}
RecordEditContext
RecordEditContext
is the base class for the record edit context. It contains the boilerplate code. We'll look at it in more detail in WeatherForecastEditContext
. Key points to note:
- It's initialiser requires a
RecordCollection
object. In the application, this is the ControllerService
RecordCollection
called RecordData
associated with the current record. It gets loaded whenever the record changes. - It holds a reference to the valid
EditContext
and expects to be notified of changes. - It handles Validation for the
EditContext
and is wired into EditContext.OnValidationRequested
. - It holds a List of
ValidationActions
which get run whenever validation is triggered.
using Microsoft.AspNetCore.Components.Forms;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public abstract class RecordEditContext : IRecordEditContext
{
public EditContext EditContext { get; private set; }
public bool IsValid => !Trip;
public bool IsDirty => this.RecordValues?.IsDirty ?? false;
public bool IsClean => !this.IsDirty;
public bool IsLoaded => this.EditContext != null && this.RecordValues != null;
protected RecordCollection RecordValues { get; private set; } = new RecordCollection();
protected bool Trip = false;
protected List<Func<bool>> ValidationActions { get; } = new List<Func<bool>>();
protected virtual void LoadValidationActions() { }
protected ValidationMessageStore ValidationMessageStore;
private bool Validating;
public RecordEditContext(RecordCollection collection)
{
Debug.Assert(collection != null);
if (collection is null)
throw new InvalidOperationException($"{nameof(RecordEditContext)}
requires a valid {nameof(RecordCollection)} object");
else
{
this.RecordValues = collection;
this.LoadValidationActions();
}
}
public bool Validate()
{
if (ValidationMessageStore != null && !this.Validating)
{
this.Validating = true;
this.ValidationMessageStore.Clear();
this.Trip = false;
foreach (var validator in this.ValidationActions)
{
if (!validator.Invoke()) this.Trip = true;
}
this.EditContext.NotifyValidationStateChanged();
this.Validating = false;
}
return IsValid;
}
public Task NotifyEditContextChangedAsync(EditContext context)
{
var oldcontext = this.EditContext;
if (context is null)
throw new InvalidOperationException($"{nameof(RecordEditContext)} -
NotifyEditContextChangedAsync requires a valid {nameof(EditContext)} object");
if (this.EditContext != null)
{
EditContext.OnValidationRequested -= ValidationRequested;
}
this.EditContext = context;
if (this.IsLoaded)
{
this.ValidationMessageStore = new ValidationMessageStore(EditContext);
this.EditContext.OnValidationRequested += this.ValidationRequested;
}
this.Validate();
return Task.CompletedTask;
}
private void ValidationRequested(object sender, ValidationRequestedEventArgs args)
{
this.Validate();
}
}
}
DbWeatherForecast
The new Weather forecast record. While we only create these records on the fly, a normal application would get them from a database.
Note
- There's a
static
declared RecordFieldValue
for each database property/field in the class. In a larger application, these should be declared in a central DataDictionary
. - The "
Database
" properties are all declared { get; init; }
: they're immutable. AsRecordCollection
builds a RecordCollection
object from the record. FromRecordCollection
is static
, it builds a new record from the supplied RecordCollection
using the edited values.
using System;
namespace CEC.Blazor.Editor
{
public class DbWeatherForecast
{
public static RecordFieldValue __ID = new RecordFieldValue()
{ FieldName = "ID", DisplayName = "ID" };
public static RecordFieldValue __Date = new RecordFieldValue()
{ FieldName = "Date", DisplayName = "Forecast Date" };
public static RecordFieldValue __TemperatureC = new RecordFieldValue()
{ FieldName = "TemperatureC", DisplayName = "Temperature C" };
public static RecordFieldValue __TemperatureF = new RecordFieldValue()
{ FieldName = "TemperatureF", DisplayName = "Temperature F", ReadOnly = true };
public static RecordFieldValue __Summary = new RecordFieldValue()
{ FieldName = "Summary", DisplayName = "Summary" };
public Guid ID { get; init; } = Guid.Empty;
public DateTime Date { get; init; } = DateTime.Now;
public int TemperatureC { get; init; } = 25;
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; init; }
public RecordCollection AsRecordCollection
{
get
{
var coll = new RecordCollection();
{
coll.Add(__ID.Clone(this.ID));
coll.Add(__Date.Clone(this.Date));
coll.Add(__TemperatureC.Clone(this.TemperatureC));
coll.Add(__TemperatureF.Clone(this.TemperatureF));
coll.Add(__Summary.Clone(this.Summary));
}
return coll;
}
}
public static DbWeatherForecast FromRecordCollection(RecordCollection coll)
=> new DbWeatherForecast()
{
ID = coll.GetEditValue<Guid>(__ID.FieldName),
Date = coll.GetEditValue<DateTime>(__Date.FieldName),
TemperatureC = coll.GetEditValue<int>(__TemperatureC.FieldName),
Summary = coll.GetEditValue<string>(__Summary.FieldName)
};
}
}
Data Services
I've split up the data access into a data and a controller service: makes it more realistic. We may be creating a dummy data set, but I'm mimicking normal practice. In a production system, this would run on interfaces and boilerplated base code implementations.
WeatherForecastDataService
The Data Service:
- Builds the dummy data set on startup.
- Provides CRUD data operations on that dataset.
- We're using Guids for Ids.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public class WeatherForecastDataService
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool",
"Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private List<DbWeatherForecast> Forecasts
{ get; set; } = new List<DbWeatherForecast>();
public WeatherForecastDataService()
=> PopulateForecasts();
public void PopulateForecasts()
{
var rng = new Random();
for (int x = 0; x < 5; x++)
{
Forecasts.Add(new DbWeatherForecast
{
ID = Guid.NewGuid(),
Date = DateTime.Now.AddDays((double)x),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
});
}
}
public Task<List<DbWeatherForecast>> GetForecastsAsync()
=> Task.FromResult(this.Forecasts);
public Task<DbWeatherForecast> GetForecastAsync(Guid id)
=> Task.FromResult(this.Forecasts.FirstOrDefault(item => item.ID.Equals(id)));
public Task<Guid> UpdateForecastAsync(DbWeatherForecast record)
{
var rec = this.Forecasts.FirstOrDefault(item => item.ID.Equals(record.ID));
if (rec != default) this.Forecasts.Remove(rec);
this.Forecasts.Add(record);
return Task.FromResult(record.ID);
}
public Task<Guid> AddForecastAsync(DbWeatherForecast record)
{
var id = Guid.NewGuid();
if (record.ID.Equals(Guid.Empty))
{
var recdata = record.AsRecordCollection;
recdata.SetField(DbWeatherForecast.__ID.FieldName, id);
record = DbWeatherForecast.FromRecordCollection(recdata);
}
else
{
var rec = this.Forecasts.FirstOrDefault(item => item.ID.Equals(record.ID));
if (rec != default) return Task.FromResult(Guid.Empty);
}
this.Forecasts.Add(record);
return Task.FromResult(id);
}
}
}
Controller Data
The controller service is the interface between the data and the UI, providing a high level business logic interface into the data. Most of the properties and methods are self-evident.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public class WeatherForecastControllerService
{
public WeatherForecastDataService DataService { get; set; }
public event EventHandler RecordChanged;
public event EventHandler ListChanged;
public RecordCollection RecordData { get; } = new RecordCollection();
public List<DbWeatherForecast> Forecasts {
get => _Forecasts;
private set
{
_Forecasts = value;
ListChanged?.Invoke(value, EventArgs.Empty);
}
}
private List<DbWeatherForecast> _Forecasts;
public DbWeatherForecast Forecast
{
get => _Forecast;
private set
{
_Forecast = value;
RecordData.AddRange(_Forecast.AsRecordCollection, true);
RecordChanged?.Invoke(_Forecast, EventArgs.Empty);
}
}
private DbWeatherForecast _Forecast;
public WeatherForecastControllerService
(WeatherForecastDataService weatherForecastDataService )
=> this.DataService = weatherForecastDataService;
public async Task GetForecastsAsync()
=> this.Forecasts = await DataService.GetForecastsAsync();
public async Task GetForecastAsync(Guid id)
{
this.Forecast = await DataService.GetForecastAsync(id);
this.RecordChanged?.Invoke(RecordChanged, EventArgs.Empty);
}
public async Task<bool> SaveForecastAsync()
{
Guid id = Guid.Empty;
var record = DbWeatherForecast.FromRecordCollection(this.RecordData);
if (this.Forecast.ID.Equals(Guid.Empty))
id = await this.DataService.AddForecastAsync(record);
else
id = await this.DataService.UpdateForecastAsync(record);
if (!id.Equals(Guid.Empty))
await GetForecastAsync(id);
return !id.Equals(Guid.Empty);
}
}
}
Building the UI
Moving on to the UI and digressing a little.
UI Components
A gripe I have with much of the UI code I see is HTML repetition. What coders do in Razor Markup, they would never dream of doing in C# code. Editor/Display/List forms are good examples. I've moved most of the repetitive HTML markup into UI Components: in my applications, HTML markup doesn't belong in high level components. A formatting issue such as not enough spacing. Fix it in one place and it's fixed everywhere!
Let's take a look at a couple of examples. All the UI components are in the UIComponents directory.
UIFormRow
Not rocket science. ChildContent
is the default definition of what gets entered between the opening and closing statements.
@namespace CEC.Blazor.Editor
<div class="row form-group">
@this.ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
}
With this, you can now declare each row as:
<UIFormRow>
....(ChildContent)
</UIFormRow>
UIButton
Again simple, but it keeps the high level declaration minimal.
@if (this.Show)
{
<button class="btn mr-1 @this.CssColor" @onclick="ButtonClick">
@this.ChildContent
</button>
}
@code {
[Parameter] public bool Show { get; set; } = true;
[Parameter] public EventCallback<MouseEventArgs> ClickEvent { get; set; }
[Parameter] public string CssColor { get; set; } = "btn-primary";
[Parameter] public RenderFragment ChildContent { get; set; }
protected void ButtonClick(MouseEventArgs e) => this.ClickEvent.InvokeAsync(e);
}
<UIButton CssColor="btn-success" Show="this.CanSave"
ClickEvent="this.Save">@this.SaveButtonText</UIButton>
ModalEditForm
ModalEditForm
replaces EditForm
. It:
- Has three
RenderFragments
. LoadingContent
is only shown whilst the form is loading. EditorContent
is shown once loading is complete. It cascades EditContext
. ButtonContent
is always shown at the bottom of the control. Loaded
controls what gets rendered. - We build the control with
BuildRenderTree
.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Rendering;
namespace CEC.Blazor.ModalEditor
{
public class ModalEditForm : ComponentBase
{
[Parameter] public RenderFragment EditorContent { get; set; }
[Parameter] public RenderFragment ButtonContent { get; set; }
[Parameter] public RenderFragment LoadingContent { get; set; }
[Parameter] public bool Loaded { get; set; }
[Parameter] public EditContext EditContext {get; set;}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (this.Loaded)
{
builder.OpenRegion(EditContext.GetHashCode());
builder.OpenComponent<CascadingValue<EditContext>>(1);
builder.AddAttribute(2, "IsFixed", true);
builder.AddAttribute(3, "Value", EditContext);
builder.AddAttribute(4, "ChildContent", EditorContent);
builder.CloseComponent();
builder.CloseRegion();
}
else
builder.AddContent(10, LoadingContent );
builder.AddContent(20, ButtonContent);
}
}
}
WeatherDataModal
Moving on to the real UI stuff.
This replaces FetchData
. It's similar. The Edit
and View
buttons now pass the ID
of the record. I haven't replaced the HTML with UI controls, so you can see how little has changed.
@page "/weatherdatamodal"
@using CEC.Blazor.Editor.Data
@namespace CEC.Blazor.Editor.Pages
<ModalDialog @ref="this.Modal"></ModalDialog>
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from a service.</p>
@if (this.ForecastService.Forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in this.ForecastService.Forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
<td class="text-right">
<button class="btn btn-sm btn-secondary"
@onclick="() => ShowViewDialog(forecast.ID)">View</button>
<button class="btn btn-sm btn-primary"
@onclick="() => ShowEditDialog(forecast.ID)">Edit</button>
</td>
</tr>
}
</tbody>
</table>
}
In code:
- We now use the new
WeatherForecastControllerService
, and the Razor markup uses the service Forecasts
list. - We load the
Forecasts
list in WeatherForecastControllerService
as part of the form OnInitializedAsync()
. - The two button handlers create a
ModalOptions
object and add the ID
to pass into the editor and viewer forms.
using Microsoft.AspNetCore.Components;
using System;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor.Pages
{
public partial class WeatherDataModal : ComponentBase
{
[Inject] WeatherForecastControllerService ForecastService { get; set; }
private ModalDialog Modal { get; set; }
protected async override Task OnInitializedAsync()
{
await ForecastService.GetForecastsAsync();
}
private async void ShowViewDialog(Guid id)
{
var options = new ModalOptions();
{
options.Set(ModalOptions.__Width, "80%");
options.Set(ModalOptions.__ID, id);
}
await this.Modal.ShowAsync<WeatherViewer>(options);
}
private async void ShowEditDialog(Guid id)
{
var options = new ModalOptions();
{
options.Set(ModalOptions.__Width, "80%");
options.Set(ModalOptions.__ID, id);
}
await this.Modal.ShowAsync<WeatherForecastEditor>(options);
}
}
}
WeatherForecastEditor
The Editor is the container that sets up all the edit components and then updates the buttons in the UI as things change in the underlying EditContext
and RecordEditorContext
. A set of Boolean Properties control the UI and button state.
On Initialization, it:
- gets the record ID from
ModalOptions
- loads the controller
DbWeatherForecast
- which loads RecordData
, the RecordCollection
object in the service - creates a new
RecordEditorContext
, passing in the RecordCollection
- creates a
EditContext
with RecordEditorContext
as the modal - notifies
RecordEditorContext
that the EditContext
has changed - wires up the
EditContext.OnFieldChanged
event to a local OnFieldChanged
event handler
Key points to note:
- The editor wires up a local property to the cascaded
ModalDialog
so it can lock and unlock the form and exit. - Contains the save and various exit methods.
OnFieldChanged
sorts out the UI buttons, form locking and rendering. It doesn't interact with the data - that's all done by the RecordEditorContext
.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using System;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public partial class WeatherForecastEditor : ComponentBase
{
public EditContext EditContext => _EditContext;
private EditContext _EditContext = null;
protected WeatherForecastEditContext RecordEditorContext { get; set; }
[Inject] protected WeatherForecastControllerService ControllerService { get; set; }
[CascadingParameter] private IModalDialog Modal { get; set; }
private bool IsModal => this.Modal != null;
private bool HasServices => this.IsModal && this.ControllerService != null;
private bool IsDirtyExit;
private bool IsDirty => RecordEditorContext.IsDirty;
private bool IsValid => RecordEditorContext.IsValid;
private bool IsLoaded => RecordEditorContext?.IsLoaded ?? false;
private bool CanSave => this.IsDirty && this.IsValid;
private bool CanExit => !this.IsDirtyExit;
private string SaveButtonText =>
this.ControllerService.Forecast.ID.Equals(Guid.Empty) ? "Save" : "Update";
protected async override Task OnInitializedAsync()
{
if (this.HasServices && Modal.Options.TryGet<Guid>
(ModalOptions.__ID, out Guid modalid))
{
await this.ControllerService.GetForecastAsync(modalid);
this.RecordEditorContext = new WeatherForecastEditContext
(this.ControllerService.RecordData);
this._EditContext = new EditContext(RecordEditorContext);
await this.RecordEditorContext.NotifyEditContextChangedAsync
(this.EditContext);
this.EditContext.OnFieldChanged += OnFieldChanged;
}
await base.OnInitializedAsync();
}
protected void OnFieldChanged(object sender, EventArgs e)
=> this.SetLock();
private void SetLock()
{
this.IsDirtyExit = false;
if (this.RecordEditorContext.IsDirty)
this.Modal.Lock(true);
else
this.Modal.Lock(false);
InvokeAsync(StateHasChanged);
}
protected async Task<bool> Save()
{
var ok = false;
if (this.RecordEditorContext.EditContext.Validate())
{
ok = await this.ControllerService.SaveForecastAsync();
if (ok)
{
this.RecordEditorContext.EditContext.MarkAsUnmodified();
this.SetLock();
}
}
return ok;
}
protected void Exit()
{
if (RecordEditorContext.IsDirty)
{
this.IsDirtyExit = true;
this.InvokeAsync(StateHasChanged);
}
else
this.Modal.Close(ModalResult.OK());
}
protected void DirtyExit()
{
this.Modal.Lock(false);
this.Modal.Close(ModalResult.OK());
}
protected void CancelExit()
=> SetLock();
}
}
The markup code is fairly standard editor fare.
- You can see the use of the
UIComponents
to standardize the HTML. EditForm
is replaced by ModalEditForm
. It ensures content isn't rendered until it's loaded and cascades the EditContext
. - The buttons use the various boolean properties to control their display state.
@namespace CEC.Blazor.ModalEditor
<UIContainer>
<UIFormRow>
<UIColumn>
<h2>Weather Forecast Editor</h2>
</UIColumn>
</UIFormRow>
</UIContainer>
<ModalEditForm EditContext="this.EditContext" Loaded="this.IsLoaded">
<LoadingContent>
... loading
</LoadingContent>
<EditorContent>
<UIContainer>
<UIFormRow>
<UILabelColumn>
Date
</UILabelColumn>
<UIInputColumn Cols="3">
<InputDate class="form-control"
@bind-Value="this.RecordEditorContext.Date"></InputDate>
</UIInputColumn>
<UIColumn Cols="3"></UIColumn>
<UIValidationColumn>
<ValidationMessage For=@(() => this.RecordEditorContext.Date) />
</UIValidationColumn>
</UIFormRow>
<UIFormRow>
<UILabelColumn>
Temperature °C
</UILabelColumn>
<UIInputColumn Cols="2">
<InputNumber class="form-control"
@bind-Value="this.RecordEditorContext.TemperatureC"></InputNumber>
</UIInputColumn>
<UIColumn Cols="4"></UIColumn>
<UIValidationColumn>
<ValidationMessage For=@(() => this.RecordEditorContext.TemperatureC) />
</UIValidationColumn>
</UIFormRow>
<UIFormRow>
<UILabelColumn>
Summary
</UILabelColumn>
<UIInputColumn>
<InputText class="form-control"
@bind-Value="this.RecordEditorContext.Summary"></InputText>
</UIInputColumn>
<UIValidationColumn>
<ValidationMessage For=@(() => this.RecordEditorContext.Summary) />
</UIValidationColumn>
</UIFormRow>
</UIContainer>
</EditorContent>
<ButtonContent>
<UIContainer>
<UIFormRow>
<UIButtonColumn>
<UIButton CssColor="btn-success"
Show="this.CanSave"
ClickEvent="this.Save">@this.SaveButtonText</UIButton>
<UIButton CssColor="btn-danger"
Show="this.IsDirtyExit"
ClickEvent="this.DirtyExit">Exit Without Saving</UIButton>
<UIButton CssColor="btn-warning"
Show="this.IsDirtyExit"
ClickEvent="this.CancelExit">Cancel Exit</UIButton>
<UIButton CssColor="btn-secondary"
Show="this.CanExit"
ClickEvent="this.Exit">Exit</UIButton>
</UIButtonColumn>
</UIFormRow>
</UIContainer>
</ButtonContent>
</ModalEditForm>
WeatherForecastEditorContext
Note
- The properties exposing the underlying fields in
RecordValues
, referenced all the way back to the RecordCollection
object in the ControllerService. - The property setters set the
EditedValue
on the RecordFieldValue
. - The property setters calling
Validate
and precipitating the validation process throughout the edit components in the form - turning any control red and displaying any validation messages. Validators
defined for properties requiring validation. - The validators loaded through
LoadValidationActions
.
using System;
namespace CEC.Blazor.Editor
{
public class WeatherForecastEditContext : RecordEditContext, IRecordEditContext
{
public DateTime Date
{
get => this.RecordValues.GetEditValue<DateTime>
(DbWeatherForecast.__Date.FieldName);
set
{
this.RecordValues.SetField(DbWeatherForecast.__Date.FieldName, value);
this.Validate();
}
}
public string Summary
{
get => this.RecordValues.GetEditValue<string>
(DbWeatherForecast.__Summary.FieldName);
set
{
this.RecordValues.SetField(DbWeatherForecast.__Summary.FieldName, value);
this.Validate();
}
}
public int TemperatureC
{
get => this.RecordValues.GetEditValue<int>
(DbWeatherForecast.__TemperatureC.FieldName);
set
{
this.RecordValues.SetField
(DbWeatherForecast.__TemperatureC.FieldName, value);
this.Validate();
}
}
public Guid WeatherForecastID
=> this.RecordValues.GetEditValue<Guid>(DbWeatherForecast.__ID.FieldName);
public WeatherForecastEditContext(RecordCollection collection) : base(collection) { }
protected override void LoadValidationActions()
{
this.ValidationActions.Add(ValidateSummary);
this.ValidationActions.Add(ValidateTemperatureC);
this.ValidationActions.Add(ValidateDate);
}
private bool ValidateSummary()
{
return this.Summary.Validation
(DbWeatherForecast.__Summary.FieldName, this, ValidationMessageStore)
.LongerThan(2,
"Your description needs to be a little longer! 3 letters minimum")
.Validate();
}
private bool ValidateDate()
{
return this.Date.Validation
(DbWeatherForecast.__Date.FieldName, this, ValidationMessageStore)
.NotDefault("You must select a date")
.LessThan(DateTime.Now.AddMonths(1), true,
"Date can only be up to 1 month ahead")
.Validate();
}
private bool ValidateTemperatureC()
{
return this.TemperatureC.Validation
(DbWeatherForecast.__TemperatureC.FieldName, this, ValidationMessageStore)
.LessThan(70, "The temperature must be less than 70C")
.GreaterThan(-60, "The temperature must be greater than -60C")
.Validate();
}
}
}
Validators
WeatherForecastEditorContext
uses a custom validation process. It's not rocket science and once you understand the principles, it is very flexible.
Skip down to the next section to see an implementation first before coming back to the abstract
Validator
class. It will make more sense.
using Microsoft.AspNetCore.Components.Forms;
using System.Collections.Generic;
namespace CEC.Blazor.Editor
{
public abstract class Validator<T>
{
public bool IsValid => !Trip;
public bool Trip = false;
public List<string> Messages { get; } = new List<string>();
protected string FieldName { get; set; }
protected T Value { get; set; }
protected string DefaultMessage { get; set; } = "The value failed validation";
protected ValidationMessageStore ValidationMessageStore { get; set; }
protected object Model { get; set; }
public Validator(T value, string fieldName,
object model, ValidationMessageStore validationMessageStore, string message)
{
this.FieldName = fieldName;
this.Value = value;
this.Model = model;
this.ValidationMessageStore = validationMessageStore;
this.DefaultMessage = string.IsNullOrWhiteSpace(message) ?
this.DefaultMessage : message;
}
public virtual bool Validate(string message = null)
{
if (!this.IsValid)
{
message ??= this.DefaultMessage;
if (this.Messages.Count == 0) Messages.Add(message);
var fi = new FieldIdentifier(this.Model, this.FieldName);
this.ValidationMessageStore.Add(fi, this.Messages);
}
return this.IsValid;
}
protected void LogMessage(string message)
{
if (!string.IsNullOrWhiteSpace(message)) Messages.Add(message);
}
}
}
StringValidator
This is a Validator
for string
s.
The key to validators work is the static
class. Validation
is an extension method for string
. When you call Validation
on a string
, it creates a StringValidator
object and returns it. You now have a StringValidator
that you can call a validation method on. Each validation method returns a reference to the validation
object. You can chain as many as you like together with their specific messages. You call the base Validate
method to complete the process. It logs any validation messages into the ValidationMessageStore
, and returns true
or false
. The ValidationMessageStore
is linked back to the EditContext
.
using Microsoft.AspNetCore.Components.Forms;
using System.Text.RegularExpressions;
namespace CEC.Blazor.Editor
{
public static class StringValidatorExtensions
{
public static StringValidator Validation(this string value, string fieldName,
object model, ValidationMessageStore validationMessageStore, string message = null)
{
var validation = new StringValidator
(value, fieldName, model, validationMessageStore, message);
return validation;
}
}
public class StringValidator : Validator<string>
{
public StringValidator(string value, string fieldName, object model,
ValidationMessageStore validationMessageStore, string message) :
base(value, fieldName, model, validationMessageStore, message) { }
public StringValidator LongerThan(int test, string message = null)
{
if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length > test))
{
Trip = true;
LogMessage(message);
}
return this;
}
public StringValidator ShorterThan(int test, string message = null)
{
if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length < test))
{
Trip = true;
LogMessage(message);
}
return this;
}
public StringValidator Matches(string pattern, string message = null)
{
if (!string.IsNullOrWhiteSpace(this.Value))
{
var match = Regex.Match(this.Value, pattern);
if (match.Success && match.Value.Equals(this.Value)) return this;
}
this.Trip = true;
LogMessage(message);
return this;
}
}
}
What Makes All this Work?
If you haven't dug through Microsoft's AspNetCore code on Github investigating how all the edit stuff hangs together, it can be a little baffling.
There's an intricate set of relationships and links within the edit form components that make all this work. The net result is a lot of co-ordinated re-rendering of components to display validation problems, and the right buttons displayed at the right time.
We've covered the initial load process for the form. <UILoader Loaded="this.IsLoaded">
controls when the form gets rendered. Once we have a live EditContext
and interlinked RecordEditorContext
IsLoaded
is true. All the Input controls get rendered and linked into the cascaded EditContext
. The first call to NotifyEditContextChangedAsync
on RecordEditorContext
runs a validation, so the form will display initial validation messages.
Let's suppose we change the Summary. Here's the important code snippet from InputBase
.
protected TValue? CurrentValue
{
get => Value;
set
{
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
if (hasChanged)
{
Value = value;
_ = ValueChanged.InvokeAsync(Value);
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}
}
On exiting the Summary edit control, the InputText
control sets Value = value
(Value
is the property value in RecordEditorContext
), invokes its own ValueChanged
event, followed by calling NotifyFieldChanged
on EditContext
. This precipitates two processes.
RecordEditorContext Property Set
The property set in RecordEditorContext
sets the EditedValue
of the RecordFieldValue
to the new value and then kicks off Validate
which performs a validation. Set, Validate
and subsequent validations are all synchronous operations. They complete before NotifyFieldChanged
is called on EditContext
. This is important: the validation process is complete before EditContext
runs code or kicks off any events. The last action of Validate
is to notify the EditContext
that the Validation State has changed - this.EditContext.NotifyValidationStateChanged()
.
public void NotifyValidationStateChanged()
{
OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty);
}
EditContext
kicks off its own OnValidationStateChanged
event. All the Input controls and ValidationMessage
instances wire into this event as shown below. The input controls check for a validation message relevant to them and change color and render if there is one. ValidationMessage
instances look up their relevant message and display it if they find one.
public override Task SetParametersAsync(ParameterView parameters)
{
....
EditContext.OnValidationStateChanged += _validationStateChangedHandler;
...
}
All the affected fields get notifications of changes and can individually update and re-render themselves.
EditContext.FieldChanged
The Input control passes NotifyFieldChanged
a FieldIdentifier
object - the property name its linked to and the Model
object - in our case RecordEditorContext
. EditContext
updates its internal FieldStates
collection, logging the FieldIdentifier
as IsModified
in the FieldState
object associated with the FieldIdentifier
. Finally, it triggers the OnFieldChanged
event. The code snippet below shows NotifyFieldChanged
in EditContext
.
public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
{
GetOrAddFieldState(fieldIdentifier).IsModified = true;
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}
The final bit of action takes place in the edit form. The local method OnFieldChanged
is wired to EditContext.OnFieldChanged
.
protected void OnFieldChanged(object sender, EventArgs e)
=> this.SetLock();
private void SetLock()
{
this.IsDirtyExit = false;
if (this.RecordEditorContext.IsDirty)
this.Modal.Lock(true);
else
this.Modal.Lock(false);
InvokeAsync(StateHasChanged);
}
SetLock
clears IsDirtyExit
- set if our action was to try to exit a dirty form. We then check if RecordEditorContext
is dirty. In our case, it is so we lock the browser window. We finally render the control which sorts out the buttons. Note that if we had already edited the value once and then changed it back to the original, RecordEditorContext
would be clean. You could exit and the Update button would disappear.
WeatherForecast Viewer
I won't go into detail. You can review the code in the Repo to see how it's put together. It's a simple version of the editor, accessing the controller service record directly for its data, and using a custom InputReadOnlyText
component to display values.
Wrap Up
Much of the infrastructure I've put together here is simplistic. The services and data records should use interfaces and core abstract classes to provide abstraction and implement boilerplate code. A set of articles - Building a Database Application- covers such a framework in more detail. Note the current article set is based on my NetCore 3.1 four month old framework and will be revised very shortly.
What I've covered here is a methodology for editing records. It's not for everybody. It depends on your mindset on data, and the environment you work in. If nothing more, I hope it provokes some thought about how you view and deal with data.
History
- 16th February, 2021: Initial version