Search code examples
blazormudblazor

Blazor Mudblazor form validation not Firing with multiple level child components


I am using Blazor Mudblazor v6.17.0 and I have a component called CustomerComponent

<div class="row" style="padding-top:10px;">
    <div class="col-4 mb-3">
        <MudAutocomplete id="name" For="@(() => Customer.Name)" T="string" Label="Name:" @bind-Value="Customer.Name" SearchFunc="@SearchCustomer" Margin="Margin.Dense" Dense="true" Variant="Variant.Outlined" />
    </div>
</div>


@code {
    [Parameter] public Customer Customer { get; set; }
    [Parameter] public IEnumerable<string> CustomerList { get; set; }
   
    private Task<IEnumerable<string>> SearchCustomer(string value)
    {
        if (string.IsNullOrEmpty(value))
        {
            Customer.Name = "";
            return Task.FromResult(CustomerList);
        }
        var filteredList = CustomerList.Where(x => x.Contains(value, StringComparison.InvariantCultureIgnoreCase));
        return Task.FromResult(filteredList);
    }

and a Component that consumes this component called CustomerTemplateComponent

 <EditForm Model="@Template" OnValidSubmit="SaveEvent">
       <DataAnnotationsValidator/>
       <br />
       <div class="row" style="padding-top: 6px;">
           <div class="col-12">
               <MudTextField id="TemplateName" For="@(() => Template.TemplateName)" @bind-Value="Template.TemplateName" 
                             Label="Template Name:" Variant="Variant.Outlined" Margin="Margin.Dense"></MudTextField>
           </div>
       </div>
       <CustomerComponent  Currencies="@Currencies" CustomerList="@CustomerList" 
                            Customer="@Template.Customer"
                            "/>
       
       <div class="row" style="padding-top:8px;">
           <div class="col-12 text-end mb-3">
               <button class="btn btn-primary" disabled="@Processing" id="Save-Button">Save</button>
               <div type="button" class="btn btn-secondary" @onclick="CancelEvent" id="Cancel-Button">Cancel</div>
           </div>
       </div>
   </EditForm>
}
@code {
    [Parameter] public bool Processing { get; set; }
    [Parameter] public Template Template { get; set; }
    [Parameter] public EventCallback CancelEvent { get; set; }
    [Parameter] public EventCallback SaveEvent { get; set; }
    [Parameter] public IEnumerable<string> CustomerList { get; set; }

}

and this component is used like follows

and this is used here

<MudPaper Elevation="10">
    <MudGrid Class="d-flex align-content-center justify-center flex-grow-1 gap-4" Style="padding:10px;">
        <CustomerTemplateComponent CancelEvent="@Cancel" CustomerList="@Customers" 
                             SaveEvent="@Save" Processing="@Processing" Customer="@_customer" />
    </MudGrid>
</MudPaper> 



private async Task Save()
    {
        Processing = true;

        try
        {
            await _templateService.SaveAsync(_template);
            ToastService.ShowSuccess("Template Updated.");
            NavigationManager.NavigateTo("/tradetemplates");
        }
        catch (Exception ex)
        {
            Logger.Error(ex, "Error saving templates.");
            ToastService.ShowError(ex.Message);
        }
        finally
        {
            Processing = false;
        }
    }

here are the classes


public class Template 
    {
        public string TemplateName { get; set; }
        public Customer Customer { get; set; }
    }
    
    public class Customer 
    {
        public string Name { get; set; }
    }

The Issue I have is when the button is clicked the form is not validated and the api is called with an invalid mode, the base component does not bind either. Should i be using cascading parameters or something else when going two levels deep with parameters? Or is there a different error, because when I consume the Base component from a top level form it works fine.

Working sample can be found https://try.mudblazor.com/snippet/GEGSkxGrGBqyVXZR


