From 52cc1ef81faac829fa4b93891eee0f9ed1e3b9a6 Mon Sep 17 00:00:00 2001 From: Andy Mantell <134642+andymantell@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:32:43 +0100 Subject: [PATCH 1/2] Add single choice question type --- .../Forms/FormElementSingleChoiceModel.cs | 34 ++++++++++ .../Forms/FormsAnswerSetSummary.cshtml.cs | 1 + .../Pages/Forms/FormsQuestionPage.cshtml.cs | 5 ++ .../Forms/_FormElementSingleChoice.cshtml | 67 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs create mode 100644 Frontend/CO.CDP.OrganisationApp/Pages/Forms/_FormElementSingleChoice.cshtml diff --git a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs new file mode 100644 index 000000000..308eac38b --- /dev/null +++ b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs @@ -0,0 +1,34 @@ +using CO.CDP.OrganisationApp.Models; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace CO.CDP.OrganisationApp.Pages.Forms; + +public class FormElementSingleChoiceModel : FormElementModel, IValidatableObject +{ + [BindProperty] + public string? SelectedOption { get; set; } + + public override FormAnswer? GetAnswer() + { + return string.IsNullOrWhiteSpace(SelectedOption) ? null : new FormAnswer { OptionValue = SelectedOption }; + } + + public override void SetAnswer(FormAnswer? answer) + { + if (answer?.OptionValue != null) + { + SelectedOption = answer.OptionValue; + } + } + + public IEnumerable Validate(ValidationContext validationContext) + { + + if (string.IsNullOrWhiteSpace(SelectedOption)) + { + yield return new ValidationResult("All information is required on this page", new[] { nameof(SelectedOption) }); + } + } +} \ No newline at end of file diff --git a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsAnswerSetSummary.cshtml.cs b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsAnswerSetSummary.cshtml.cs index 1c3ce13d3..7319c381f 100644 --- a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsAnswerSetSummary.cshtml.cs +++ b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsAnswerSetSummary.cshtml.cs @@ -185,6 +185,7 @@ private async Task InitAndVerifyPage() FormQuestionType.YesOrNo => answer.BoolValue.HasValue ? (answer.BoolValue == true ? "yes" : "no") : "", FormQuestionType.Date => answer.DateValue.HasValue ? answer.DateValue.Value.ToString("dd/MM/yyyy") : "", FormQuestionType.Address => answer.AddressValue != null ? $"{answer.AddressValue.StreetAddress}, {answer.AddressValue.Locality}, {answer.AddressValue.PostalCode}, {answer.AddressValue.CountryName}" : "", + FormQuestionType.SingleChoice => answer.OptionValue ?? "", _ => "" }; diff --git a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsQuestionPage.cshtml.cs b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsQuestionPage.cshtml.cs index a22378ada..4318bbe83 100644 --- a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsQuestionPage.cshtml.cs +++ b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormsQuestionPage.cshtml.cs @@ -44,6 +44,8 @@ public class FormsQuestionPageModel( [BindProperty] public FormElementAddressModel? AddressModel { get; set; } [BindProperty] + public FormElementSingleChoiceModel? SingleChoiceModel { get; set; } + [BindProperty] public FormElementCheckBoxInputModel? CheckBoxModel { get; set; } [BindProperty(SupportsGet = true)] @@ -158,6 +160,7 @@ public async Task> GetAnswers() FormQuestionType.YesOrNo => answer.Answer?.BoolValue.HasValue == true ? (answer.Answer.BoolValue == true ? "yes" : "no") : "", FormQuestionType.Date => answer.Answer?.DateValue.HasValue == true ? answer.Answer.DateValue.Value.ToString("dd/MM/yyyy") : "", FormQuestionType.Address => answer.Answer?.AddressValue != null ? answer.Answer.AddressValue.ToHtmlString() : "", + FormQuestionType.SingleChoice => answer.Answer?.OptionValue ?? "", _ => "" }; @@ -227,6 +230,7 @@ public bool PreviousQuestionHasNonUKAddressAnswer() { FormQuestionType.CheckBox, "_FormElementCheckBoxInput" }, { FormQuestionType.Address, "_FormElementAddress" }, { FormQuestionType.MultiLine, "_FormElementMultiLineInput" }, + { FormQuestionType.SingleChoice, "_FormElementSingleChoice" }, }; if (formQuestionPartials.TryGetValue(question.Type, out var partialView)) @@ -252,6 +256,7 @@ public bool PreviousQuestionHasNonUKAddressAnswer() FormQuestionType.Date => DateInputModel ?? new FormElementDateInputModel(), FormQuestionType.CheckBox => CheckBoxModel ?? new FormElementCheckBoxInputModel(), FormQuestionType.Address => AddressModel ?? new FormElementAddressModel(), + FormQuestionType.SingleChoice => SingleChoiceModel ?? new FormElementSingleChoiceModel(), _ => throw new NotImplementedException($"Forms question: {question.Type} is not supported"), }; diff --git a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/_FormElementSingleChoice.cshtml b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/_FormElementSingleChoice.cshtml new file mode 100644 index 000000000..f5cbf9a53 --- /dev/null +++ b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/_FormElementSingleChoice.cshtml @@ -0,0 +1,67 @@ +@model FormElementSingleChoiceModel + +@{ + var hasError = ((TagBuilder)Html.ValidationMessageFor(m => m.SelectedOption)).HasInnerHtml; + string ariaDescribedby = ""; + + if (!string.IsNullOrWhiteSpace(Model.Description)) + { + ariaDescribedby += "SelectedOption-description"; + } + + if (hasError) + { + ariaDescribedby += " SelectedOption-error"; + } +} + +
+
+ @if (!string.IsNullOrWhiteSpace(Model.Heading)) + { + +

+ @Model.Heading + @if (!string.IsNullOrWhiteSpace(Model.Caption)) + { + @Model.Caption + } +

+
+ } + + @if (!string.IsNullOrWhiteSpace(Model.Description)) + { +
+ @Html.Raw(Model.Description) +
+ } + + @if (hasError) + { +

+ Error: + @Html.ValidationMessageFor(m => m.SelectedOption) +

+ } + +
+ @{ + var index = 0; + if (Model.Options?.Choices != null ) { + foreach(var value in Model.Options.Choices) + { + var id = index == 0 ? "SelectedOption" : $"SelectedOption_{index}"; + +
+ + +
+ + index++; + } + } + } +
+
+
\ No newline at end of file From 9b03f8f662d91a8a18b1a5342e127b4499a07c02 Mon Sep 17 00:00:00 2001 From: Andy Mantell <134642+andymantell@users.noreply.github.com> Date: Fri, 27 Sep 2024 12:31:13 +0100 Subject: [PATCH 2/2] Tests for SingleChoice field type --- .../Forms/FormElementSingleChoiceModelTest.cs | 92 +++++++++++++++++++ .../Forms/FormElementSingleChoiceModel.cs | 22 ++++- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 Frontend/CO.CDP.OrganisationApp.Tests/Pages/Forms/FormElementSingleChoiceModelTest.cs diff --git a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/Forms/FormElementSingleChoiceModelTest.cs b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/Forms/FormElementSingleChoiceModelTest.cs new file mode 100644 index 000000000..94eed3317 --- /dev/null +++ b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/Forms/FormElementSingleChoiceModelTest.cs @@ -0,0 +1,92 @@ +using CO.CDP.OrganisationApp.Models; +using CO.CDP.OrganisationApp.Pages.Forms; +using FluentAssertions; +using System.ComponentModel.DataAnnotations; + +namespace CO.CDP.OrganisationApp.Tests.Pages.Forms; + +public class FormElementSingleChoiceModelTest +{ + private readonly FormElementSingleChoiceModel _model; + + public FormElementSingleChoiceModelTest() + { + _model = new FormElementSingleChoiceModel(); + _model.Options = new FormQuestionOptions() { Choices = ["Option 1", "Option 2", "Option 3"] } ; + } + + [Theory] + [InlineData(null, null)] + [InlineData(" ", null)] + [InlineData("yes", null)] + [InlineData("no", null)] + [InlineData("Option 1", "Option 1")] + [InlineData("Option 2", "Option 2")] + [InlineData("Option 3", "Option 3")] + public void GetAnswer_GetsExpectedFormAnswer(string? input, string? expected) + { + _model.SelectedOption = input; + + var answer = _model.GetAnswer(); + + if (expected == null) + { + answer.Should().BeNull(); + } + else + { + answer.Should().NotBeNull(); + answer!.OptionValue.Should().Be(expected); + } + } + + [Theory] + [InlineData(null, null)] + [InlineData(" ", null)] + [InlineData("yes", null)] + [InlineData("no", null)] + [InlineData("Option 1", "Option 1")] + [InlineData("Option 2", "Option 2")] + [InlineData("Option 3", "Option 3")] + public void SetAnswer_SetsExpectedOption(string? selectedOption, string? expected) + { + var answer = new FormAnswer { OptionValue = selectedOption }; + + _model.SetAnswer(answer); + + if (expected == null) + { + _model.SelectedOption.Should().BeNull(); + } + else + { + _model.SelectedOption.Should().NotBeNull(); + _model.SelectedOption.Should().Be(expected); + } + } + + [Theory] + [InlineData(null, "Select an option")] + [InlineData(" ", "Select an option")] + [InlineData("yes", "Invalid option selected")] + [InlineData("no", "Invalid option selected")] + [InlineData("Option 1", null)] + [InlineData("Option 2", null)] + [InlineData("Option 3", null)] + public void Validate_ReturnsExpectedResults(string? selectedOption, string? expectedErrorMessage) + { + _model.SelectedOption = selectedOption; + + var validationResults = _model.Validate(new ValidationContext(_model)).ToList(); + + if (expectedErrorMessage != null) + { + validationResults.Should().ContainSingle(); + validationResults.First().ErrorMessage.Should().Be(expectedErrorMessage); + } + else + { + validationResults.Should().BeEmpty(); + } + } +} \ No newline at end of file diff --git a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs index 308eac38b..72587697e 100644 --- a/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs +++ b/Frontend/CO.CDP.OrganisationApp/Pages/Forms/FormElementSingleChoiceModel.cs @@ -12,12 +12,21 @@ public class FormElementSingleChoiceModel : FormElementModel, IValidatableObject public override FormAnswer? GetAnswer() { - return string.IsNullOrWhiteSpace(SelectedOption) ? null : new FormAnswer { OptionValue = SelectedOption }; + if( + SelectedOption != null + && Options?.Choices != null + && Options.Choices.Contains(SelectedOption) + ) + { + return new FormAnswer { OptionValue = SelectedOption }; + } + + return null; } public override void SetAnswer(FormAnswer? answer) { - if (answer?.OptionValue != null) + if (answer?.OptionValue != null && Options?.Choices != null && Options.Choices.Contains(answer.OptionValue)) { SelectedOption = answer.OptionValue; } @@ -28,7 +37,14 @@ public IEnumerable Validate(ValidationContext validationContex if (string.IsNullOrWhiteSpace(SelectedOption)) { - yield return new ValidationResult("All information is required on this page", new[] { nameof(SelectedOption) }); + yield return new ValidationResult("Select an option", new[] { nameof(SelectedOption) }); + yield break; + } + + if(Options?.Choices == null || (SelectedOption != null && !Options.Choices.Contains(SelectedOption))) + { + yield return new ValidationResult("Invalid option selected", new[] { nameof(SelectedOption) }); + yield break; } } } \ No newline at end of file