EditContext
has no mechanism to store the initial state of model properties, and therefore doesn't track true state. It simply registers that a value in a InputBase
field has changed. The value could change back to it's original and EditContext
would still register it as modified.
This article demonstrates how to build an Edit State tracker for the Blazor EditContext
.
Note: this implementation only tracks flat single layer objects. If you want to track nested objects, you need to build your own edit context.
The screenshot below shows a dirty invalid form where I've clicked on the browser refresh button to try and exit the dirty form.
You can find the code here Blazr.EditStateTracker in a Blazor Server application.
EditContext
maintains an internal dictionary of Edit States defined as FieldIdentifier
/FieldState
pairs.
FieldIdentifier
is defined as:
public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
{
public object Model { get; }
public string FieldName { get; }
//....
}
And FieldState
as:
internal sealed class FieldState
{
public bool IsModified {get; set;}
//...
}
All InputBase
controls call EditContext.NotifyFieldChanged
when they update. NotifyFieldChanged
adds or updates an entry in the Edit State dictionary, and raises the OnFieldChanged
event.
public event EventHandler<FieldChangedEventArgs>? OnFieldChanged;
public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
{
GetOrAddFieldState(fieldIdentifier).IsModified = true;
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}
internal FieldState GetOrAddFieldState(in FieldIdentifier fieldIdentifier)
{
if (!_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
state = new FieldState(fieldIdentifier);
_fieldStates.Add(fieldIdentifier, state);
}
return state;
}
Field state is used by the InputBase
components through some rather complicated Css Provider code to get the css formatting to the component. Green for modified and valid, Red for invalid. The code snippets from the various classes are shown below for reference.
The code that gets the Css for InputBase
.
protected string CssClass
{
get
{
var fieldClass = EditContext?.FieldCssClass(FieldIdentifier);
return AttributeUtilities.CombineClassNames(AdditionalAttributes, fieldClass) ?? string.Empty;
}
}
The FieldCssClass
extention method defined in EditContextFieldClassExtensions
public static string FieldCssClass(this EditContext editContext, in FieldIdentifier fieldIdentifier)
{
var provider = editContext.Properties.TryGetValue(FieldCssClassProviderKey, out var customProvider)
? (FieldCssClassProvider)customProvider
: FieldCssClassProvider.Instance;
return provider.GetFieldCssClass(editContext, fieldIdentifier);
}
And the default FieldCssClassProvider
provider.
public class FieldCssClassProvider
{
internal static readonly FieldCssClassProvider Instance = new FieldCssClassProvider();
public virtual string GetFieldCssClass(EditContext editContext, in FieldIdentifier fieldIdentifier)
{
var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any();
if (editContext.IsModified(fieldIdentifier))
{
return isValid ? "modified valid" : "modified invalid";
}
else
{
return isValid ? "valid" : "invalid";
}
}
}
The implementation consists of three objects:
EditStateProperty
- a class to hold state data for a property.BlazrEditStateStore
- a collection class to hold the trackedEditContext.Model
true state.BlazrEditStateTracker
- a component to embed inEditForm
that wires everything up and sorts inconsistencies inEditContext
.
A fourth object is implemented in the Blazr.Core library.
TrackStateAttribute
- a custom attribute to identify properties to track.
It uses a WeatherForecastEditContext
object to edit a WeatherForecast
. This separates out editing from the data object.
The custom attribute to identify tracked properties. It does nothing more that identifies the properties to track.
public class TrackStateAttribute : Attribute {}
Applied to WeatherForecastEditContext
:
public class WeatherForecastEditContext
{
[TrackState] public DateOnly Date { get; set; }
[TrackState] public int TemperatureC { get; set; }
[TrackState, Required] public string? Summary { get; set; }
//...
}
BlazrEditStateProperty
tracks the state of individual properties.
public class EditStateProperty
{
public string Name { get; private set; }
public object? BaseValue { get; private set; }
public object? CurrentValue { get; private set; }
public EditStateProperty(string name, object? value)
{
Name = name;
BaseValue = value;
CurrentValue= value;
}
public void Set(object? value)
=> CurrentValue = value;
public void Reset()
=> CurrentValue = this.BaseValue;
public bool IsDirty => !BaseValue?.Equals(CurrentValue) ?? CurrentValue is not null;
}
BlazrEditStateStore
is the collection object that maintains the property state list. The class requires the EditContext
in ctor and tracks EditContext.Model
. It obtains the trackable properties through reflection and builds a list of BlazrEditStateProperty
objects.
Update
updates the property values and manages the true field state on EditContext
.
IsDirty
provides the object or an individual property state.
public class BlazrEditStateStore
{
private object _model = new();
private List<EditStateProperty> _properties = new();
private EditContext _editContext;
public event EventHandler<FieldChangedEventArgs>? StoreUpdated;
public BlazrEditStateStore(EditContext context)
{
_editContext = context;
_model = context.Model;
var props = _model.GetType().GetProperties().Where(
prop => Attribute.IsDefined(prop, typeof(TrackStateAttribute)));
foreach (var prop in props)
{
_properties.Add(new(prop.Name, prop.GetValue(_model)));
}
}
public void Update(FieldChangedEventArgs e)
{
var property = _properties.FirstOrDefault(item => item.Name.Equals(e.FieldIdentifier.FieldName));
if (property != null)
{
var propInfo = e.FieldIdentifier.Model.GetType().GetProperty(e.FieldIdentifier.FieldName);
if (propInfo != null)
{
var value = propInfo.GetValue(e.FieldIdentifier.Model);
property.Set(value);
// If the value is clean clear out the modified setting in the Edit Context
if (!IsDirty(e.FieldIdentifier.FieldName))
_editContext.MarkAsUnmodified(e.FieldIdentifier);
this.StoreUpdated?.Invoke(this, e);
}
}
}
public void Reset()
{
foreach (var prop in _properties)
{
prop.Reset();
_editContext.MarkAsUnmodified(new(_editContext, prop.Name));
}
}
public bool IsDirty(string fieldName)
=> _properties.FirstOrDefault(item => item.Name.Equals(fieldName))?.IsDirty ?? false;
public bool IsDirty()
=> _properties.Any(item => item.IsDirty);
}
BlazrEditStateTracker
is a component that plugs everything together in EditForm
.
The component:
- Captures the
EditContext
. - Creates an
BlazrEditStateStore
. - Hooks up a handler to the
OnFieldChanged
event ofEditContext
. - Adds a
BlazrEditStateStore
instance reference to theEditContext.Properties
collection.
OnFieldChanged
calls Update
on the store, and if the edit state has changed invokes EditStateChanged
.
LockNavigation
enables/disables navigation locking. The UI adds the NavigationLock
component and wires it up if required.
Note:
BlazrEditStateTracker
inherits fromBlazrControlBase
. This component is part of theBlazr.BaseComponents
Nuget library.
@inherits BlazrControlBase
@implements IDisposable
@using Blazr.BaseComponents;
@if (this.LockNavigation)
{
<NavigationLock OnBeforeInternalNavigation=this.OnLocationChanged ConfirmExternalNavigation=_isDirty />
}
@code {
[CascadingParameter] private EditContext _editContext { get; set; } = default!;
[Parameter] public bool LockNavigation { get; set; }
public const string EditStateStoreName = "EditStateStore";
private BlazrEditStateStore _store = default!;
private bool _currentIsDirty = false;
private bool _isDirty => _store.IsDirty();
protected override Task OnParametersSetAsync()
{
if(this.NotInitialized)
{
ArgumentNullException.ThrowIfNull(_editContext);
_store = new(_editContext);
ArgumentNullException.ThrowIfNull(_store);
_editContext.Properties[EditStateStoreName] = _store;
_editContext.OnFieldChanged += OnFieldChanged;
}
return Task.CompletedTask;
}
private void OnFieldChanged(object? sender, FieldChangedEventArgs e)
{
_store.Update(e);
if (_isDirty != _currentIsDirty)
_currentIsDirty = _isDirty;
this.StateHasChanged();
}
private void OnLocationChanged(LocationChangingContext context)
{
if (_isDirty)
context.PreventNavigation();
}
public void Dispose()
=> _editContext.OnFieldChanged -= OnFieldChanged;
}
The BlazrEditStateTracker
registers the instance of the BlazrEditStateStore
with the EditContext
. This can be accessed directly through the EditContext
by a set of EditContext
extension methods provided in the namespace.
public static class BlazrEditContextExtensions
{
public const string EditStateStoreName = "EditStateStore";
public static bool GetEditState(this EditContext editContext)
{
BlazrEditStateStore? store = null;
if (editContext.Properties.TryGetValue(EditStateStoreName, out object? value))
store = value as BlazrEditStateStore;
return store?.IsDirty() ?? false;
}
public static BlazrEditStateStore? GetStateStore(this EditContext editContext)
{
BlazrEditStateStore? store = null;
if (editContext.Properties.TryGetValue(EditStateStoreName, out object? value))
store = value as BlazrEditStateStore;
return store;
}
public static bool TryGetStateStore(this EditContext editContext, [NotNullWhen(true)] out BlazrEditStateStore? store)
{
store = null;
if (editContext.Properties.TryGetValue(EditStateStoreName, out object? value))
store = value as BlazrEditStateStore;
return store is not null;
}
public static bool IsFieldValid(this EditContext editContext, FieldIdentifier? fieldIdentifier)
{
var messages = editContext.GetValidationMessages(fieldIdentifier ?? new());
return messages is null || messages.Count() == 0;
}
public static bool IsFieldValid(this EditContext editContext, Expression<Func<string>>? expression)
{
if (TryGetFieldIdentifier(expression, out var fieldIdentifier))
return editContext.GetValidationMessages(fieldIdentifier ?? new()) is null;
return false;
}
private static bool TryGetFieldIdentifier(Expression<Func<string>>? expression, [NotNullWhen(true)] out FieldIdentifier? fi)
{
fi = null;
if (expression is null)
return false;
fi = FieldIdentifier.Create(expression);
return fi is not null;
}
}
This is a typical edit form. Note:
- The
BlazrEditStateTracker
component added to theEditForm
. - Getting the
BlazrEditStateStore
from the EditContext. - Tracking edit state through the
BlazrEditStateStore
instance. - Validation is included to show it works.
- There's a mock save to demostrate how to implement it.
- The
NavigationManager
andNavigationLock
implementation to control navigation. - The Exit button change when the form is dirty and the use of the standard browser confirm dialog to confirm exit.
@page "/"
@inject NavigationManager NavManager
@inject IJSRuntime Js
@implements IDisposable
<PageTitle>Index</PageTitle>
<Alert @bind-Message="_alertMessage" />
<EditForm EditContext=_editContext>
<DataAnnotationsValidator />
<BlazrEditStateTracker />
<div class="mb-3">
<label class="form-label">Date</label>
<InputDate class="form-control" @bind-Value=_model.Date />
</div>
<div class="mb-3">
<label class="form-label">Temperature °C</label>
<InputNumber class="form-control" @bind-Value=_model.TemperatureC />
</div>
<div class="mb-3">
<label class="form-label">Summary</label>
<InputSelect class="form-select" @bind-Value=_model.Summary>
@if (_model.Summary is null)
{
<option disabled selected value=""> -- Choose a Summary --</option>
}
@foreach (var summary in Summaries)
{
<option value="@summary">@summary</option>
}
</InputSelect>
<ValidationMessage For="() => _model.Summary" />
</div>
<div class="mb-3 text-end">
<button disabled="@_isClean" type="button" class="btn btn-success" @onclick="this.SaveAsync">Submit</button>
<button hidden="@_isDirty" type="button" class="btn btn-dark" @onclick="this.Exit">Exit</button>
<button hidden="@_isClean" type="button" class="btn btn-danger" @onclick="this.ExitWithoutSave">Exit without Saving</button>
</div>
</EditForm>
<NavigationLock ConfirmExternalNavigation=_isDirty />
<div class="bg-dark text-white m-4 p-2">
<pre>Date : @_model.Date</pre>
<pre>Temperature °C : @_model.TemperatureC</pre>
<pre>Summary: @_model.Summary</pre>
<pre>State: @(_isDirty ? "Dirty" : "Clean")</pre>
</div>
@code {
private WeatherForecastEditContext _model = new(new() { Date = DateOnly.FromDateTime(DateTime.Now), TemperatureC = 10 });
private EditContext? _editContext;
private BlazrEditStateStore? _editStateStore => _editContext?.GetStateStore();
private bool _isDirty => _editStateStore?.IsDirty() ?? false;
private bool _isClean => !_isDirty;
private IDisposable? _disposeMe;
protected override void OnInitialized()
{
// Sets up the Edit Context
_editContext = new EditContext(_model);
// set up the handler to handle attempted navigation events
_disposeMe = this.NavManager.RegisterLocationChangingHandler(this.OnLocationChanging);
}
private ValueTask OnLocationChanging(LocationChangingContext context)
{
// Prevent navigation if the edit context is dirty
if (_isDirty)
context.PreventNavigation();
return ValueTask.CompletedTask;
}
private async Task SaveAsync()
{
// If the edit context isn't dirty, then there's nothing to do
if (!_isDirty)
return;
// Validate the form
if (_editContext?.Validate() ?? false)
{
// mock an async call to the data pipeline to save the record
var updatedRecord = _model.AsRecord;
await Task.Delay(100);
// Error handling code here
// This will reset the edit contexts and the EditStateTracker
_model = new(updatedRecord);
_editContext = new EditContext(_model);
}
}
private void Exit()
{
// Belt and braces check before exiting
if (_isClean)
this.NavManager.NavigateTo("/counter");
}
private async Task ExitWithoutSave()
{
// Confirm with a Js Confirm popup
bool confirmed = await Js.InvokeAsync<bool>("confirm", "Are you sure you want to exit without saving?");
if (confirmed)
{
// Reset the EditStateStore - it will now be clean, so we can exit
_editStateStore?.Reset();
this.NavManager.NavigateTo("/counter");
}
}
public void Dispose()
{
// dispose the RegisterLocationChangingHandler correctly
_disposeMe?.Dispose();
}
private List<string> Summaries = new() { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
}
There is no mechanism for refreshing or resetting the state because EditContext
has no mechanism to reset itself.
In the form SaveAsync
creates a new EditContext
based on the saved model. EditForm
detects the new EditContext
, and forces the Renderer to destroy the old components and rebuild it's content.
Here's the relevant code from EditForm
.
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
Debug.Assert(_editContext != null);
// If _editContext changes, tear down and recreate all descendants.
// This is so we can safely use the IsFixed optimization on CascadingValue,
// optimizing for the common case where _editContext never changes.
builder.OpenRegion(_editContext.GetHashCode());
builder.OpenElement(0, "form");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "onsubmit", _handleSubmitDelegate);
builder.OpenComponent<CascadingValue<EditContext>>(3);
builder.AddComponentParameter(4, "IsFixed", true);
builder.AddComponentParameter(5, "Value", _editContext);
builder.AddComponentParameter(6, "ChildContent", ChildContent?.Invoke(_editContext));
builder.CloseComponent();
builder.CloseElement();
builder.CloseRegion();
}