The third article in a series describing how to build Blazor edit forms/controls with state management, validation and form locking. This article focuses on form locking.
The Inline Dialog Control
This is the third 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.
This article describes how to build a component that disables links, buttons the URL bar,... : everywhere except the content within the component. While it can't stop the user navigating through the browser controls, it turns on the browser beforeunload
event to force the "Do you really want to leave this site?" dialog box. Everything is implemented with a relatively simple standard Blazor component and a small js
file.
Links
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/.
The example form described at this end of this article can be seen at https://cec-blazor-database.azurewebsites.net//inlinedialogeditor.
Previous articles:
Overview
If you want to see the component in action, go to this page on my demo site. It's a basic mockup to demonstrate the functionality and extends the form used in the last two articles. There's a typical Edit Form with the two extra controls covered in the previous articles:
EditFormState
monitors the edit state of the Model
data ValidationFormState
- a form validator
The key bit of action is hooking up the InlineDialog
control Lock
to the form state. EditFormState
monitors the form state and invokes the EventCallback EditStateChanged
whenever a change takes place. The page EditStateChanged
event handler is registered with the EditFormState.EditStateChanged
and updates _isDirty
whenever the state changes. If EditFormState' is dirty, InlineDialog
` is locked.
@using Blazor.Database.Data
@page "/inlinedialogEditor"
<InlineDialog Lock="this._isDirty" Transparent="false">
<EditForm Model="@Model" OnValidSubmit="@HandleValidSubmit" class="p-3">
<EditFormState @ref="editFormState" EditStateChanged="this.EditStateChanged">
</EditFormState>
<ValidationFormState @ref="validationFormState"></ValidationFormState>
<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" />
<ValidationMessage For="@(() => Model.Date)" />
<label class="form-label">Temp C:</label>
<InputNumber class="form-control" @bind-Value="Model.TemperatureC" />
<ValidationMessage For="@(() => Model.TemperatureC)" />
<label class="form-label">Summary:</label>
<InputText class="form-control" @bind-Value="Model.Summary" />
<ValidationMessage For="@(() => Model.Summary)" />
<div class="mt-2">
<div>Validation Messages:</div>
<ValidationSummary />
</div>
<div class="text-right mt-2">
<button class="btn @btnStateColour" disabled>@btnStateText</button>
<button class="btn @btnValidColour" disabled>@btnValidText</button>
<button class="btn btn-primary" type="submit"
disabled="@_btnSubmitDisabled">Submit</button>
</div>
</EditForm>
</InlineDialog>
}
@code {
protected bool _isDirty = false;
protected bool _isValid => validationFormState?.IsValid ?? true;
protected string btnStateColour => _isDirty ? "btn-danger" : "btn-success";
protected string btnStateText => _isDirty ? "Dirty" : "Clean";
protected string btnValidColour => !_isValid ? "btn-danger" : "btn-success";
protected string btnValidText => !_isValid ? "Invalid" : "Valid";
protected bool _btnSubmitDisabled => !(_isValid && _isDirty);
protected EditFormState editFormState { get; set; }
protected ValidationFormState validationFormState { get; set; }
private WeatherForecast Model = new WeatherForecast()
{
ID = 1,
Date = DateTime.Now,
TemperatureC = 22,
Summary = "Balmy"
};
private void HandleValidSubmit()
{
this.editFormState.UpdateState();
}
private void EditStateChanged(bool editstate)
=> this._isDirty = editstate;
}
InLineDialog
Let's look at the Parameters and public
Properties first.
- We capture added attributes, though we only use
class
. Cascade
turns on/off parameter cascading of this
i.e., the instance of InlineDialog
. Default is true
. Transparent
sets the background to either transparent or translucent. The demo is set to transluscent so you can see it switch in and out. ChildContent
is the content between <InlineDialog>
and </InlineDialog>
. IsLocked
is a read only Property for checking the component state.
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>
AdditionalAttributes { get; set; } = new Dictionary<string, object>();
[Parameter] public bool Cascade { get; set; } = true;
[Parameter] public bool Transparent { get; set; } = true;
[Parameter] public RenderFragment ChildContent { get; set; }
public bool IsLocked => this._isLocked;
The private
properties:
- Inject
IJSRuntime
to give access to the JavaScript Interop and set/unset the browser BeforeUnload
event. CssClass
builds the HTML class
attribute for the component, combining any entered classes with those built by the component. - The CSS properties define the various CSS options for
class
. _isLocked
in the private
field for controlling lock state.
[Inject] private IJSRuntime _js { get; set; }
private string CssClass => (AdditionalAttributes != null &&
AdditionalAttributes.TryGetValue("class", out var obj))
? $"{this.frontcss}
{ Convert.ToString(obj, CultureInfo.InvariantCulture)}"
: this.frontcss;
private string backcss = string.Empty;
private string frontcss = string.Empty;
private string _backcss => this.Transparent ? "back-block-transparent" : "back-block";
private string _frontcss => this.Transparent ? "fore-block-transparent" : "fore-block";
private string __backcss => string.Empty;
private string __frontcss => string.Empty;
private bool _isLocked;
There are two public
methods: Lock
and Unlock
. These change the CSS classes. SetPageExitCheck
interfaces with the JavaScript functions to add or remove the beforeunload
event on Window
. The code is shown below:
public void Lock()
{
this._isLocked = true;
this.backcss = this._backcss;
this.frontcss = this._frontcss;
this.SetPageExitCheck(true);
this.InvokeAsync(StateHasChanged);
}
public void Unlock()
{
this._isLocked = false;
this.backcss = this.__backcss;
this.frontcss = this.__frontcss;
this.SetPageExitCheck(false);
this.InvokeAsync(StateHasChanged);
}
private void SetPageExitCheck(bool action)
=> _js.InvokeAsync<bool>("cecblazor_setEditorExitCheck", action);
The JavaScript in site.js:
window.cecblazor_setEditorExitCheck = function (show) {
if (show) {
window.addEventListener("beforeunload", cecblazor_showExitDialog);
}
else {
window.removeEventListener("beforeunload", cecblazor_showExitDialog);
}
}
window.cecblazor_showExitDialog = function (event) {
event.preventDefault();
event.returnValue = "There are unsaved changes on this page. Do you want to leave?";
}
Moving on to the Razor for the component:
- We add a
div
with the CSS class _backcss
: this is either back-block-transparent or back-block when Locked
or empty when Unlocked
. - We add a
div
with the Css class _frontcss
: this is either fore-block-transparent or fore-block when Locked
or empty when Unlocked
combined with any class
attribute value we have added to the component. - We cascade
this
if Cascade
is true
.
<div class="@this.backcss"></div>
<div class="@this.CssClass">
@if (this.Cascade)
{
<CascadingValue Value="this">
@this.ChildContent
</CascadingValue>
}
else
{
@this.ChildContent
}
</div>
Moving on to the component CSS, which is where the magic happens. We implement a similar CSS technique to that used in modal dialogs, adding a transparent or translucent layer over the page content to block
content below the layer, and place the contents of InlineDialog
in front of that layer. If you use a lot on z-index layers in your application, you may need to tweak the Z-index to make sure it sits on top.
div.back-block {
display: block;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100;
height: 100;
overflow: auto;
background-color: RGBA(224, 224, 224, 0.4);
}
div.back-block-transparent {
display: block;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto; * Enable scroll if needed */
background-color: transparent;
}
div.fore-block-transparent {
display: block;
position: relative;
z-index: 2;
}
div.fore-block {
display: block;
position: relative;
z-index: 2;
background-color: RGB(255, 255, 255);
}
Wrap Up
This solution uses the same techniques used by modal dialogs in placing a barrier between the controls on the page and the contents of the control. It's an in place modal dialog. Lock
inserts the barrier and Unlock
removes it. We add the JavaScript Interop to turn on add off the beforeunload
event on the browser. Choose between a transparent or transluscent layer, or code your own CSS.
Having developed many solutions to solve this problem, and written articles about them, I'm a little flabbergasted to finally find a solution that's this easy. The best solutions are always the simplest!
If you are reading this well into the future, the most recent version of this article will be here.
History
- 17th March, 2021: Initial version