0

Value from HTML date picker is not interpreted in ASP.NET Core MVC controller

Context

  • ASP.NET Core MVC web application targeting .NET 6.0
  • Entity Framework Core 6.0.6
  • SQL Server Local DB
  • The forms contain HTML date picker.

Goal

  • Being able to smoothly work with the new DateOnly type. SQL Server has a corresponding date type.
  • I don't want to use any annotation at model level. Data shall be treated automatically/globally like common types.
  • Value from DB should be displayed accordingly in the date picker upon loading forms. ✔
  • Value from date picker should be retrieved correctly by the targeted action. ❌

So ultimately what I want is the Student.DateOfBirth of type DateOnly to be properly populated when it reaches the action in the controller instead on being null.

Many thanks to anyone being able to help.

Actions performed

1. Add DateOnly properties in a few models

For instance:

namespace WebApp1.Models;

public class Student
{
    public Guid StudentId { get; set; }
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public DateOnly? DateOfBirth { get; set; }
}

2. Setup EF

2.1. Write Entity Configs

For the Student entity

namespace WebApp1.Persistence.EntityConfigs;

public class StudentConfig : IEntityTypeConfiguration<Student>
{
    public void Configure(EntityTypeBuilder<Student> builder)
    {
        builder.HasKey(s => s.StudentId);

        builder
            .Property(s => s.FirstName)
            .HasMaxLength(50)
            .IsRequired();

        builder
            .Property(s => s.LastName)
            .HasMaxLength(50)
            .IsRequired();

        // Seeding
        builder.HasData(
            new Student { StudentId = Guid.NewGuid(), DateOfBirth = DateOnly.FromDateTime(DateTime.Today), FirstName = "Uchi", LastName = "Testing" },
            new Student { StudentId = Guid.NewGuid(), DateOfBirth = DateOnly.FromDateTime(DateTime.Today), FirstName = "Armin", LastName = "VanSisharp" },
            new Student { StudentId = Guid.NewGuid(), DateOfBirth = DateOnly.FromDateTime(DateTime.Today), FirstName = "Jack", LastName = "O'Voltrayed" }
            );
    }
}

2.2. Write a ValueConverter<DateOnly,DateTime>

namespace WebApp1.Persistence.Conventions;

public class DateOnlyConverter : ValueConverter<DateOnly, DateTime>
{
    public DateOnlyConverter() : base(
        d => d.ToDateTime(TimeOnly.MinValue),
        d => DateOnly.FromDateTime(d)
        )
    { }
}

2.3. Add usual config

Setup of the connection string in appsettings.json which works fine.

In program.cs:

builder.Services.AddTransient<ApplicationContext>();

builder.Services.AddDbContext<ApplicationContext>(
    options => options
    .UseSqlServer(builder.Configuration.GetConnectionString("Dev")
));

3. Write DateOnlyJsonConverter

namespace WebApplication1.Converters;

public class DateOnlyJsonConverter : JsonConverter<DateOnly>
{
    public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        => DateOnly.ParseExact(reader.GetString()!, "yyyy-MM-dd", CultureInfo.InvariantCulture);

    public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
    }
}

4. Add the JSON converter in JsonOptions

In Program.cs:

builder.Services.AddControllersWithViews()
    .AddJsonOptions(options =>
        options.JsonSerializerOptions
            .Converters.Add(new DateOnlyJsonConverter())
);

5. Wrote a Tag Helper for date picker

public class DatePickerTagHelper : TagHelper
{
    protected string dateFormat = "yyyy-MM-dd";

    public ModelExpression AspFor { get; set; }
    public DateOnly? Date { get; set; }
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        base.Process(context, output);

        output.TagName = "input";

        if (!string.IsNullOrEmpty(AspFor.Name))
        {
            output.Attributes.Add("id", AspFor.Name);
            output.Attributes.Add("name", AspFor.Name);
        }

        output.Attributes.Add("type", "date");

