Skip to content

Commit

Permalink
Add mechanism of overriding name used for date input error messages
Browse files Browse the repository at this point in the history
Closes #282
  • Loading branch information
gunndabad committed Oct 14, 2024
1 parent f55fa75 commit 3436312
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 26 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

### `DateInputAttribute`
This attribute can be added to properties that are model bound from date input components. It allows overriding the prefix used for error messages e.g.
```cs
[DateInput(ErrorMessagePrefix = "Your date of birth")]
public DateOnly? DateOfBirth { get; set; }
```

### `asp-for` attributes
The `asp-for` attributes have been deprecated; the `for` attribute should be used instead.

Expand Down
6 changes: 5 additions & 1 deletion samples/Samples.DateInput/Pages/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
}

<form asp-page="Index">
<govuk-date-input asp-for="DateOfBirth" />
<govuk-date-input asp-for="DateOfBirth">
<govuk-date-input-fieldset>
<govuk-date-input-fieldset-legend is-page-heading="true" class="govuk-fieldset__legend--l" />
</govuk-date-input-fieldset>
</govuk-date-input>

<govuk-button type="submit">Save</govuk-button>
</form>
4 changes: 3 additions & 1 deletion samples/Samples.DateInput/Pages/Index.cshtml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using GovUk.Frontend.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using NodaTime;
Expand All @@ -8,7 +9,8 @@ namespace Samples.DateInput.Pages;
public class IndexModel : PageModel
{
[BindProperty]
[Display(Name = "Date of birth")]
[Display(Name = "What is your date of birth?")]
[DateInput(ErrorMessagePrefix = "Your date of birth")]
[Required(ErrorMessage = "Enter your date of birth")]
public LocalDate? DateOfBirth { get; set; }

Expand Down
20 changes: 20 additions & 0 deletions src/GovUk.Frontend.AspNetCore/DateInputAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using GovUk.Frontend.AspNetCore.ModelBinding;

namespace GovUk.Frontend.AspNetCore;

/// <summary>
/// An attribute that can specify the error message prefix to use in model binding from date input components.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class DateInputAttribute : Attribute
{
/// <summary>
/// Gets or sets the prefix used in error messages.
/// </summary>
/// <remarks>
/// This prefix is used at the start of error messages produced by <see cref="DateInputModelBinder"/>
/// e.g. <c>{ErrorMessagePrefix} must be a real date</c>
/// </remarks>
public string? ErrorMessagePrefix { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public ConfigureMvcOptions(IOptions<GovUkFrontendAspNetCoreOptions> gfaOptionsAc
public void Configure(MvcOptions options)
{
options.ModelBinderProviders.Insert(2, new DateInputModelBinderProvider(_gfaOptionsAccessor));
options.ModelMetadataDetailsProviders.Add(new GovUkFrontendAspNetCoreMetadataDetailsProvider());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ namespace GovUk.Frontend.AspNetCore.ModelBinding;

internal class DateInputModelConverterModelBinder : IModelBinder
{
private const string DayComponentName = "Day";
private const string MonthComponentName = "Month";
private const string YearComponentName = "Year";
private const string DayInputName = "Day";
private const string MonthInputName = "Month";
private const string YearInputName = "Year";

private readonly DateInputModelConverter _dateInputModelConverter;
private readonly IOptions<GovUkFrontendAspNetCoreOptions> _optionsAccessor;
Expand All @@ -35,9 +35,9 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
throw new InvalidOperationException($"Cannot bind {modelType.Name}.");
}

var dayModelName = $"{bindingContext.ModelName}.{DayComponentName}";
var monthModelName = $"{bindingContext.ModelName}.{MonthComponentName}";
var yearModelName = $"{bindingContext.ModelName}.{YearComponentName}";
var dayModelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, DayInputName);
var monthModelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, MonthInputName);
var yearModelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, YearInputName);

var dayValueProviderResult = bindingContext.ValueProvider.GetValue(dayModelName);
var monthValueProviderResult = bindingContext.ValueProvider.GetValue(monthModelName);
Expand Down Expand Up @@ -97,27 +97,31 @@ internal static string GetModelStateErrorMessage(DateInputParseErrors parseError
Debug.Assert(parseErrors != DateInputParseErrors.None);
Debug.Assert(parseErrors != (DateInputParseErrors.MissingDay | DateInputParseErrors.MissingMonth | DateInputParseErrors.MissingYear));

var displayName = modelMetadata.DisplayName ?? modelMetadata.PropertyName;
var dateInputModelMetadata = modelMetadata.AdditionalValues.TryGetValue(typeof(DateInputModelMetadata), out var metadataObj) &&
metadataObj is DateInputModelMetadata dimm ? dimm :
null;

var missingComponents = new List<string>();
var displayName = dateInputModelMetadata?.ErrorMessagePrefix ?? modelMetadata.DisplayName ?? modelMetadata.PropertyName;

var missingFields = new List<string>();

if ((parseErrors & DateInputParseErrors.MissingDay) != 0)
{
missingComponents.Add("day");
missingFields.Add("day");
}
if ((parseErrors & DateInputParseErrors.MissingMonth) != 0)
{
missingComponents.Add("month");
missingFields.Add("month");
}
if ((parseErrors & DateInputParseErrors.MissingYear) != 0)
{
missingComponents.Add("year");
missingFields.Add("year");
}

if (missingComponents.Count > 0)
if (missingFields.Count > 0)
{
Debug.Assert(missingComponents.Count <= 2);
return $"{displayName} must include a {string.Join(" and ", missingComponents)}";
Debug.Assert(missingFields.Count <= 2);
return $"{displayName} must include a {string.Join(" and ", missingFields)}";
}

return $"{displayName} must be a real date";
Expand Down Expand Up @@ -176,7 +180,7 @@ bool TryParseMonth(string value, out int result)

if (!int.TryParse(value, out result) && acceptMonthNames)
{
result = value.ToLower() switch
result = value.ToLowerInvariant() switch
{
"jan" => 1,
"january" => 1,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace GovUk.Frontend.AspNetCore.ModelBinding;

internal sealed class DateInputModelMetadata
{
public string? ErrorMessagePrefix { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace GovUk.Frontend.AspNetCore.ModelBinding;

/// <summary>
/// The errors that occurred when parsing the components of a date input component.
/// The errors that occurred when parsing the fields of a date input component.
/// </summary>
[Flags]
public enum DateInputParseErrors
Expand All @@ -14,32 +14,32 @@ public enum DateInputParseErrors
None = 0,

/// <summary>
/// The year component is missing.
/// The year field is missing.
/// </summary>
MissingYear = 1,

/// <summary>
/// The year component is invalid.
/// The year field is invalid.
/// </summary>
InvalidYear = 2,

/// <summary>
/// The month component is missing.
/// The month field is missing.
/// </summary>
MissingMonth = 4,

/// <summary>
/// The month component is invalid.
/// The month field is invalid.
/// </summary>
InvalidMonth = 8,

/// <summary>
/// The day component is missing.
/// The day field is missing.
/// </summary>
MissingDay = 16,

/// <summary>
/// The day component is invalid.
/// The day field is invalid.
/// </summary>
InvalidDay = 32
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;

namespace GovUk.Frontend.AspNetCore.ModelBinding;

internal class GovUkFrontendAspNetCoreMetadataDetailsProvider : IMetadataDetailsProvider, IDisplayMetadataProvider
{
public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
{
var dateOnlyMetadataAttribute = context.Attributes.OfType<DateInputAttribute>().FirstOrDefault();

if (dateOnlyMetadataAttribute is not null)
{
var dateOnlyMetadata = new DateInputModelMetadata()
{
ErrorMessagePrefix = dateOnlyMetadataAttribute.ErrorMessagePrefix
};

context.DisplayMetadata.AdditionalValues.Add(typeof(DateInputModelMetadata), dateOnlyMetadata);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using GovUk.Frontend.AspNetCore.ModelBinding;
using GovUk.Frontend.AspNetCore.Tests.Infrastructure;
Expand Down Expand Up @@ -240,6 +241,41 @@ public void GetModelStateErrorMessage(DateInputParseErrors parseErrors, string e
Assert.Equal(expectedMessage, result);
}

[Theory]
[InlineData(DateInputParseErrors.MissingYear, "Your date of birth must include a year")]
[InlineData(DateInputParseErrors.InvalidYear, "Your date of birth must be a real date")]
[InlineData(DateInputParseErrors.MissingMonth, "Your date of birth must include a month")]
[InlineData(DateInputParseErrors.InvalidMonth, "Your date of birth must be a real date")]
[InlineData(DateInputParseErrors.InvalidDay, "Your date of birth must be a real date")]
[InlineData(DateInputParseErrors.MissingDay, "Your date of birth must include a day")]
[InlineData(DateInputParseErrors.MissingYear | DateInputParseErrors.MissingMonth, "Your date of birth must include a month and year")]
[InlineData(DateInputParseErrors.MissingYear | DateInputParseErrors.MissingDay, "Your date of birth must include a day and year")]
[InlineData(DateInputParseErrors.MissingMonth | DateInputParseErrors.MissingDay, "Your date of birth must include a day and month")]
[InlineData(DateInputParseErrors.InvalidYear | DateInputParseErrors.InvalidMonth, "Your date of birth must be a real date")]
[InlineData(DateInputParseErrors.InvalidYear | DateInputParseErrors.InvalidMonth | DateInputParseErrors.InvalidDay, "Your date of birth must be a real date")]
[InlineData(DateInputParseErrors.InvalidMonth | DateInputParseErrors.InvalidDay, "Your date of birth must be a real date")]
public void GetModelStateErrorMessageWithDateInputMetadata(DateInputParseErrors parseErrors, string expectedMessage)
{
// Arrange
var dateInputModelMetadata = new DateInputModelMetadata()
{
ErrorMessagePrefix = "Your date of birth"
};

var modelMetadata = new DisplayNameModelMetadata(
"Date of birth",
additionalValues: new Dictionary<object, object>()
{
{ typeof(DateInputModelMetadata), dateInputModelMetadata }
});

// Act
var result = DateInputModelConverterModelBinder.GetModelStateErrorMessage(parseErrors, modelMetadata);

// Assert
Assert.Equal(expectedMessage, result);
}

[Theory]
[InlineData("", "4", "2020", DateInputParseErrors.MissingDay)]
[InlineData("1", "", "2020", DateInputParseErrors.MissingMonth)]
Expand Down Expand Up @@ -345,13 +381,14 @@ private static ActionContext CreateActionContextWithServices()

private class DisplayNameModelMetadata : ModelMetadata
{
public DisplayNameModelMetadata(string displayName)
public DisplayNameModelMetadata(string displayName, IReadOnlyDictionary<object, object>? additionalValues = null)
: base(ModelMetadataIdentity.ForType(typeof(DateOnly?)))
{
DisplayName = displayName;
AdditionalValues = additionalValues ?? new Dictionary<object, object>();
}

public override IReadOnlyDictionary<object, object> AdditionalValues => throw new NotImplementedException();
public override IReadOnlyDictionary<object, object> AdditionalValues { get; }

public override ModelPropertyCollection Properties => throw new NotImplementedException();

Expand Down

0 comments on commit 3436312

Please sign in to comment.