Blazor Input Date does not revalidate upon value assignment

304 views Asked by At

I have an InputDate from Blazor which should revert to the previous date if the change confirmation is not accepted which works. However, when I input something like 11/31/2024 which is an invalid date and does not accept change it still treats the previous date 12/31/2024 as an invalid date (red lines) as if it did not update.

Using WebAssembly 5.0.7

@page "/test"

<EditForm EditContext="_editContext">
    <DataAnnotationsValidator />
             <InputDate class="mb-3" ValueExpression="@(()=>MyModel.TestDate)"
               Value="MyModel.TestDate"
               ValueChanged="@((DateTime value) => TogglePopUp(value))" />

</EditForm>

@if (ShowPopUp)
{
    <div class="popconfirm">
        Are you sure you want to change?
        <button type="button" class="btn btn-warning" @onclick="() => Confirmation(false)">No</button>
        <button type="button" class="btn btn-primary" @onclick="() => Confirmation(true)">Yes</button>
    </div>
}
    
@code {
    MyClass MyModel;
    EditContext _editContext;
    bool ShowPopUp { get; set; }
    DateTime previousDate { get; set; }

    protected override void OnInitialized()
    {
        MyModel = new() { TestDate = new DateTime(2024,12,31) };
        _editContext = new EditContext(MyModel);
    }


    public class MyClass
    {
        public DateTime TestDate { get; set; }
    }

    private async Task TogglePopUp(DateTime selectedDate)
    {
        previousDate = MyModel.TestDate;
        MyModel.TestDate = selectedDate;
        ShowPopUp = true;
    }

    private void Confirmation(bool confirm)
    {
        if (!confirm)
        {
            MyModel.TestDate = previousDate;
        }
        ShowPopUp = false;
    }
}
3

There are 3 answers

0
Yzak On BEST ANSWER

It seems it's a known issue for the built in InputDate component for blazor, as a workaround I created a separate component for the confirmation modal and add focus on after render, so that on the first digit it won't allow typing another.

ConfirmationModal.razor

<button type="button" @ref="confirmationModal" class="btn btn-primary" @onclick="() => Confirmation(true)">Yes</button>

private ElementReference confirmationModal;

protected override void OnAfterRender(bool firstRender)
{
    confirmationModal.FocusAsync();
}

this is just a workaround feel free to add fix

2
RBee On

I would recommend using a nullable DateTime? instead of DateTime.

When you use DateTime? and the user inputs an invalid date the value passed in ValueChanged will be null and you can handle it accordingly.

private async Task TogglePopUp(DateTime? selectedDate)
{
    if(selectedDate != null)
    {
        previousDate = MyModel.TestDate;
        MyModel.TestDate = selectedDate;
        ShowPopUp = true;
    }
}

Snippet

Not sure if this clears up your validation marker, but this makes it so that the user will not be able to input an invalid date.

5
MrC aka Shaun Curtis On

There would appear to be a fundamental problem in the behaviour of the input field or in the way Blazor implements it for dates. I may also be totally wrong and missed something obvious!

In an existing date of say 31/12/2023, typing a 1 in the month triggers an immediate change event to update the value to 31/01/2023. I would expect the bind updates to take place when I exit the control, not oninput.

If I'm correct, you almost certainly need to build your own input control to overcome the issues.

Here's my version: it's my first pass so there may be bugs!

The principle difference in this control is I use the onfocusout to do the bind updates as OnChanged seems to get fired like OnInput in the edit context.

You appear to be only using Date and not time, so use the DateOnly data type. It makes equality checking much easier.

If you must use DateTime then you will need to play a little with my code and sort out your own equality checking.

The code is heavily commented, but if you don't understand something, ask. I've replaced your simple confirmation with the browser alert as it needs asynchronous behaviour and to lock the background.

This is MyInputDate

@using System.Globalization;
@using System.Linq.Expressions;
@using System.Text;

@inject IJSRuntime JsRuntime

<input class="@_ComponentCss" type="date" value="@_dateString" @onchange="UpdateDateInternally" @onfocusout="ChangeDate" />

