From 3436312d8bc4443b350ba684986e9b34be459691 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Mon, 14 Oct 2024 11:11:38 +0100 Subject: [PATCH] Add mechanism of overriding name used for date input error messages Closes #282 --- CHANGELOG.md | 7 ++++ samples/Samples.DateInput/Pages/Index.cshtml | 6 ++- .../Samples.DateInput/Pages/Index.cshtml.cs | 4 +- .../DateInputAttribute.cs | 20 +++++++++ .../GovUkFrontendAspNetCoreExtensions.cs | 1 + .../DateInputModelConverterModelBinder.cs | 34 ++++++++------- .../ModelBinding/DateInputModelMetadata.cs | 6 +++ .../ModelBinding/DateInputParseErrors.cs | 14 +++---- ...ontendAspNetCoreMetadataDetailsProvider.cs | 22 ++++++++++ .../ModelBinding/DateInputModelBinderTests.cs | 41 ++++++++++++++++++- 10 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 src/GovUk.Frontend.AspNetCore/DateInputAttribute.cs create mode 100644 src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelMetadata.cs create mode 100644 src/GovUk.Frontend.AspNetCore/ModelBinding/GovUkFrontendAspNetCoreMetadataDetailsProvider.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ceb78e..0b45a01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/samples/Samples.DateInput/Pages/Index.cshtml b/samples/Samples.DateInput/Pages/Index.cshtml index 1d820b6a..7316f24b 100644 --- a/samples/Samples.DateInput/Pages/Index.cshtml +++ b/samples/Samples.DateInput/Pages/Index.cshtml @@ -5,7 +5,11 @@ }
- + + + + + Save diff --git a/samples/Samples.DateInput/Pages/Index.cshtml.cs b/samples/Samples.DateInput/Pages/Index.cshtml.cs index 0f2a86ac..7513fbbc 100644 --- a/samples/Samples.DateInput/Pages/Index.cshtml.cs +++ b/samples/Samples.DateInput/Pages/Index.cshtml.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using GovUk.Frontend.AspNetCore; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NodaTime; @@ -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; } diff --git a/src/GovUk.Frontend.AspNetCore/DateInputAttribute.cs b/src/GovUk.Frontend.AspNetCore/DateInputAttribute.cs new file mode 100644 index 00000000..35b0430f --- /dev/null +++ b/src/GovUk.Frontend.AspNetCore/DateInputAttribute.cs @@ -0,0 +1,20 @@ +using System; +using GovUk.Frontend.AspNetCore.ModelBinding; + +namespace GovUk.Frontend.AspNetCore; + +/// +/// An attribute that can specify the error message prefix to use in model binding from date input components. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)] +public sealed class DateInputAttribute : Attribute +{ + /// + /// Gets or sets the prefix used in error messages. + /// + /// + /// This prefix is used at the start of error messages produced by + /// e.g. {ErrorMessagePrefix} must be a real date + /// + public string? ErrorMessagePrefix { get; set; } +} diff --git a/src/GovUk.Frontend.AspNetCore/GovUkFrontendAspNetCoreExtensions.cs b/src/GovUk.Frontend.AspNetCore/GovUkFrontendAspNetCoreExtensions.cs index 5ba12afe..9ffb5f9f 100644 --- a/src/GovUk.Frontend.AspNetCore/GovUkFrontendAspNetCoreExtensions.cs +++ b/src/GovUk.Frontend.AspNetCore/GovUkFrontendAspNetCoreExtensions.cs @@ -95,6 +95,7 @@ public ConfigureMvcOptions(IOptions gfaOptionsAc public void Configure(MvcOptions options) { options.ModelBinderProviders.Insert(2, new DateInputModelBinderProvider(_gfaOptionsAccessor)); + options.ModelMetadataDetailsProviders.Add(new GovUkFrontendAspNetCoreMetadataDetailsProvider()); } } } diff --git a/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelConverterModelBinder.cs b/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelConverterModelBinder.cs index 3aeefe92..c6c48027 100644 --- a/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelConverterModelBinder.cs +++ b/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelConverterModelBinder.cs @@ -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 _optionsAccessor; @@ -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); @@ -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(); + var displayName = dateInputModelMetadata?.ErrorMessagePrefix ?? modelMetadata.DisplayName ?? modelMetadata.PropertyName; + + var missingFields = new List(); 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"; @@ -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, diff --git a/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelMetadata.cs b/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelMetadata.cs new file mode 100644 index 00000000..2aa757dd --- /dev/null +++ b/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputModelMetadata.cs @@ -0,0 +1,6 @@ +namespace GovUk.Frontend.AspNetCore.ModelBinding; + +internal sealed class DateInputModelMetadata +{ + public string? ErrorMessagePrefix { get; set; } +} diff --git a/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputParseErrors.cs b/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputParseErrors.cs index 157dfbd5..1df70624 100644 --- a/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputParseErrors.cs +++ b/src/GovUk.Frontend.AspNetCore/ModelBinding/DateInputParseErrors.cs @@ -3,7 +3,7 @@ namespace GovUk.Frontend.AspNetCore.ModelBinding; /// -/// 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. /// [Flags] public enum DateInputParseErrors @@ -14,32 +14,32 @@ public enum DateInputParseErrors None = 0, /// - /// The year component is missing. + /// The year field is missing. /// MissingYear = 1, /// - /// The year component is invalid. + /// The year field is invalid. /// InvalidYear = 2, /// - /// The month component is missing. + /// The month field is missing. /// MissingMonth = 4, /// - /// The month component is invalid. + /// The month field is invalid. /// InvalidMonth = 8, /// - /// The day component is missing. + /// The day field is missing. /// MissingDay = 16, /// - /// The day component is invalid. + /// The day field is invalid. /// InvalidDay = 32 } diff --git a/src/GovUk.Frontend.AspNetCore/ModelBinding/GovUkFrontendAspNetCoreMetadataDetailsProvider.cs b/src/GovUk.Frontend.AspNetCore/ModelBinding/GovUkFrontendAspNetCoreMetadataDetailsProvider.cs new file mode 100644 index 00000000..57369dd1 --- /dev/null +++ b/src/GovUk.Frontend.AspNetCore/ModelBinding/GovUkFrontendAspNetCoreMetadataDetailsProvider.cs @@ -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().FirstOrDefault(); + + if (dateOnlyMetadataAttribute is not null) + { + var dateOnlyMetadata = new DateInputModelMetadata() + { + ErrorMessagePrefix = dateOnlyMetadataAttribute.ErrorMessagePrefix + }; + + context.DisplayMetadata.AdditionalValues.Add(typeof(DateInputModelMetadata), dateOnlyMetadata); + } + } +} diff --git a/tests/GovUk.Frontend.AspNetCore.Tests/ModelBinding/DateInputModelBinderTests.cs b/tests/GovUk.Frontend.AspNetCore.Tests/ModelBinding/DateInputModelBinderTests.cs index 052773c0..73ca533b 100644 --- a/tests/GovUk.Frontend.AspNetCore.Tests/ModelBinding/DateInputModelBinderTests.cs +++ b/tests/GovUk.Frontend.AspNetCore.Tests/ModelBinding/DateInputModelBinderTests.cs @@ -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; @@ -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() + { + { 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)] @@ -345,13 +381,14 @@ private static ActionContext CreateActionContextWithServices() private class DisplayNameModelMetadata : ModelMetadata { - public DisplayNameModelMetadata(string displayName) + public DisplayNameModelMetadata(string displayName, IReadOnlyDictionary? additionalValues = null) : base(ModelMetadataIdentity.ForType(typeof(DateOnly?))) { DisplayName = displayName; + AdditionalValues = additionalValues ?? new Dictionary(); } - public override IReadOnlyDictionary AdditionalValues => throw new NotImplementedException(); + public override IReadOnlyDictionary AdditionalValues { get; } public override ModelPropertyCollection Properties => throw new NotImplementedException();