The first article in a series looking at how to build Blazor edit forms/controls with state management, validation and form locking. This article focuses on edit state.
Overview - The Blazor EditFormState Control
This is the first in a series of articles describing a set of useful Blazor Edit controls that solve some of the current shortcomings in the out-of-the-box edit experience without the need to buy expensive toolkits.
Code and Examples
The repository contains a project that implements the controls for all the articles in this series. You can find it here.
The example site is at https://cec-blazor-database.azurewebsites.net/.
You can see the test form described later at https://cec-blazor-database.azurewebsites.net//testeditor.
The Repo is a Work In Progress for future articles so will change and develop.
The Blazor Edit Setting
To begin, let's look at the current form controls and how they work together. A classic form looks something like this:
<EditForm Model="@exampleModel" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText id="name" @bind-Value="exampleModel.Name" />
<ValidationMessage For="@(() => exampleModel.Name)" />
<button type="submit">Submit</button>
</EditForm>
EditForm
EditForm
is the overall wrapper. It:
- Creates the html
Form
context. - Hooks up any
Submit
buttons - i.e., buttons with their type
set to submit
within the form. - Creates/manages the
EditContext
. - Cascades the
EditContext
. All controls within EditForm
capture and use it in one way or another. - Provides callback delegates to the parent control for the submission process -
OnSubmit
, OnValidSubmit
and OnInvalidSubmit
.
EditContext
EditContext
is the class at the heart of the edit process, providing overall management. The data class it operates on is the model
: defined as an object
type. It can be any object, but in practice will be a data class of some type. The only pre-requisite is that fields used in the form are declared as public
read/write properties.
The EditContext
is either:
- passed directly to
EditForm
as the EditContext
parameter, - or the object instance of the model is set as the
Model
parameter and EditForm
creates an EditContext
instance from it.
An important point to remember is don't change out the EditContext
model for another object once you've created it. While it may be possible, it's not advisable. If the model needs to be changed out, code to refresh the whole form: better safe than ...!
FieldIdentifier
The FieldIdentifier
class represents a partial "serialization" of a model property. The EditContext
tracks and identifies individual properties through their FieldIdentifier
. Model
is the object that owns the property and FieldName
is the property name obtained through reflection.
Input Controls
InputText
and InputNumber
and the other InputBase
controls capture the cascaded EditContext
. Any value changes are pushed up to EditContext
by calling NotifyFieldChanged
with their FieldIdentifier
.
EditContext Revisited
The EditContext
maintains a FieldIdentifier
list internally. FieldIdentifier
objects are passed around in various methods and events to identify specific fields. Calls to NotifyFieldChanged
add FieldIdentifier
objects to the list. EditContext
triggers OnFieldChanged
whenever NotifyFieldChanged
is called.
IsModified
provides access to the state of the list or an individual FieldIdentifier
. MarkAsUnmodified
resets an individual FieldIdentifier
or all the FieldIdentifiers
in the collection.
EditContext
also contains the functionality to manage validation, but not actually do it. We'll look at the validation process in the next article.
EditFormState Control
The EditFormState
control, like all edit form controls, captures the cascaded EditState
. What it does is:
- Builds a list of
public
properties exposed by the Model
and maintains the edit state of each - an equality check of the original value against the edited value. - Updates the state on each change in a field value.
- Exposes the state through a
readonly
property. - Provides an
EventCallback
delegate which is triggered whenever the edit state is updated.
Before we look at the control, let's look at the Model - in our case, WeatherForecast
- and some of the supporting classes.
WeatherForecast
WeatherForecast
is a typical data class.
- Each field is declared as a property with default values.
Validate
implements IValidation
. Ignore this for the moment, we'll look at validation in the next article. I've shown it as you'll see it in the Repo code.
public class WeatherForecast : IValidation
{
public int ID { get; set; } = -1;
public DateTime Date { get; set; } = DateTime.Now;
public int TemperatureC { get; set; } = 0;
[NotMapped] public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; } = string.Empty;
public bool Validate(ValidationMessageStore validationMessageStore, string fieldname, object model = null)
{
....
}
}
EditField
EditField
is our class for "serializing" out properties from the model.
- The base fields are records - they can only be set on initialization.
EditedValue
carries the current value of the field. IsDirty
tests equality between Value
and EditedValue
.
public class EditField
{
public string FieldName { get; init; }
public Guid GUID { get; init; }
public object Value { get; init; }
public object Model { get; init; }
public 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 EditField(object model, string fieldName, object value)
{
this.Model = model;
this.FieldName = fieldName;
this.Value = value;
this.EditedValue = value;
this.GUID = Guid.NewGuid();
}
public void Reset()
=> this.EditedValue = this.Value;
}
EditFieldCollection
EditFieldCollection
is an IEnumerable
collection of EditField
. The class provides a set of controlled setters and getters for the collection and implements the necessary methods for the IEnumerable
interface. It also provides an IsDirty
property to expose the state of the collection.
public class EditFieldCollection : IEnumerable
{
private List<EditField> _items = new List<EditField>();
public int Count => _items.Count;
public Action<bool> FieldValueChanged;
public bool IsDirty => _items.Any(item => item.IsDirty);
public void Clear()
=> _items.Clear();
public void ResetValues()
=> _items.ForEach(item => item.Reset());
public IEnumerator GetEnumerator()
=> new EditFieldCollectionEnumerator(_items);
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 TryGet<T>(string FieldName, out T value)
{
value = default;
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x.Value is T t) value = t;
return x.Value != default;
}
public bool TryGetEditValue<T>(string FieldName, out T value)
{
value = default;
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x.EditedValue is T t) value = t;
return x.EditedValue != default;
}
public bool HasField(EditField field)
=> this.HasField(field.FieldName);
public bool HasField(string FieldName)
{
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x is null | x == default) return false;
return true;
}
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);
return true;
}
return false;
}
public bool AddField(object model, string fieldName, object value)
{
this._items.Add(new EditField(model, fieldName, value));
return true;
}
The Enumerator
support class.
public class EditFieldCollectionEnumerator : IEnumerator
{
private List<EditField> _items = new List<EditField>();
private int _cursor;
object IEnumerator.Current
{
get
{
if ((_cursor < 0) || (_cursor == _items.Count))
throw new InvalidOperationException();
return _items[_cursor];
}
}
public EditFieldCollectionEnumerator(List<EditField> items)
{
this._items = items;
_cursor = -1;
}
void IEnumerator.Reset()
=> _cursor = -1;
bool IEnumerator.MoveNext()
{
if (_cursor < _items.Count)
_cursor++;
return (!(_cursor == _items.Count));
}
}
}
Now we've seen the support classes, On to the main control.
EditFormState
EditFormState
is declared as a component and implements IDisposable
.
public class EditFormState : ComponentBase, IDisposable
The properties are:
- Pick up the
EditContext
from the cascade. - Provide a
EditStateChanged
callback to the parent control to tell it the edit state has changed. - Provide a readonly Property
IsDirty
for controls using @ref
to check the control state. EditFields
is the internal EditFieldCollection
we populate and use to manage the edit state. disposedValue
is part of the IDisposable
implementation.
[CascadingParameter] public EditContext EditContext { get; set; }
[Parameter] public EventCallback<bool> EditStateChanged { get; set; }
public bool IsDirty => EditFields?.IsDirty ?? false;
private EditFieldCollection EditFields = new EditFieldCollection();
private bool disposedValue;
When the component initializes, it captures the Model
properties and populates EditFields
with the initial data. The last step is to wire up to EditContext.OnFieldChanged
to FieldChanged
, so FieldChanged
gets called whenever a field value changes.
protected override Task OnInitializedAsync()
{
Debug.Assert(this.EditContext != null);
if (this.EditContext != null)
{
this.GetEditFields();
this.EditContext.OnFieldChanged += FieldChanged;
}
return Task.CompletedTask;
}
protected void GetEditFields()
{
this.EditFields.Clear();
var model = this.EditContext.Model;
var props = model.GetType().GetProperties();
foreach (var prop in props)
{
var value = prop.GetValue(model);
EditFields.AddField(model, prop.Name, value);
}
}
The FieldChanged
event handler looks up the EditField
from EditFields
and sets its EditedValue
by calling SetField
. It then triggers the EditStateChanged
callback, with the current dirty state.
private void FieldChanged(object sender, FieldChangedEventArgs e)
{
var prop = e.FieldIdentifier.Model.GetType().GetProperty(e.FieldIdentifier.FieldName);
if (prop != null)
{
var value = prop.GetValue(e.FieldIdentifier.Model);
EditFields.SetField(e.FieldIdentifier.FieldName, value);
this.EditStateChanged.InvokeAsync(EditFields?.IsDirty ?? false);
}
}
Finally, we have some utility methods and IDisposable
implementation.
public void UpdateState()
{
this.GetEditFields();
this.EditStateChanged.InvokeAsync(EditFields?.IsDirty ?? false);
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
if (this.EditContext != null)
this.EditContext.OnFieldChanged -= this.FieldChanged;
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
A Simple Implementation
To test the component, here's a simple test page.
Change the temperature up and down and you should see the State button change colour and Text.
You can see this example in action at https://cec-blazor-database.azurewebsites.net/editstateeditor.
@using Blazor.Database.Data
@page "/test"
<EditForm Model="@Model" OnValidSubmit="@HandleValidSubmit">
<EditFormState @ref="editFormState" EditStateChanged="this.EditStateChanged">
</EditFormState>
<label class="form-label">ID:</label> <InputNumber class="form-control"
@bind-Value="Model.ID" />
<label class="form-label">Date:</label> <InputDate class="form-control"
@bind-Value="Model.Date" />
<label class="form-label">Temp C:</label> <InputNumber class="form-control"
@bind-Value="Model.TemperatureC" />
<label class="form-label">Summary:</label> <InputText class="form-control"
@bind-Value="Model.Summary" />
<div class="text-right mt-2">
<button class="btn @btncolour">@btntext</button>
<button class="btn btn-primary" type="submit">Submit</button>
</div>
<div>
</div>
</EditForm>
@code {
protected bool _isDirty = false;
protected string btncolour => _isDirty ? "btn-danger" : "btn-success";
protected string btntext => _isDirty ? "Dirty" : "Clean";
protected EditFormState editFormState { get; set; }
private WeatherForecast Model = new WeatherForecast()
{
ID = 1,
Date = DateTime.Now,
TemperatureC = 22,
Summary = <span class="pl-pds">"Balmy"
};
private void HandleValidSubmit()
{
this.editFormState.UpdateState();
}
private void EditStateChanged(bool editstate)
=> this._isDirty = editstate;
}
Wrap Up
While the real benefits of this control may not be immediately obvious if you haven't implemented such functionality before, we'll use it in the follow on articles to build an editor form. The next article looks at the validation process and how to build a simple custom validator. The third article looks at form locking, using this control as part of the process.
If you've found this article well into the future, the latest version will be available here.
History
- 16th March, 2021: Initial version