Disabling the submit button in Blazor with Validation

Disabling the Submit button in Blazor Validation

Button

Blazor now has built-in validation and good documentation here. Some people want to disable the submit button until all fields have been successfully validated. How?

If you want to follow along with the code right in front of you, you can download the repo here.

One more thing: I've written this using Blazor Preview 6, maybe the future will make this a lot easier...

Attempt #1

So I have a project where I ask a customer to register, and I am using Blazor validation with EditForm and InputText components.

<h1>@Title</h1>

<EditForm @ref="editForm"
          OnValidSubmit="@Submit"
          Model="@Customer">

  <DataAnnotationsValidator />

  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label" for="FirstName">Name:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Name" />
      <ValidationMessage For="@(() => Customer.Name)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="LastName">Street:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Street" />
      <ValidationMessage For="@(() => Customer.Street)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="Birthday">City:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.City" />
      <ValidationMessage For="@(() => Customer.City)" />
    </div>
  </div>
  <div class="form-group mb-0">
    <button disabled="@isInvalid" type="submit" id="BtnRegister" class="@ButtonClass">
      @ButtonTitle
    </button>
  </div>
</EditForm>

@isInvalid.ToJson()

@code {

[Parameter]
protected string Title { get; set; }

[Parameter]
protected string ButtonTitle { get; set; }

[Parameter]
protected string ButtonClass { get; set; }

[Parameter]
protected Customer Customer { get; set; }

[Parameter]
protected EventCallback<Customer> CustomerChanged { get; set; }

[Parameter]
protected EventCallback Submit { get; set; }

private EditForm editForm;

private bool isInvalid = false;

protected  override void OnInit()
{
    if( editForm?.EditContext == null)
  {
    Console.WriteLine("??? EditContext is null???");
  } else
  {
    isInvalid = editForm.EditContext.Validate();
  }
}

}

My index component has a customer and uses this CustomerEntry component to edit.

@page "/"
<!-- Customer entry -->
  <CustomerEntry1 Title="Please enter your details below"
                  ButtonTitle="Checkout"
                  ButtonClass="btn btn-primary"
                  Customer="@Customer"
                  Submit="@PlaceOrder" />
<!-- End customer entry -->

<hr />

<div>
  @Customer.ToJson()
</div>

@code {

  private Customer Customer { get; set; } = new Customer();

  private void PlaceOrder()
  {
    Console.WriteLine("Placing order");
  }

}

As my first attempt, I am trying to access the EditContext from the EditForm component. Then it becomes a simple matter of calling Validate on the EditContext. However, it is null. Apparently, when you pass a Model to the EditForm the internal EditContext does not get exposed...

Is this a bug, or a feature? Hmmm...

Attempt #2

Let us try creating the EditContext ourself. Here is CustomerEntry again.

<h1>@Title</h1>

<EditForm OnValidSubmit="@Submit"
          EditContext="@editContext">

  <DataAnnotationsValidator />

  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label" for="FirstName">Name:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Name" />
      <ValidationMessage For="@(() => Customer.Name)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="LastName">Street:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Street" />
      <ValidationMessage For="@(() => Customer.Street)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="Birthday">City:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.City" />
      <ValidationMessage For="@(() => Customer.City)" />
    </div>
  </div>
  <div class="form-group mb-0">
    <button disabled="@isInvalid" type="submit" id="BtnRegister" class="@ButtonClass">
      @ButtonTitle
    </button>
  </div>
</EditForm>

@isInvalid.ToJson()

@code {

  [Parameter]
  protected string Title { get; set; }

  [Parameter]
  protected string ButtonTitle { get; set; }

  [Parameter]
  protected string ButtonClass { get; set; }

  [Parameter]
  protected Customer Customer { get; set; }

  [Parameter]
  protected EventCallback<Customer> CustomerChanged { get; set; }

  [Parameter]
  protected EventCallback Submit { get; set; }

  private EditContext editContext;

  private bool isInvalid = true;

  protected override void OnInit()
  {
    this.editContext = new EditContext(this.Customer);
    this.editContext.OnFieldChanged += (sender, e) =>
    {
      isInvalid = !editContext.Validate();
      this.StateHasChanged();
    };

    // isInvalid = !editContext.Validate();
  }
}

Hey! This works. If you load this form, the submit button is disabled, and only disables when you complete the form without validation issues. If you want the button to be enabled in the beginning, simply uncomment the last line in OnInit.

Attempt #3

But what if you do want to use the Model property? Well, then you can capture the EditContext using another component. Let us start with CustomerEntry again.

<h1>@Title</h1>

