skip to Main Content

I’ve recently started using Blazor. Is there a way to trigger form model validation only on submit, instead of live on each change?

Just for clarification, let’s say I have something like this:

<EditForm Model="this" OnValidSubmit="SubmitForm">
    <DataAnnotationsValidator />
    <ValidationSummary />
            <Label For="Name">Name</Label>
            <InputText id="Name" name="Name" class="form-control" @bind-Value="Name"/>
    <button type="submit">Save</button>
</EditForm>

@code {
    [StringLength(10, ErrorMessage="Name too long")]
    public string Name { get; set; }

    private async Task SubmitForm()
    {
        // ...
        // send a POST request
    }
}

By default, it seems like the validity of the field and the error messages displayed in the ValidationSummary get re-evaluated on every change of the text input (e.g. as soon as I delete the 11th character from the input, the "too long" message disappears).

I would prefer if the displayed messages would remain frozen until the Submit button is clicked.

I suppose it would be possible to implement it by removing the ValidationSummary component and implementing a custom solution (e.g. displaying a List of error messages that’s refreshed only on submit), but I was wondering if there is some idiomatic solution that I’m not aware of.

2

Answers


  1. When validation occurs is controlled by the Validator you’re using.

    There are two events that you can receive from EditContext:

    OnValidationRequested is invoked either when EditContext.Validate is called or as part of the form submission process.

    OnFieldChanged is invoked every time a field value is changed.

    A validator uses these events to trigger it’s validation process, and outputs the results to the EditContext’s ValidationMessageStore.

    DataAnnotationsValidator wires up for both events and triggers validation whenever either is invoked.

    There are other validators out there, and writing your own is not too difficult. Other than those from the usual control suppliers, there’s Blazored, or mine. Mine is documented here – https://shauncurtis.github.io/articles/Blazor-Form-Validation.html. it has a DoValidationOnFieldChange setting!

    Login or Signup to reply.
  2. @enet’s answer sparked an alternative answer. Build your own DataAnnotationsValidator.

    Here’s the EditContext Extensions code. It’s a modified version of the original MS Code with some extra control arguments.

    using Microsoft.AspNetCore.Components.Forms;
    using System.Collections.Concurrent;
    using System.ComponentModel.DataAnnotations;
    using System.Diagnostics.CodeAnalysis;
    using System.Reflection;
    using System.Reflection.Metadata;
    using System.Runtime.InteropServices;
    
    namespace StackOverflowAnswers;
    
    public static class EditContextCustomValidationExtensions
    {
        public static IDisposable EnableCustomValidation(this EditContext editContext, bool doFieldValidation, bool clearMessageStore)
            =>  new DataAnnotationsEventSubscriptions(editContext, doFieldValidation, clearMessageStore);
    
        private static event Action? OnClearCache;
    
        private static void ClearCache(Type[]? _)
            =>  OnClearCache?.Invoke();
    
        private sealed class DataAnnotationsEventSubscriptions : IDisposable
        {
            private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new();
    
            private readonly EditContext _editContext;
            private readonly ValidationMessageStore _messages;
            private bool _doFieldValidation;
            private bool _clearMessageStore;
    
            public DataAnnotationsEventSubscriptions(EditContext editContext, bool doFieldValidation, bool clearMessageStore)
            {
                _doFieldValidation = doFieldValidation;
                _clearMessageStore = clearMessageStore;
                _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
                _messages = new ValidationMessageStore(_editContext);
    
                if (doFieldValidation)
                    _editContext.OnFieldChanged += OnFieldChanged;
                _editContext.OnValidationRequested += OnValidationRequested;
    
                if (MetadataUpdater.IsSupported)
                {
                    OnClearCache += ClearCache;
                }
            }
            private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs)
            {
                var fieldIdentifier = eventArgs.FieldIdentifier;
                if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
                {
                    var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
                    var validationContext = new ValidationContext(fieldIdentifier.Model)
                    {
                        MemberName = propertyInfo.Name
                    };
                    var results = new List<ValidationResult>();
    
                    Validator.TryValidateProperty(propertyValue, validationContext, results);
                    _messages.Clear(fieldIdentifier);
                    foreach (var result in CollectionsMarshal.AsSpan(results))
                    {
                        _messages.Add(fieldIdentifier, result.ErrorMessage!);
                    }
    
                    // We have to notify even if there were no messages before and are still no messages now,
                    // because the "state" that changed might be the completion of some async validation task
                    _editContext.NotifyValidationStateChanged();
                }
            }
    
            private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
            {
                var validationContext = new ValidationContext(_editContext.Model);
                var validationResults = new List<ValidationResult>();
                Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true);
    
                // Transfer results to the ValidationMessageStore
                _messages.Clear();
                foreach (var validationResult in validationResults)
                {
                    if (validationResult == null)
                    {
                        continue;
                    }
    
                    var hasMemberNames = false;
                    foreach (var memberName in validationResult.MemberNames)
                    {
                        hasMemberNames = true;
                        _messages.Add(_editContext.Field(memberName), validationResult.ErrorMessage!);
                    }
    
                    if (!hasMemberNames)
                    {
                        _messages.Add(new FieldIdentifier(_editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!);
                    }
                }
    
                _editContext.NotifyValidationStateChanged();
            }
    
            public void Dispose()
            {
                if (_clearMessageStore)
                    _messages.Clear();
                if (_doFieldValidation)
                    _editContext.OnFieldChanged -= OnFieldChanged;
                _editContext.OnValidationRequested -= OnValidationRequested;
                _editContext.NotifyValidationStateChanged();
    
                if (MetadataUpdater.IsSupported)
                {
                    OnClearCache -= ClearCache;
                }
            }
    
            private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo? propertyInfo)
            {
                var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
                if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
                {
                    // DataAnnotations only validates public properties, so that's all we'll look for
                    // If we can't find it, cache 'null' so we don't have to try again next time
                    propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
    
                    // No need to lock, because it doesn't matter if we write the same value twice
                    _propertyInfoCache[cacheKey] = propertyInfo;
                }
    
                return propertyInfo != null;
            }
    
            internal void ClearCache()
                => _propertyInfoCache.Clear();
        }
    }
    

    And the CustomValidation component:

    using Microsoft.AspNetCore.Components;
    using Microsoft.AspNetCore.Components.Forms;
    
    namespace StackOverflowAnswers;
    
    public class CustomValidation : ComponentBase, IDisposable
    {
        private IDisposable? _subscriptions;
        private EditContext? _originalEditContext;
    
        [CascadingParameter] EditContext? CurrentEditContext { get; set; }
    
        [Parameter] public bool DoEditValidation { get; set; } = false;
    
        /// <inheritdoc />
        protected override void OnInitialized()
        {
            if (CurrentEditContext == null)
            {
                throw new InvalidOperationException($"{nameof(DataAnnotationsValidator)} requires a cascading " +
                    $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DataAnnotationsValidator)} " +
                    $"inside an EditForm.");
            }
    
            _subscriptions = CurrentEditContext.EnableCustomValidation(DoEditValidation, true);
            _originalEditContext = CurrentEditContext;
        }
    
        /// <inheritdoc />
        protected override void OnParametersSet()
        {
            if (CurrentEditContext != _originalEditContext)
            {
                // While we could support this, there's no known use case presently. Since InputBase doesn't support it,
                // it's more understandable to have the same restriction.
                throw new InvalidOperationException($"{GetType()} does not support changing the " +
                    $"{nameof(EditContext)} dynamically.");
            }
        }
    
        /// <inheritdoc/>
        protected virtual void Dispose(bool disposing)
        {
        }
    
        void IDisposable.Dispose()
        {
            _subscriptions?.Dispose();
            _subscriptions = null;
    
            Dispose(disposing: true);
        }
    }
    

    You can use it like this:

    <EditForm EditContext=this.editContext OnValidSubmit=OnValidSubmit>
        <CustomValidation DoEditValidation=false/>
        @*<DataAnnotationsValidator/>*@
        <div class="row">
            <div class="col-2">
                Date:
            </div>
            <div class="col-10">
                <InputDate @bind-Value=this.Record.Date></InputDate>
            </div>
        </div>
    .......
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search