Solution

  • To ensure that the child component validation is captured in the parent component's EditForm you can use EditContext.OnValidationRequested, where you pass the EditContext from the parent component to the child component as a CascadingParameter.

    NOTE: i've used a @bind-Customer here

    // CustomerTemplateComponent.razor
    <EditForm Model="@Template" OnValidSubmit="SaveEvent" Context="EditContext">
        <DataAnnotationsValidator/>
        <br />
        <div class="row" style="padding-top: 6px;">
            <div class="col-12">
                <MudTextField id="TemplateName" For="@(() => Template.TemplateName)" @bind-Value="Template.TemplateName" 
                                Label="Template Name:" Variant="Variant.Outlined" Margin="Margin.Dense">
                    </MudTextField>
            </div>
        </div>
        <CascadingValue Value="EditContext">
            <CustomerComponent CustomerList="@CustomerList" @bind-Customer="@Template.Customer"/>
        </CascadingValue>
        
        <div class="row" style="padding-top:8px;">
            <div class="col-12 text-end mb-3">
                <button class="btn btn-primary" disabled="@Processing" id="Save-Button">Save</button>
                <div type="button" class="btn btn-secondary" @onclick="CancelEvent" id="Cancel-Button">Cancel</div>
            </div>
        </div>
    </EditForm>
    
    @code {
        [Parameter] public bool Processing { get; set; }
        [Parameter] public Template Template { get; set; }
        [Parameter] public EventCallback CancelEvent { get; set; }
        [Parameter] public EventCallback SaveEvent { get; set; }
        [Parameter] public IEnumerable<string> CustomerList { get; set; }
    
        private EditContext EditContext;
        protected override void OnInitialized()
        {
            EditContext = new EditContext(Template);
        }
    }
    

    Then, in the child component i.e. CustomerComponent. We subscribe to the EditContext.OnValidationRequested event which will be triggered when the form requests validation i.e. when submit button is clicked. Here we can use this event to validate the property and then EditContext.NotifyValidationStateChanged to propagate the validation back to the parent components EditContext.

    Some other changes made :

    • Change @bind-Value to using Value & ValueChanged as we need to implement custom logic when the MudAutoComplete's value changes, and this allows us to use @bind-Customer on the parent component.

    • ValidationMessageStore - This is used to hold validation messages and is tied to the EditContext when instantiated i.e. new ValidationMessageStore(ParentEditContext);

    // CustomerComponent.razor
    @using System.ComponentModel.DataAnnotations
    @using Microsoft.AspNetCore.Components.Forms
    @implements IDisposable
    
    <div class="row" style="padding-top:10px;">
        <div class="col-4 mb-3">
            <MudAutocomplete id="name" T="string" Label="Name:"
                Value="@Customer.Name" ResetValueOnEmptyText="true"
                ValueChanged="@HandleNameChanged" SearchFunc="@SearchCustomer" 
                For="@(() => Customer.Name)"
                Margin="Margin.Dense" Dense="true" Variant="Variant.Outlined" />
        </div>
    </div>
    
    @code {
        [CascadingParameter] public EditContext ParentEditContext { get; set; }
        private ValidationMessageStore _messageStore;
    
        [Parameter] public Customer Customer { get; set; }
        [Parameter] public EventCallback<Customer> CustomerChanged { get; set; }
        [Parameter] public IEnumerable<string> CustomerList { get; set; }
    
        protected override void OnInitialized()
        {
            if (ParentEditContext != null)
            {
                _messageStore = new ValidationMessageStore(ParentEditContext);
    
                ParentEditContext.OnValidationRequested += (sender, eventArgs) =>
                {
                    _messageStore.Clear();
                    Validate();
                    ParentEditContext.NotifyValidationStateChanged();
                };
            }
        }
    
        async Task HandleNameChanged(string newName)
        {
            Customer.Name = newName;
            await CustomerChanged.InvokeAsync(Customer);
        }
    
        void Validate()
        {
            var validationResults = new List<ValidationResult>();
            var context = new ValidationContext(Customer)
            {
                MemberName = nameof(Customer.Name)
            };
    
            Validator.TryValidateProperty(Customer.Name, context, validationResults);
    
            foreach (var validationResult in validationResults)
            {
                _messageStore.Add(new FieldIdentifier(Customer, nameof(Customer.Name)), validationResult.ErrorMessage);
            }
        }
    
        public void Dispose()
        {
            if (ParentEditContext != null)
            {
                ParentEditContext.OnValidationRequested -= (sender, eventArgs) =>
                {
                    _messageStore.Clear();
                    Validate();
                    ParentEditContext.NotifyValidationStateChanged();
                };
            }
        }
    
        private Task<IEnumerable<string>> SearchCustomer(string value)
        {
            if (string.IsNullOrEmpty(value))
            {
                Customer.Name = "";
                return Task.FromResult(CustomerList.AsEnumerable());
            }
            var filteredList = CustomerList.Where(x => x.Contains(value, StringComparison.InvariantCultureIgnoreCase));
            return Task.FromResult(filteredList.AsEnumerable());
        }
    }
    

    Demo 👉MudBlazor Snippet

    If you want to re-validate the entire object instead of just one property then you can use Validator.TryValidateObject. I've included that snippet in the demo.

    void ValidateEntireObject()
    {
        var validationResults = new List<ValidationResult>();
        Validator.TryValidateObject(Customer, new ValidationContext(Customer), validationResults, true);
    
        foreach (var validationResult in validationResults)
        {
            _messageStore.Add(new FieldIdentifier(Customer, validationResult.MemberNames.First()), validationResult.ErrorMessage);
        }
    }