<EditForm OnValidSubmit="@Submit"
          Model="@Customer">
  <DataAnnotationsValidator />

  <InputWatcher @ref="inputWatcher" FieldChanged="@FieldChanged" />

  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label" for="FirstName">Name:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Name" />
      <ValidationMessage For="@(() => Customer.Name)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="LastName">Street:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Street" />
      <ValidationMessage For="@(() => Customer.Street)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="Birthday">City:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.City" />
      <ValidationMessage For="@(() => Customer.City)" />
    </div>
  </div>
  <div class="form-group mb-0">
    <button disabled="@isInvalid" type="submit" id="BtnRegister" class="@ButtonClass">
      @ButtonTitle
    </button>
  </div>
</EditForm>

<div>
  @Customer.ToJson()
</div>
<div>
  @isInvalid.ToJson()
</div>

@code {

[Parameter]
protected string Title { get; set; }

[Parameter]
protected string ButtonTitle { get; set; }

[Parameter]
protected string ButtonClass { get; set; }

[Parameter]
protected Customer Customer { get; set; }

[Parameter]
protected EventCallback Submit { get; set; }

private InputWatcher inputWatcher;
private bool isInvalid = false;

private void FieldChanged(string fieldName)
{
  Console.WriteLine($"*** {Customer.Name}");
  isInvalid = !inputWatcher.Validate();
}

}

Notice the InputWatcher? This is it:

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;

namespace BlazorValidation.Client
{

  public class InputWatcher : ComponentBase
  {
    private EditContext editContext;

    [CascadingParameter]
    protected EditContext EditContext
    {
      get => editContext;
      set
      {
        editContext = value;
        EditContext.OnFieldChanged += async (sender, e) =>
        {
          await FieldChanged.InvokeAsync(e.FieldIdentifier.FieldName);
        };
      }
    }

    [Parameter]
    protected EventCallback<string> FieldChanged { get; set; }

    public bool Validate()
    => EditContext?.Validate() ?? false;

  }
}

The InputWatcher fetches the EditContext as a cascading property and exposes the Validate method. You can also bind to the FieldChanged event where you call the Validate method. It works. Period.

But there is one more thing I want to bring to your attention. Inside the CustomerEntry component the customer updates (you can see the content of the Customer below the submit button as simple JSON). But if you look at the Customer in the Index component you will see that it only updates when you submit. What if there is another component visualizing customer (as the JSON output below the horizontal ruler).

Attempt #4

Here is the last CustomerEntry component of this blog post

<h1>@Title</h1>

<EditForm OnValidSubmit="@Submit"
          Model="@Customer">
  <DataAnnotationsValidator />

  <InputWatcher @ref="inputWatcher" FieldChanged="@FieldChanged" />

  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label" for="FirstName">Name:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Name" />
      <ValidationMessage For="@(() => Customer.Name)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="LastName">Street:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.Street" />
      <ValidationMessage For="@(() => Customer.Street)" />
    </div>
  </div>
  <div class="form-group row mb-1">
    <label class="col-sm-3 col-form-label"
           for="Birthday">City:</label>
    <div class="col-sm-9">
      <InputText class="form-control"
                 @bind-Value="@Customer.City" />
      <ValidationMessage For="@(() => Customer.City)" />
    </div>
  </div>
  <div class="form-group mb-0">
    <button disabled="@isInvalid" type="submit" id="BtnRegister" class="@ButtonClass">
      @ButtonTitle
    </button>
  </div>
</EditForm>

<div>
  @Customer.ToJson()
</div>

<div>
  @isInvalid.ToJson()
</div>

@code {

  [Parameter]
  protected string Title { get; set; }

  [Parameter]
  protected string ButtonTitle { get; set; }

  [Parameter]
  protected string ButtonClass { get; set; }

  [Parameter]
  protected Customer Customer { get; set; }

  [Parameter]
  protected EventCallback<Customer> CustomerChanged { get; set; }

  [Parameter]
  protected EventCallback Submit { get; set; }

  private InputWatcher inputWatcher;
  private bool isInvalid = false;

  private void FieldChanged(string fieldName)
  {
    Console.WriteLine($"*** {Customer.Name}");
    isInvalid = !inputWatcher.Validate();
    CustomerChanged.InvokeAsync(Customer);
  }

}

Every time a field changes we invoke the CustomerChanged event. You would expect this to update the index, but no such luck... And I am using EventCallback<T>...

Oh, wait. Two-way data-binding will update the UI when the target value changes:

<CustomerEntry3 Title="Please enter your details below"
                ButtonTitle="Checkout"
                ButtonClass="btn btn-primary"
                @bind-Customer="@Customer"
                Submit="@PlaceOrder" />

Mission Accomplished