        if (Date.HasValue)
            output.Attributes
                .SetAttribute("value", GetDateToString());

        //output.Attributes.Add("value", "2022-01-01");
        output.TagMode = TagMode.SelfClosing;
    }

    protected string GetDateToString() => (Date.HasValue)
        ? Date.Value.ToString(dateFormat)
        : DateOnly.FromDateTime(DateTime.Today).ToString(dateFormat);
}

6. Wrote a form view

@using WebApp1.Models;
@using WebApplication1.Helpers;
@model Student
<h2>Edit @(Model.StudentId == Guid.Empty ? "New Student":$"Student {@Model.FirstName} {@Model.LastName}")</h2>

<form asp-action="UpsertStudent" asp-controller="Student" method="post">
    <input asp-for="StudentId" type="hidden" />
    <label asp-for="FirstName"></label>
    <input asp-for="FirstName" />
    <label asp-for="LastName"></label>
    <input asp-for="LastName" />
    <label asp-for="DateOfBirth"></label>
    <date-picker date=@Model.DateOfBirth asp-for="DateOfBirth"/>
    <button type="reset">Reset</button>
    <submit-button text="Submit Me"/>
</form>

7. [P.S.] Applied newly made convention to EF

I forgot that one.

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    base.ConfigureConventions(configurationBuilder);

    configurationBuilder
        .Properties<DateOnly>()
        .HaveConversion<DateOnlyConverter>()
        .HaveColumnType(SqlDbType.Date.ToString());
}

Behaviour

  • Data is properly retrieved from DB and displayed upon form/page load. The date picker is set to the same date as persisted in the DB ✔

enter image description here

enter image description here

  • Data is transferred accordingly in the HttpContext.Requests.Form which contains any updated value from the form ✔

enter image description here

  • Date is set to null in the action ❌

enter image description here

  • ModelState.IsValid returns true

Documentation

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
UchiTesting
  • 111
  • 8
  • Have you tried the solution here? https://stackoverflow.com/a/69207036/9491881 – Lee Jul 08 '22 at 16:53
  • My guess is that you can't serialize DateOnly... why not use DateTime? (it would make things a whole lot simpler...) – pcalkins Jul 08 '22 at 17:35
  • I tried your solution. Created the TypeConverter, the extension method and added to Service. Now `ModelState` is not valid anymore. And `DateOfBirth` is still `null`. – UchiTesting Jul 10 '22 at 19:59

2 Answers2

0

As I see you writed converter from json string while request is sending via formdata.

Actually it looks like a bug in asp net core, so simplest way for me is just using DateTime and then convert it to dateonly. Or you can try to write custom model binder for dateonly

P.S. as I understand issue with automatic binding already opened https://github.com/dotnet/aspnetcore/issues/34591

Buka
  • 59
  • 6
0

The First question: When you send the request,The data would not be serialized if it was in Request.Form,so your json related settings would not work.You could create a ViewModel and map your targetmodel with automapper

The Second question: Because the property is nullable,if you want to keep it nullable,you need to add the [required]attribute on it,for more details,you could read this document: https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-6.0#non-nullable-reference-types-and-the-required-attribute

I tried as below:

Model:

 public class Student
    {
        public Guid StudentId { get; set; }
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
        public DateOnly? DateOfBirth { get; set; }
    }
    public class StudentVM
    {
        public Guid? StudentId { get; set; }
        
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
        
        public DateTime DateOfBirth { get; set; }
    }

Create Map

public class AutoMapperProfiles : Profile
    {
        public AutoMapperProfiles()
        {

            CreateMap<StudentVM, Student>().ForMember(des=>des.DateOfBirth,o=>o.MapFrom(src=>DateOnly.FromDateTime(src.DateOfBirth))).ReverseMap();  
        }
    }

Set in Program.cs:

builder.Services.AddAutoMapper(typeof(Program));

Result: enter image description here

Ruikai Feng
  • 6,823
  • 1
  • 2
  • 11