3

I have a simple input model for my blazor server side component. I want to use the build in validation for two DateTime properties.

[DataType(DataType.Date)]
public DateTime? FromDate { get; set; }
[DataType(DataType.Date)]
public DateTime? ToDate { get; set; }

How can I only accept ToDate > FromDate?

Pelkas
  • 33
  • 3

1 Answers1

3

Solution using custom ValidationAttributes:

DateMustBeAfterAttribute.cs:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class DateMustBeAfterAttribute : ValidationAttribute
{
    public DateMustBeAfterAttribute(string targetPropertyName)
        => TargetPropertyName = targetPropertyName;

    public string TargetPropertyName { get; }

    public string GetErrorMessage(string propertyName) =>
        $"'{propertyName}' must be after '{TargetPropertyName}'.";

    protected override ValidationResult? IsValid(
        object? value, ValidationContext validationContext)
    {
        var targetValue = validationContext.ObjectInstance
            .GetType()
            .GetProperty(TargetPropertyName)
            ?.GetValue(validationContext.ObjectInstance, null);
        
        if ((DateTime?)value < (DateTime?)targetValue)
        {
            var propertyName = validationContext.MemberName ?? string.Empty;
            return new ValidationResult(GetErrorMessage(propertyName), new[] { propertyName });
        }

        return ValidationResult.Success;
    }
}

DateMustBeBeforeAttribute.cs:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class DateMustBeBeforeAttribute : ValidationAttribute
{
    public DateMustBeBeforeAttribute(string targetPropertyName)
        => TargetPropertyName = targetPropertyName;

    public string TargetPropertyName { get; }

    public string GetErrorMessage(string propertyName) =>
        $"'{propertyName}' must be before '{TargetPropertyName}'.";

    protected override ValidationResult? IsValid(
        object? value, ValidationContext validationContext)
    {
        var targetValue = validationContext.ObjectInstance
            .GetType()
            .GetProperty(TargetPropertyName)
            ?.GetValue(validationContext.ObjectInstance, null);

        if ((DateTime?)value > (DateTime?)targetValue)
        {
            var propertyName = validationContext.MemberName ?? string.Empty;
            return new ValidationResult(GetErrorMessage(propertyName), new[] { propertyName });
        }

        return ValidationResult.Success;
    }
}

Usage:

public class DateTimeModel
{
    [Required]
    [DateMustBeBefore(nameof(ToDate))]
    [DataType(DataType.Date)]
    public DateTime? FromDate { get; set; }

    [Required]
    [DateMustBeAfter(nameof(FromDate))]
    [DataType(DataType.Date)]   
    public DateTime? ToDate { get; set; }
}

The fields are linked so we need to notify EditContext when any one of them changes to re-validate the other.

Example EditForm:

<EditForm EditContext="editContext" OnInvalidSubmit="@HandleValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <p>
        <label>
            From Date:
            <InputDate TValue="DateTime?"
                       Value="dateTimeModel.FromDate"
                       ValueChanged="HandleFromDateChanged"
                       ValueExpression="() => dateTimeModel.FromDate" />
        </label>
        <ValidationMessage For="@(() => dateTimeModel.FromDate)" />
    </p>

    <p>
        <label>
            To Date:
            <InputDate TValue="DateTime?"
                       Value="dateTimeModel.ToDate"
                       ValueChanged="HandleToDateChanged"
                       ValueExpression="() => dateTimeModel.ToDate" />
        </label>
        <ValidationMessage For="@(() => dateTimeModel.ToDate)" />
    </p>

    <button type="submit">Submit</button>
</EditForm>

@code {
    private EditContext? editContext;
    private DateTimeModel dateTimeModel = new();

    protected override void OnInitialized()
    {
        editContext = new EditContext(dateTimeModel);
    }

    private void HandleFromDateChanged(DateTime? fromDate)
    {
        dateTimeModel.FromDate = fromDate;

        if (editContext != null && dateTimeModel.ToDate != null)
        {
            FieldIdentifier toDateField = editContext.Field(nameof(DateTimeModel.ToDate));
            editContext.NotifyFieldChanged(toDateField);
        }
    }

    private void HandleToDateChanged(DateTime? toDate)
    {
        dateTimeModel.ToDate = toDate;

        if (editContext != null && dateTimeModel.FromDate != null)
        {
            FieldIdentifier fromDateField = editContext.Field(nameof(DateTimeModel.FromDate));
            editContext.NotifyFieldChanged(fromDateField);
        }
    }

    private void HandleValidSubmit()
    {
    }
}

Blazor fiddle example

GitHub repository with demo

Solution using IValidatableObject:

To do more complex validation checks, your model can inherit from IValidatableObject interface and implement the Validate method:

public class ExampleModel : IValidatableObject
{
    [DataType(DataType.Date)]
    public DateTime? FromDate { get; set; }

    [DataType(DataType.Date)]
    public DateTime? ToDate { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (ToDate < FromDate)
        {
            yield return new ValidationResult("ToDate must be after FromDate", new[] { nameof(ToDate) });
        }
    }
}