@code {
    [Parameter] public DateOnly? Value { get; set; }
    [Parameter] public EventCallback<DateOnly?> ValueChanged { get; set; }
    [Parameter] public Expression<Func<DateOnly?>>? ValueExpression { get; set; }
    [Parameter] public string? Class { get; set; }
    [CascadingParameter] private EditContext? CascadedEditContext { get; set; }

    private DateOnly? _currentValue;
    private DateOnly? _originalValue;
    private string? _currentValueAsString;
    private bool _isValidDate = true;
    private bool _resetDate;
    private ValidationMessageStore? _parsingValidationMessages;

    private bool _isModified => this.Value != _originalValue;

    // Get the display value in the input
    private string _dateString
    {
        get
        {
            // This is a bit of a kludge to force the UI to update if we don't want to change the value
            // We have to do it as the Browser DOM and the Renderer DOM are out of sync on the actual value being currenrkly displayed
            if (_resetDate)
            {
                _resetDate = false;
                return BindConverter.FormatValue(DateOnly.FromDateTime(DateTime.MinValue), "yyyy-MM-dd", CultureInfo.InvariantCulture);
            }

            return BindConverter.FormatValue(this.Value ?? DateOnly.FromDateTime(DateTime.Now), "yyyy-MM-dd", CultureInfo.InvariantCulture);
        }
    }

    // This sorts out the CSS for the control and turns on and off the green and red
    // Normally you would use a CSSBuilder class - see https://github.com/EdCharbeneau/BlazorComponentUtilities
    private string _ComponentCss
    {
        get
        {
            var sb = new StringBuilder();
            if (this.Class is not null)
                sb.Append($"{this.Class} ");

            if (!_isValidDate)
                sb.Append($"invalid ");
            else
                sb.Append($"valid ");

            if (_isModified)
                sb.Append($"modified ");
            return sb.ToString().Trim();
        }
    }

    protected override void OnInitialized()
    {
        // Set up the message store and initial values
        if (CascadedEditContext is not null)
            _parsingValidationMessages = new(CascadedEditContext);

        _originalValue = this.Value;
        _currentValue = this.Value;
    }

    // This method gets called whwnever the input get's modified
    // It maintains the current state of the value and updates the Edit Context and messages stores as required
    private void UpdateDateInternally(ChangeEventArgs e)
    {
        _isValidDate = true;

        //clear any validation messages
        _parsingValidationMessages?.Clear();

        // if e.Value is an empty string we have an invalid date
        if (string.IsNullOrEmpty(e.Value?.ToString() ?? null))
        {
            _isValidDate = false;

            if (this.ValueExpression is not null && this.CascadedEditContext is not null)
            {
                // Get the Field Identifier and update the Edit Context stuff
                var fi = FieldIdentifier.Create(this.ValueExpression);
                this.CascadedEditContext.MarkAsUnmodified(fi);

                // Add a validation message
                _parsingValidationMessages?.Add(fi, "Date is Invalid");

                // notify the Edit Context which will notify all the Validation components
                CascadedEditContext.NotifyFieldChanged(fi);
            }

            return;
        }

        // At this point we have a valid date value that we store internally
        if (BindConverter.TryConvertToDateOnly(e.Value, CultureInfo.InvariantCulture, out DateOnly value))
        {
            _currentValue = value;

            if (this.ValueExpression is not null && this.CascadedEditContext is not null)
            {
                // Get the Field Identifier and update the Edit Context stuff
                var fi = FieldIdentifier.Create(this.ValueExpression);
                this.CascadedEditContext.IsModified(fi);

                // notify the Edit Context which will notify all the Validation components
                CascadedEditContext.NotifyFieldChanged(fi);
            }
        }
    }

    // Called when the user moved focus away from the control
    // It's only at this point we want to transmit the update up to the parent through the binding
    // And only if stuff is valid and the user says so
    private async Task ChangeDate()
    {
        if (_isValidDate && _currentValue != this.Value)
        {
            // Pop up the browser alert
            // If you want to implement a Blazor Dialog then replace this code.
            bool confirmed = await JsRuntime.InvokeAsync<bool>("confirm", "Do you want to Update the Date?");

            if (confirmed)
                await this.ValueChanged.InvokeAsync(_currentValue);
            else
            {
                _resetDate = true;
                await this.ValueChanged.InvokeAsync(this.Value);
            }
        }
    }
}

And then the test page:

@page "/"

<EditForm EditContext="_editContext">
    <DataAnnotationsValidator />
    <MyInputDate Class="form-control" @bind-Value=MyModel.TestDate/>
    <ValidationMessage For="() => this.MyModel.TestDate "  />
</EditForm>

<div class="bg-dark text-white m-2 p-2">
<pre>Value : @this.MyModel.TestDate</pre>
</div>

@code {
    MyClass MyModel = new();
    EditContext? _editContext;

    protected override void OnInitialized()
    {
        MyModel = new() { TestDate = DateOnly.FromDateTime(new DateTime(2024, 12, 31)) };
        _editContext = new EditContext(MyModel);
    }

    public class MyClass
    {
        public DateOnly? TestDate { get; set; }
    }
}