Search code examples
asp.netblazorfluentvalidationmudblazor

In-line validation with dynamic components in Blazor/Mudblazor


I am using MudBlazor form components to create a form. The form has some static components and some dynamic components. I am having trouble getting the dynamic components to validate.

Form

<MudPaper Class="px-4 pb-2 mt-5 rounded-lg">
    <MudForm @ref="_form"
             Validation="@(AddHeartbeatSettingViewModelValidator.ValidateValue)"
             Model="@AddHeartbeatSettingViewModel"
             @bind-Errors="@_errors">
        <MudGrid>
            @if (HasGeneralErrors())
            {
                <MudItem xs="12">
                    <MudAlert Severity="Severity.Error"
                              Variant="Variant.Filled"
                              Class="mb-4">
                        There was a problem with your submission
                        <ul>
                            @foreach (var error in _generalErrors)
                            {
                                <li>@error.ErrorMessage</li>
                            }
                        </ul>
                    </MudAlert>
                </MudItem>
            }

            <MudItem xs="12">
                <MudSwitch T="bool"
                           @bind-Value="AddHeartbeatSettingViewModel.IsEnabled"
                           Label="Enabled"
                           Color="Color.Primary"/>
            </MudItem>
            <MudItem xs="12">
                <FrequencySelector
                    ForFrequency="() => AddHeartbeatSettingViewModel.Frequency.Value"
                    ForTimeUnit="() => AddHeartbeatSettingViewModel.Frequency.TimeUnit"
                    Disabled="@(!AddHeartbeatSettingViewModel.IsEnabled)"
                    @bind-Frequency="@AddHeartbeatSettingViewModel.Frequency.Value"
                    @bind-TimeUnit="@AddHeartbeatSettingViewModel.Frequency.TimeUnit"/>
            </MudItem>

            <MudItem xs="12">
                <MudText Typo="Typo.h5">Pester</MudText>
                @if (AddHeartbeatSettingViewModel.Pester.Intervals.Count > 0)
                {
                    for (var i = 0; i < AddHeartbeatSettingViewModel.Pester.Intervals.Count; i++)
                    {
                        var localCount = i;
                        <FrequencySelector
                            ForFrequency="() => AddHeartbeatSettingViewModel.Pester.Intervals[localCount].Value"
                            ForTimeUnit="() => AddHeartbeatSettingViewModel.Pester.Intervals[localCount].TimeUnit"
                            Disabled="@(!AddHeartbeatSettingViewModel.IsEnabled)"
                            @bind-Frequency="@AddHeartbeatSettingViewModel.Pester.Intervals[localCount].Value"
                            @bind-TimeUnit="@AddHeartbeatSettingViewModel.Pester.Intervals[localCount].TimeUnit"/>
                    }
                }
                
                <MudButton
                    Disabled="@(!AddHeartbeatSettingViewModel.IsEnabled)"
                    StartIcon="@Icons.Material.Filled.AddCircle"
                    Color="AddPesterButtonColor"
                    Variant="Variant.Filled"
                    Class="rounded-lg flex-initial"
                    OnClick="@(() => HandleAddPester())"
                    >@_addPesterButtonLabel</MudButton>
            </MudItem>

            <MudItem xs="12">
                <MudButton
                    StartIcon="@Icons.Material.Filled.AddCircle"
                    Disabled="IsSaving"
                    Color="SubmitButtonColor"
                    Variant="Variant.Filled"
                    Class="rounded-lg flex-initial"
                    OnClick="@(() => HandleSubmit())">
                    @_submitButtonLabel
                </MudButton>
            </MudItem>
        </MudGrid>
    </MudForm>
</MudPaper>

Logic

public partial class AddHeartbeatSetting : AbstractAuthRequiredComponent
{
    [Inject] private AddHeartbeatSettingViewModelValidator AddHeartbeatSettingViewModelValidator { get; set; } = null!;
    private const Color SubmitButtonColor = Color.Primary;
    private const Color AddPesterButtonColor = Color.Secondary;
    private MudForm _form = null!;
    private string[] _errors = [];
    private List<ValidationFailure> _generalErrors = [];
    protected readonly AddHeartbeatSettingViewModel AddHeartbeatSettingViewModel = new();
    private bool IsSaving;
    private string _submitButtonLabel = "Create Heartbeat Setting";
    private string _addPesterButtonLabel = "Add Interval";

    protected override void ProfileAvailable(Profile profile)
    {
        base.ProfileAvailable(profile);
        AddHeartbeatSettingViewModel.ProfileId = profile.Id;
    }

    private bool HasGeneralErrors() => _generalErrors.Count > 0;

    private async Task HandleSubmit()
    {
        await _form.Validate();
        var validationResult = await AddHeartbeatSettingViewModelValidator.ValidateAsync(AddHeartbeatSettingViewModel);
        _generalErrors = validationResult.Errors.FindAll(error => error.PropertyName == string.Empty);
    }

    private void HandleAddPester()
    {
        AddHeartbeatSettingViewModel.Pester.Intervals.Add(new FrequencyViewModel());
    }
}

View Model

public class AddHeartbeatSettingViewModel
{
    public ProfileId? ProfileId { get; set; }
    public bool IsEnabled { get; set; }
    public FrequencyViewModel Frequency { get; set; } = new();
    public PesterViewModel Pester { get; set; } = new();
}

public class FrequencyViewModel
{
    public int Value { get; set; }
    public int TimeUnit { get; set; }
}