Blazor fiddle example

Dimitris Maragkos
  • 8,932
  • 2
  • 8
  • 26
  • I tried this, but it doesnt say anything when I try to run the application. Even tho I choose start date after end date when I run the app – Pelkas Sep 28 '22 at 09:04
  • Check this example: https://blazorfiddle.com/s/txg2vq89 – Dimitris Maragkos Sep 28 '22 at 09:10
  • So do I need to change _Layout.cshtml as yours? – Pelkas Sep 28 '22 at 09:30
  • No. Can you add the code of your component with the EditForm in your question? – Dimitris Maragkos Sep 28 '22 at 09:35
  • I did it now. And inside DateTimeModel I have the model attributes with IValidatableObject – Pelkas Sep 28 '22 at 09:49
  • I created a demo Blazor server project and it works find: https://github.com/Jimmys20/SO73878118 – Dimitris Maragkos Sep 28 '22 at 10:06
  • Okey I saw the problem now. I have like: [Required] public string name {get; set;} [DataType(DataType.Date)] public DateTime? FromDate { get; set; } [DataType(DataType.Date)] public DateTime? ToDate { get; set; } When I remove the [required] over Name then it works, otherwise it dont. Do you know how I can solve this? – Pelkas Sep 28 '22 at 10:22
  • The `IValidatableObject.Validate` method will execute only after all other requirements succeed. So if you type something in the `Name` input to make it valid and then press submit it will works. – Dimitris Maragkos Sep 28 '22 at 10:27
  • Added another solution using `ValidationAttribute`. – Dimitris Maragkos Sep 28 '22 at 10:52
  • Thanks a lot for the help. Learned a lot! But when I press the submit button, the error message disapears, do you know why? – Pelkas Sep 28 '22 at 13:47
  • I made a small improvement to the `DateMustBeAfterAttribute`. Changed `return new ValidationResult(GetErrorMessage());` to `return new ValidationResult(GetErrorMessage(), new[] { validationContext.MemberName ?? string.Empty });` and this fixes the problem with message disappearing. – Dimitris Maragkos Sep 28 '22 at 14:05
  • @DimitrisMaragkos, this is not the way to implement a custom ValidationAttribute that compares the value of one property with another property's value in the model class. You should apply the attribute to both fields, not only to `ToDate.`, which is why both fields can pass validation, and are still invalid: Suppose you enter `09/28/2022` for `From Date` and `09/29/2022` for `ToDate`. So far, so good. But now, go to the `From Date` combo box, and select the value `09/30/2022.` Alas, the data entered is not valid, and yet it passes validation. – enet Sep 28 '22 at 20:32
  • Incidentally, the list boxes' borders should be red when the field fails validation, and green when it passes validation. I'm telling you this because I like to help :) – enet Sep 28 '22 at 20:32
  • In the scenario you described the data do not pass validation. When the submit button is pressed the form shows validation error and `OnValidSubmit` is **not** invoked. I'm not sure if creating two "mirror" validation attributes (`DateMustBeBeforeAttribute`, `DateMustBeAfterAttribute`) would be a better solution. https://blazorfiddle.com/s/p97n9vb4. Maybe it would just be better to handle validation on the UI using `EditContext` like in [this](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms-and-input-components?view=aspnetcore-6.0#basic-validation) example. – Dimitris Maragkos Sep 28 '22 at 23:25
  • This is similar case with `Password`, `ConfirmPassword` properties and usually the [`CompareAttribute`](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.compareattribute?view=net-6.0) is only placed on the `ConfirmPassword` property. – Dimitris Maragkos Sep 28 '22 at 23:27
  • @DimitrisMaragkos, Create A Blazor Server App with Individual Accounts. Register...Enter password, then confirmed password. Navigate to the password field with the mouse pointer, and change the password. Tab out of the password field. Lo and behold, validation is triggered. This is not the way your validation works, as I described in my first comment. Applying the validation attribute to both fields is not the issue here. You can apply it to the `ToDate` field only. The logic that is reponsible for validation in the other direction is missing, and that is the issue. – enet Sep 29 '22 at 07:20
  • @DimitrisMaragkos, Just imitate the external behviour of the CompareAttribute attribute. Note that I suggest to apply the custom validation attribute to both fields because doing it this way enable a better UI control and display... highlighting the culprit field with red border, displaying a validation message below the field, etc. But you can still apply it only to the `ToDate` field if you want, but again, your validation should be bidirectional... – enet Sep 29 '22 at 07:20
  • But this doesnt display "FromDate" with red border. Can I add that, i.e. if one is chosen wrong dates them both should display red borders – Pelkas Sep 29 '22 at 12:09
  • Thanks, Is there a way to hide one of the error messages? Like it says "FromDate must be before EndDate" and "EndDate must be after FromDate", these messages says the same thing so I wonder if Im able to hide one of them. Tried to do [Required (Errormessage = " ")], but this only hides it while chosing values, not when submitting – Pelkas Sep 29 '22 at 14:13