public class PesterViewModel
{
    public List<FrequencyViewModel> Intervals { get; set; } = [];
}

Validator

public class AddHeartbeatSettingViewModelValidator : BlazorFormValidator<AddHeartbeatSettingViewModel>
{
    public AddHeartbeatSettingViewModelValidator(IValidator<FrequencyViewModel> frequencyViewModelValidator)
    {
        RuleFor(x => x.ProfileId)
            .NotNull()
            .WithMessage("ProfileId is required.")
            .Must(BeValidGuid)
            .WithMessage("Invalid ProfileId.");
        
        When(x => x.IsEnabled, () =>
        {
            RuleFor(x => x.Frequency)
                .SetValidator(frequencyViewModelValidator);
            
            RuleForEach(x => x.Pester.Intervals)
                .SetValidator(frequencyViewModelValidator);
        });
    }

    private static bool BeValidGuid(ProfileId? guid) => guid?.Value != Guid.Empty;
}

FrequencySelector Component

Razor

<div class="d-flex flex-row align-baseline">
    <MudNumericField T="int"
                     Label="@_frequencyLabel"
                     For="@ForFrequency"
                     Disabled="@Disabled"
                     Class="mr-10"
                     ValueChanged="UpdateFrequency"/>

    <MudSelect T="int"
               Label="Time Unit:"
               For="@ForTimeUnit"
               Variant="Variant.Outlined"
               Disabled="@Disabled"
               Margin="Margin.Dense"
               AnchorOrigin="Origin.BottomCenter"
               ValueChanged="UpdateTimeUnit"
               Class="ml-10">
        @foreach (var timeUnit in AfterLife.Domain.Heartbeats.Enums.TimeUnit.List)
        {
            <MudSelectItem T="int"
                           Value="@timeUnit.Value">
                @timeUnit.Name
            </MudSelectItem>
        }
    </MudSelect>
</div>

Logic

public partial class FrequencySelector : ComponentBase
{
    /// <summary>
    /// The frequency
    /// <remarks>REQUIRED</remarks>
    /// </summary>
    [Parameter, EditorRequired]
    public int Frequency { get; set; }

    /// <summary>
    /// The frequency changed event
    /// </summary>
    [Parameter]
    public EventCallback<int> FrequencyChanged { get; set; }

    /// <summary>
    /// The time unit
    /// <remarks>REQUIRED</remarks>
    /// </summary>
    [Parameter, EditorRequired]
    public int TimeUnit { get; set; }

    /// <summary>
    /// The time unit changed event
    /// </summary>
    [Parameter]
    public EventCallback<int> TimeUnitChanged { get; set; }

    /// <summary>
    /// The disabled state for the component
    /// </summary>
    [Parameter]
    public bool Disabled { get; set; }

    /// <summary>
    /// The model field representing validation results for the frequency property
    /// </summary>
    [Parameter]
    public Expression<Func<int>>? ForFrequency { get; set; }

    /// <summary>
    /// The model field representing validation results for the time unit property
    /// </summary>
    [Parameter]
    public Expression<Func<int>>? ForTimeUnit { get; set; }

    private string _frequencyLabel = AfterLife.Domain.Heartbeats.Enums.TimeUnit.FromValue(0).ToString();
    private const int MinFrequency = 0;

    private async Task UpdateFrequency(int value)
    {
        Frequency = value;
        await FrequencyChanged.InvokeAsync(Frequency);
    }

    private async Task UpdateTimeUnit(int value)
    {
        if (!AfterLife.Domain.Heartbeats.Enums.TimeUnit.TryFromValue(value, out var timeUnit)) return;

        TimeUnit = timeUnit;
        _frequencyLabel = timeUnit.ToString();
        await TimeUnitChanged.InvokeAsync(TimeUnit);
    }
}

This renders as expected and var validationResult = await AddHeartbeatSettingViewModelValidator.ValidateAsync(AddHeartbeatSettingViewModel); does capture all of the validation errors as expected. However, the dynamically added FrequencySelector components do not display error messages in-line when being edited or when await _form.Validate(); is called. The FrequencySelector component that is there initially when the form is rendered does behave as expected.

What am I doing wrong?


Solution

  • It turns out that the solution was to wrap each component in the for(each) loop with a child MudForm component.

    E.g.

    <MudItem xs="12">
        <MudText Typo="Typo.h5">Pester</MudText>
            @if (AddHeartbeatSettingViewModel.Pester.Intervals.Count > 0)
            {
                foreach (var frequencyModel in AddHeartbeatSettingViewModel.Pester.Intervals))
                {
                    <MudForm Model="frequencyModel" Validation="FrequencyViewModelValidator.ValidateValue">
                        <FrequencySelector
                            ForFrequency="() => frequencyModel.Value"
                            ForTimeUnit="() => frequencyModel.TimeUnit"
                            Disabled="@(!AddHeartbeatSettingViewModel.IsEnabled)"
                            @bind-Frequency="@frequencyModel.Value"
                            @bind-TimeUnit="@frequencyModel.TimeUnit"/>
                                
                    </MudForm>
                }
            }
                    
        <MudButton
            Disabled="@(!AddHeartbeatSettingViewModel.IsEnabled)"
            StartIcon="@Icons.Material.Filled.AddCircle"
            Color="AddPesterButtonColor"
            Variant="Variant.Filled"
            Class="rounded-lg flex-initial"
            OnClick="@(() => HandleAddPester())"
            >@_addPesterButtonLabel</MudButton>
    </MudItem>