From 0a1ebd5502d6b9ddad5beec524e62c481f91c754 Mon Sep 17 00:00:00 2001 From: cairnsj <51908793+cairnsj@users.noreply.github.com> Date: Mon, 30 Oct 2023 11:38:51 +0000 Subject: [PATCH 1/6] Add GdsFilterTagHelper and associated classes --- .../GdsFilterCategoryTagHelperTests.cs | 108 +++++++++++ .../GdsFilterCheckboxTagHelperTests.cs | 175 ++++++++++++++++++ .../TagHelpers/GdsFilterTagHelperTests.cs | 90 +++++++++ .../TagHelpers/GdsFilterCategoryTagHelper.cs | 74 ++++++++ .../TagHelpers/GdsFilterCheckboxTagHelper.cs | 55 ++++++ .../TagHelpers/GdsFilterHelper.cs | 87 +++++++++ 6 files changed, 589 insertions(+) create mode 100644 Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCategoryTagHelperTests.cs create mode 100644 Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCheckboxTagHelperTests.cs create mode 100644 Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterTagHelperTests.cs create mode 100644 Childrens-Social-Care-CPD/TagHelpers/GdsFilterCategoryTagHelper.cs create mode 100644 Childrens-Social-Care-CPD/TagHelpers/GdsFilterCheckboxTagHelper.cs create mode 100644 Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs diff --git a/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCategoryTagHelperTests.cs b/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCategoryTagHelperTests.cs new file mode 100644 index 00000000..26b0b73e --- /dev/null +++ b/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCategoryTagHelperTests.cs @@ -0,0 +1,108 @@ +using Childrens_Social_Care_CPD.TagHelpers; +using FluentAssertions; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.WebEncoders.Testing; +using NUnit.Framework; +using System.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; + +using System.Threading.Tasks; + +namespace Childrens_Social_Care_CPD_Tests.TagHelpers; + +public class GdsFilterCategoryTagHelperTests +{ + private TagHelperContext _tagHelperContext; + private TagHelperOutput _tagHelperOutput; + + [SetUp] + public void SetUp() + { + static Task func(bool result, HtmlEncoder encoder) + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(string.Empty); + return Task.FromResult(tagHelperContent); + } + + _tagHelperContext = new TagHelperContext(new TagHelperAttributeList(), new Dictionary(), "id"); + _tagHelperOutput = new TagHelperOutput("gds-filter-category", new TagHelperAttributeList(), func); + } + + [Test] + public async Task Output_Is_An_Div_Element() + { + // arrange + var sut = new GdsFilterCategoryTagHelper(); + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + + // assert + _tagHelperOutput.TagName.Should().Be("div"); + } + + [Test] + public async Task Index_Should_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCategoryTagHelper(); + sut.Index = 3; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain("id=\"HtmlEncode[[accordion-default-heading-3]]\""); + actual.Should().Contain("id=\"HtmlEncode[[accordion-default-content-3]]\""); + } + + [Test] + public async Task Title_Should_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCategoryTagHelper(); + sut.Title = "Foo"; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain(">Foo<"); + } + + [Test] + public async Task ChildContent_Should_Be_Rendered() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCategoryTagHelper(); + + var content = ""; + Task func(bool result, HtmlEncoder encoder) + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(content); + return Task.FromResult(tagHelperContent); + } + var tagHelperOutput = new TagHelperOutput("gds-filter-category", new TagHelperAttributeList(), func) + { + TagMode = TagMode.StartTagAndEndTag + }; + + // act + await sut.ProcessAsync(_tagHelperContext, tagHelperOutput); + tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain(""); + } +} diff --git a/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCheckboxTagHelperTests.cs b/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCheckboxTagHelperTests.cs new file mode 100644 index 00000000..1fbffd47 --- /dev/null +++ b/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterCheckboxTagHelperTests.cs @@ -0,0 +1,175 @@ +using Childrens_Social_Care_CPD.TagHelpers; +using FluentAssertions; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.WebEncoders.Testing; +using NUnit.Framework; +using System.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; + +using System.Threading.Tasks; + +namespace Childrens_Social_Care_CPD_Tests.TagHelpers; + +public class GdsFilterCheckboxTagHelperTests +{ + private TagHelperContext _tagHelperContext; + private TagHelperOutput _tagHelperOutput; + + [SetUp] + public void SetUp() + { + static Task func(bool result, HtmlEncoder encoder) + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(string.Empty); + return Task.FromResult(tagHelperContent); + } + + _tagHelperContext = new TagHelperContext(new TagHelperAttributeList(), new Dictionary(), "id"); + _tagHelperOutput = new TagHelperOutput("gds-filter-checkbox", new TagHelperAttributeList(), func); + } + + [Test] + public async Task Output_Is_An_Div_Element() + { + // arrange + var sut = new GdsFilterCheckboxTagHelper(); + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + + // assert + _tagHelperOutput.TagName.Should().Be("div"); + } + + [Test] + public async Task Id_Should_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCheckboxTagHelper(); + sut.Id = "foo"; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain("id=\"HtmlEncode[[foo]]\""); + } + + [Test] + public async Task Name_Should_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCheckboxTagHelper(); + sut.Name = "foo"; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain("name=\"HtmlEncode[[foo]]\""); + } + + [Test] + public async Task Value_Should_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCheckboxTagHelper(); + sut.Value = "foo"; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain("value=\"HtmlEncode[[foo]]\""); + } + + [Test] + public async Task Checked_Should_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCheckboxTagHelper(); + sut.Checked = true; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain("checked"); + } + + [Test] + public async Task Checked_Should_Not_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCheckboxTagHelper(); + sut.Checked = false; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().NotContain("checked"); + } + + [Test] + public async Task Output_Is_A_Checkbox() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCheckboxTagHelper(); + sut.Checked = false; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain("type=\"HtmlEncode[[checkbox]]\""); + } + + [Test] + public async Task ChildContent_Should_Be_Rendered() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterCheckboxTagHelper(); + + var content = ""; + Task func(bool result, HtmlEncoder encoder) + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(content); + return Task.FromResult(tagHelperContent); + } + var tagHelperOutput = new TagHelperOutput("gds-filter-checkbox", new TagHelperAttributeList(), func) + { + TagMode = TagMode.StartTagAndEndTag + }; + + // act + await sut.ProcessAsync(_tagHelperContext, tagHelperOutput); + tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain(""); + } +} diff --git a/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterTagHelperTests.cs b/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterTagHelperTests.cs new file mode 100644 index 00000000..bb37ad7f --- /dev/null +++ b/Childrens-Social-Care-CPD-Tests/TagHelpers/GdsFilterTagHelperTests.cs @@ -0,0 +1,90 @@ +using Childrens_Social_Care_CPD.TagHelpers; +using FluentAssertions; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.WebEncoders.Testing; +using NUnit.Framework; +using System.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; + +using System.Threading.Tasks; + +namespace Childrens_Social_Care_CPD_Tests.TagHelpers; + +public class GdsFilterTagHelperTests +{ + private TagHelperContext _tagHelperContext; + private TagHelperOutput _tagHelperOutput; + + [SetUp] + public void SetUp() + { + static Task func(bool result, HtmlEncoder encoder) + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(string.Empty); + return Task.FromResult(tagHelperContent); + } + + _tagHelperContext = new TagHelperContext(new TagHelperAttributeList(), new Dictionary(), "id"); + _tagHelperOutput = new TagHelperOutput("gds-filter-category", new TagHelperAttributeList(), func); + } + + [Test] + public async Task Output_Is_An_Div_Element() + { + // arrange + var sut = new GdsFilterTagHelper(); + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + + // assert + _tagHelperOutput.TagName.Should().Be("div"); + } + + [Test] + public async Task ClearFiltersUri_Should_Be_Used() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterTagHelper(); + sut.ClearFiltersUri = "foo"; + + // act + await sut.ProcessAsync(_tagHelperContext, _tagHelperOutput); + _tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain("href=\"HtmlEncode[[foo]]\""); + } + + [Test] + public async Task ChildContent_Should_Be_Rendered() + { + // arrange + var stringWriter = new StringWriter(); + var sut = new GdsFilterTagHelper(); + + var content = ""; + Task func(bool result, HtmlEncoder encoder) + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(content); + return Task.FromResult(tagHelperContent); + } + var tagHelperOutput = new TagHelperOutput("gds-filter-category", new TagHelperAttributeList(), func) + { + TagMode = TagMode.StartTagAndEndTag + }; + + // act + await sut.ProcessAsync(_tagHelperContext, tagHelperOutput); + tagHelperOutput.WriteTo(stringWriter, new HtmlTestEncoder()); + var actual = stringWriter.ToString(); + + // assert + actual.Should().Contain(""); + } +} diff --git a/Childrens-Social-Care-CPD/TagHelpers/GdsFilterCategoryTagHelper.cs b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterCategoryTagHelper.cs new file mode 100644 index 00000000..fd53fb01 --- /dev/null +++ b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterCategoryTagHelper.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.AspNetCore.Razor.TagHelpers; +using System.Text.Encodings.Web; + +namespace Childrens_Social_Care_CPD.TagHelpers; + +[HtmlTargetElement(TagName)] +[OutputElementHint("div")] +[RestrictChildren(GdsFilterCheckboxTagHelper.TagName)] +public class GdsFilterCategoryTagHelper : TagHelper +{ + internal const string TagName = "gds-filter-category"; + + [HtmlAttributeName("title")] + public string Title { get; set; } + + [HtmlAttributeName("index")] + public int Index { get; set; } + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.TagMode = TagMode.StartTagAndEndTag; + output.AddClass("govuk-accordion__section", HtmlEncoder.Default); + + IHtmlContent content = await output.GetChildContentAsync(); + + output.Content.AppendHtml(RenderCategoryHeader(Index, Title)); + output.Content.AppendHtml(RenderCategoryBody(Index, content)); + } + + private static IHtmlContent RenderCategoryHeader(int index, string categoryTitle) + { + var headerSpan = new TagBuilder("span"); + headerSpan.AddCssClass("govuk-accordion__section-button"); + headerSpan.Attributes.Add("id", $"accordion-default-heading-{index}"); + headerSpan.InnerHtml.AppendHtml(categoryTitle); + + var h2 = new TagBuilder("h2"); + h2.AddCssClass("govuk-accordion__section-heading"); + h2.InnerHtml.AppendHtml(headerSpan); + + var div = new TagBuilder("div"); + div.AddCssClass("govuk-accordion__section-header"); + div.InnerHtml.AppendHtml(h2); + return div; + } + + private static IHtmlContent RenderCategoryBody(int index, IHtmlContent content) + { + var fieldsetDiv = new TagBuilder("div"); + fieldsetDiv.AddCssClass("govuk-checkboxes govuk-checkboxes--small"); + fieldsetDiv.Attributes.Add("data-module", "govuk-checkboxes"); + + fieldsetDiv.InnerHtml.AppendHtml(content); + + var fieldset = new TagBuilder("fieldset"); + fieldset.AddCssClass("govuk-fieldset"); + fieldset.InnerHtml.AppendHtml(fieldsetDiv); + + var contentDiv = new TagBuilder("div"); + contentDiv.AddCssClass("govuk-form-group"); + contentDiv.InnerHtml.AppendHtml(fieldset); + + var div = new TagBuilder("div"); + div.AddCssClass("govuk-accordion__section-content"); + div.Attributes.Add("id", $"accordion-default-content-{index}"); + div.Attributes.Add("aria-labelledby", $"accordion-default-heading-{index}"); + div.InnerHtml.AppendHtml(contentDiv); + return div; + } +} \ No newline at end of file diff --git a/Childrens-Social-Care-CPD/TagHelpers/GdsFilterCheckboxTagHelper.cs b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterCheckboxTagHelper.cs new file mode 100644 index 00000000..e1c4fd2a --- /dev/null +++ b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterCheckboxTagHelper.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.AspNetCore.Razor.TagHelpers; +using System.Text.Encodings.Web; + +namespace Childrens_Social_Care_CPD.TagHelpers; + +[HtmlTargetElement(TagName)] +[OutputElementHint("div")] +public class GdsFilterCheckboxTagHelper : TagHelper +{ + internal const string TagName = "gds-filter-checkbox"; + + [HtmlAttributeName("id")] + public string Id { get; set; } + + [HtmlAttributeName("name")] + public string Name { get; set; } + + [HtmlAttributeName("checked")] + public bool Checked { get; set; } + + [HtmlAttributeName("value")] + public string Value { get; set; } + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.TagMode = TagMode.StartTagAndEndTag; + output.AddClass("govuk-checkboxes__item", HtmlEncoder.Default); + + IHtmlContent content = await output.GetChildContentAsync(); + + var input = new TagBuilder("input"); + input.AddCssClass("govuk-checkboxes__input"); + + input.Attributes.Add("type", "checkbox"); + input.Attributes.Add("id", Id); + input.Attributes.Add("name", Name); + input.Attributes.Add("value", Value); + if (Checked) + { + input.Attributes.Add("checked", ""); + } + + var label = new TagBuilder("label"); + label.AddCssClass("govuk-label govuk-checkboxes__label"); + label.Attributes.Add("for", Id); + label.InnerHtml.AppendHtml(content); + + output.Content.AppendHtml(input); + output.Content.AppendHtml(label); + } +} \ No newline at end of file diff --git a/Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs new file mode 100644 index 00000000..66851e77 --- /dev/null +++ b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.AspNetCore.Razor.TagHelpers; +using System.Text.Encodings.Web; + +namespace Childrens_Social_Care_CPD.TagHelpers; + +[HtmlTargetElement(TagName)] +[OutputElementHint("div")] +[RestrictChildren(GdsFilterCategoryTagHelper.TagName)] +public class GdsFilterTagHelper : TagHelper +{ + internal const string TagName = "gds-filter"; + + [HtmlAttributeName("clear-filters-uri")] + public string ClearFiltersUri { get; set; } + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + output.AddClass("govuk-summary-card", HtmlEncoder.Default); + output.TagName = "div"; + output.TagMode = TagMode.StartTagAndEndTag; + + IHtmlContent content = await output.GetChildContentAsync(); + + output.Content.AppendHtml(RenderHeader(ClearFiltersUri)); + output.Content.AppendHtml(RenderBody(content)); + } + + private static IHtmlContent RenderBody(IHtmlContent content) + { + var button = new TagBuilder("button"); + button.AddCssClass("govuk-button"); + button.Attributes.Add("data-module", "govuk-button"); + button.InnerHtml.Append("Apply filter"); + + var innerDiv = new TagBuilder("div"); + innerDiv.AddCssClass("govuk-accordion"); + innerDiv.Attributes.Add("id", "accordion-default"); + innerDiv.Attributes.Add("data-module", "govuk-accordion"); + innerDiv.InnerHtml.AppendHtml(content); + + var form = new TagBuilder("form"); + form.Attributes.Add("method", "get"); + + form.InnerHtml.AppendHtml(innerDiv); + form.InnerHtml.AppendHtml(button); + + var div = new TagBuilder("div"); + div.AddCssClass("govuk-summary-card__content"); + div.InnerHtml.AppendHtml(form); + return div; + } + + private static IHtmlContent RenderHeader(string path) + { + var h2 = new TagBuilder("h2"); + h2.AddCssClass("govuk-heading-m"); + h2.InnerHtml.Append("Filter"); + + var div = new TagBuilder("div"); + div.AddCssClass("govuk-summary-card__title-wrapper"); + div.InnerHtml.AppendHtml(h2); + div.InnerHtml.AppendHtml(RenderActions(path)); + return div; + } + + private static IHtmlContent RenderActions(string path) + { + var anchor = new TagBuilder("a"); + anchor.AddCssClass("govuk-link"); + + anchor.InnerHtml.Append("Clear filters"); + anchor.Attributes.Add("href", path); + + var li = new TagBuilder("li"); + li.AddCssClass("govuk-summary-card__action"); + li.InnerHtml.AppendHtml(anchor); + + var ul = new TagBuilder("ul"); + ul.AddCssClass("govuk-summary-card__actions"); + ul.InnerHtml.AppendHtml(li); + + return ul; + } +} \ No newline at end of file From 4293b7947ef9572746e498d0f36355c70c49de2c Mon Sep 17 00:00:00 2001 From: cairnsj <51908793+cairnsj@users.noreply.github.com> Date: Mon, 30 Oct 2023 11:50:04 +0000 Subject: [PATCH 2/6] Update GdsFilterHelper.cs --- Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs index 66851e77..f4986f90 100644 --- a/Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs +++ b/Childrens-Social-Care-CPD/TagHelpers/GdsFilterHelper.cs @@ -57,7 +57,7 @@ private static IHtmlContent RenderHeader(string path) { var h2 = new TagBuilder("h2"); h2.AddCssClass("govuk-heading-m"); - h2.InnerHtml.Append("Filter"); + h2.InnerHtml.Append("Filters"); var div = new TagBuilder("div"); div.AddCssClass("govuk-summary-card__title-wrapper"); From deacd1d803e7c49039de834682092613d623ce86 Mon Sep 17 00:00:00 2001 From: cairnsj <51908793+cairnsj@users.noreply.github.com> Date: Mon, 30 Oct 2023 11:51:35 +0000 Subject: [PATCH 3/6] Like for like replacement of the filter components --- .../Views/Resources/Search.cshtml | 94 ++++--------------- 1 file changed, 16 insertions(+), 78 deletions(-) diff --git a/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml b/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml index ff014f9c..c9aaf1a1 100644 --- a/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml +++ b/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml @@ -15,88 +15,26 @@ @{ var tagsDict = new Dictionary(Model.TagInfos.Select((tagInfo, index) => KeyValuePair.Create(index, tagInfo))); } -
-
-
-
-

Filter

-
-
-
-
- -
- @if (Model.SelectedTags != null && Model.SelectedTags.Length > 0) + + @{ + var groupsx = tagsDict.GroupBy(kvp => kvp.Value.Category); + var index = 1; // gov uk js requires starting at 1 for this control + foreach (var group in groupsx) { -
-
-
-

Selected filters

-
- -
- - @{ - var selected = tagsDict.Where(kvp => Model.SelectedTags.Contains(kvp.Key)); - var grouped = selected.GroupBy(x => x.Value.Category); - - foreach (var group in grouped) - { -

@group.Key

-
    - @foreach (var value in group.Select(x => x.Value)) - { - // build link for all other selected - var tags = selected.Where(x => x.Value.TagName != value.TagName).Select(x => $"tags={x.Key}"); - var qsTags = $"&{string.Join("&", tags)}".Trim('&'); - var url = $"/resources?{qsTags}"; - -
  • Remove this filter@value.DisplayName
  • - } -
- } + + @foreach (var kvp in group) + { + var isChecked = Model.SelectedTags.Contains(kvp.Key); + + @kvp.Value.DisplayName + } -
+ + index++; } - -
- - - @{ - var groups = tagsDict.GroupBy(kvp => kvp.Value.Category); - foreach (var group in groups) - { -
-
- - @group.Key - -
- - @foreach (var kvp in group) - { - var isChecked = Model.SelectedTags.Contains(kvp.Key); -
- - -
- } -
-
-
- } - } -
-
-
-
+ } + } @{ From a78b3c19f0bd1fe1161a640aa93932a496c53895 Mon Sep 17 00:00:00 2001 From: cairnsj <51908793+cairnsj@users.noreply.github.com> Date: Tue, 31 Oct 2023 11:51:27 +0000 Subject: [PATCH 4/6] Add feature to dynamically retrieve resource search tags but group. --- .../Controllers/ResourcesControllerTests.cs | 138 ++------------ .../ResourcesDynamicTagsSearchStategyTests.cs | 177 ++++++++++++++++++ .../ResourcesFixedTagsSearchStrategyTests.cs | 171 +++++++++++++++++ .../ResourcesSearchStrategyFactoryTests.cs | 30 +++ .../DataAccess/ResourcesRepositoryTests.cs | 101 +++++++--- .../Childrens-Social-Care-CPD.csproj | 1 + .../Configuration/Features.cs | 6 + .../Controllers/ResourcesController.cs | 93 ++------- .../Resources/IResourcesSearchStrategy.cs | 9 + .../IResourcesSearchStrategyFactory.cs | 6 + .../ResourcesDynamicTagsSearchStategy.cs | 80 ++++++++ .../ResourcesFixedTagsSearchStrategy.cs | 89 +++++++++ .../ResourcesSearchStrategyFactory.cs | 25 +++ .../Core/Resources/TagInfo.cs | 3 + .../DataAccess/ResourcesRepository.cs | 40 +++- .../Models/ResourcesListViewModel.cs | 4 +- .../Views/Resources/Search.cshtml | 40 +++- .../WebApplicationBuilderExtensions.cs | 2 + 18 files changed, 777 insertions(+), 238 deletions(-) create mode 100644 Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesDynamicTagsSearchStategyTests.cs create mode 100644 Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesFixedTagsSearchStrategyTests.cs create mode 100644 Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs create mode 100644 Childrens-Social-Care-CPD/Configuration/Features.cs create mode 100644 Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategy.cs create mode 100644 Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs create mode 100644 Childrens-Social-Care-CPD/Core/Resources/ResourcesDynamicTagsSearchStategy.cs create mode 100644 Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs create mode 100644 Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs create mode 100644 Childrens-Social-Care-CPD/Core/Resources/TagInfo.cs diff --git a/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs b/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs index 82ca31e9..acd1bc04 100644 --- a/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs +++ b/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs @@ -1,52 +1,28 @@ -using Childrens_Social_Care_CPD.Contentful.Models; -using Childrens_Social_Care_CPD.Controllers; -using Childrens_Social_Care_CPD.DataAccess; +using Childrens_Social_Care_CPD.Controllers; +using Childrens_Social_Care_CPD.Core.Resources; using Childrens_Social_Care_CPD.Models; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; -using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; -using static Childrens_Social_Care_CPD.GraphQL.Queries.SearchResourcesByTags; namespace Childrens_Social_Care_CPD_Tests.Controllers; public class ResourcesControllerTests { - private IResourcesRepository _resourcesRepository; - + private IResourcesSearchStrategyFactory _searchStrategyFactory; private ResourcesController _resourcesController; private IRequestCookieCollection _cookies; private HttpContext _httpContext; private HttpRequest _httpRequest; - private ILogger _logger; - private CancellationTokenSource _cancellationTokenSource; - - private void SetPageContent(Content content) - { - _resourcesRepository.FetchRootPage(Arg.Any()).Returns(content); - } - - public void SetSearchResults(ResponseType content) - { - _resourcesRepository - .FindByTags(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(content); - } [SetUp] public void SetUp() { - _cancellationTokenSource = new CancellationTokenSource(); - _resourcesRepository = Substitute.For(); - - _logger = Substitute.For>(); _cookies = Substitute.For(); _httpContext = Substitute.For(); _httpRequest = Substitute.For(); @@ -55,8 +31,9 @@ public void SetUp() _httpRequest.Cookies.Returns(_cookies); _httpContext.Request.Returns(_httpRequest); controllerContext.HttpContext = _httpContext; - - _resourcesController = new ResourcesController(_logger, _resourcesRepository) + + _searchStrategyFactory = Substitute.For(); + _resourcesController = new ResourcesController(_searchStrategyFactory) { ControllerContext = controllerContext, TempData = Substitute.For() @@ -64,107 +41,18 @@ public void SetUp() } [Test] - public async Task Search_With_Empty_Query_Returns_View() - { - // act - var actual = await _resourcesController.Search(query: null, _cancellationTokenSource.Token) as ViewResult; - - // assert - actual.Should().BeOfType(); - actual.Model.Should().BeOfType(); - } - - [Test] - public async Task Search_Page_Resource_Is_Passed_To_View() - { - // arrange - var content = new Content(); - SetPageContent(content); - - // act - var actual = (await _resourcesController.Search(query: null, _cancellationTokenSource.Token) as ViewResult)?.Model as ResourcesListViewModel; - - // assert - actual.Content.Should().Be(content); - } - - [Test] - public async Task Search_Sets_The_ViewState_ContextModel() - { - // act - await _resourcesController.Search(null, _cancellationTokenSource.Token); - var actual = _resourcesController.ViewData["ContextModel"] as ContextModel; - - // assert - actual.Should().NotBeNull(); - actual.Id.Should().Be(string.Empty); - actual.Title.Should().Be("Resources"); - actual.Category.Should().Be("Resources"); - } - - [Test] - public async Task Search_Selected_Tags_Are_Passed_Into_View() - { - // arrange - var query = new ResourcesQuery - { - Page = 1, - Tags = new int[] { 1, 2 } - }; - - // act - var actual = (await _resourcesController.Search(query, _cancellationTokenSource.Token) as ViewResult)?.Model as ResourcesListViewModel; - - // assert - actual.SelectedTags.Should().Equal(query.Tags); - } - - [Test] - public async Task Search_Page_Set_To_Be_In_Bounds() + public async Task Search_Returns_Strategy_Model() { // arrange - var results = new ResponseType() - { - ResourceCollection = new ResourceCollection() - { - Total = 3, - Items = new Collection() - { - new SearchResult(), - new SearchResult(), - new SearchResult(), - } - } - }; - SetSearchResults(results); - var query = new ResourcesQuery - { - Page = 2, - Tags = new int[] { 1, 2 } - }; + var strategy = Substitute.For(); + _searchStrategyFactory.Create().Returns(strategy); + var model = new ResourcesListViewModel(null, null, null, null); + strategy.SearchAsync(Arg.Any(), Arg.Any()).Returns(model); // act - var actual = (await _resourcesController.Search(query, _cancellationTokenSource.Token) as ViewResult)?.Model as ResourcesListViewModel; + var actual = await _resourcesController.Search(query: null) as ViewResult; // assert - actual.CurrentPage.Should().Be(1); - } - - [Test] - public async Task Search_Invalid_Tags_Logs_Warning() - { - // arrange - var tags = new int[] { -1 }; - var query = new ResourcesQuery - { - Page = 2, - Tags = tags - }; - - // act - await _resourcesController.Search(query, _cancellationTokenSource.Token); - - //assert - _logger.ReceivedWithAnyArgs(1).LogWarning(default, args: default); + actual.Model.Should().Be(model); } } \ No newline at end of file diff --git a/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesDynamicTagsSearchStategyTests.cs b/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesDynamicTagsSearchStategyTests.cs new file mode 100644 index 00000000..df845919 --- /dev/null +++ b/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesDynamicTagsSearchStategyTests.cs @@ -0,0 +1,177 @@ +using Childrens_Social_Care_CPD.Contentful.Models; +using Childrens_Social_Care_CPD.Controllers; +using Childrens_Social_Care_CPD.Core.Resources; +using Childrens_Social_Care_CPD.DataAccess; +using NSubstitute; +using NUnit.Framework; +using static Childrens_Social_Care_CPD.GraphQL.Queries.SearchResourcesByTags; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Threading; +using FluentAssertions; + +namespace Childrens_Social_Care_CPD_Tests.Core.Resources; + +public class ResourcesDynamicTagsSearchStategyTests +{ + private IResourcesRepository _resourcesRepository; + + private IResourcesSearchStrategy _sut; + + private void SetTags(IEnumerable tags) + { + _resourcesRepository.GetSearchTagsAsync().Returns(tags); + } + + private void SetPageContent(Content content) + { + _resourcesRepository.FetchRootPageAsync(Arg.Any()).Returns(content); + } + + private void SetSearchResults(ResponseType content) + { + _resourcesRepository + .FindByTagsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(content); + } + + [SetUp] + public void SetUp() + { + _resourcesRepository = Substitute.For(); + SetTags(new List + { + new TagInfo("Cat1", "Tag1", "tag1") + }); + _sut = new ResourcesDynamicTagsSearchStategy(_resourcesRepository); + } + + [Test] + public async Task Search_With_Empty_Query() + { + // act + var actual = await _sut.SearchAsync(query: null); + + // assert + actual.TotalPages.Should().Be(0); + actual.TotalResults.Should().Be(0); + actual.CurrentPage.Should().Be(0); + actual.SelectedTags.Should().BeEmpty(); + actual.Results.Should().BeNull(); + actual.Content.Should().BeNull(); + } + + [Test] + public async Task Search_Returns_Tags() + { + // act + var actual = await _sut.SearchAsync(query: null); + + // assert + actual.TagInfos.Should().HaveCount(1); + } + + [Test] + public async Task Search_Returns_CMS_Content() + { + // arrange + var content = new Content(); + SetPageContent(content); + + // act + var actual = await _sut.SearchAsync(query: null); + + // assert + actual.Content.Should().Be(content); + } + + [Test] + public async Task Search_Selected_Tags_Are_Passed_Into_View() + { + // arrange + var query = new ResourcesQuery + { + Page = 1, + Tags = new string[] { "tag1", "tag2" } + }; + + // act + var actual = await _sut.SearchAsync(query); + + // assert + actual.SelectedTags.Should().Equal(query.Tags); + } + + [Test] + public async Task Search_Page_Set_To_Be_In_Bounds() + { + // arrange + var results = new ResponseType() + { + ResourceCollection = new ResourceCollection() + { + Total = 3, + Items = new Collection() + { + new SearchResult(), + new SearchResult(), + new SearchResult(), + } + } + }; + SetSearchResults(results); + var query = new ResourcesQuery + { + Page = 2, + Tags = new string[] { "tag1", "tag2" } + }; + + // act + var actual = await _sut.SearchAsync(query); + + // assert + actual.CurrentPage.Should().Be(1); + } + + [TestCase("")] + [TestCase("-1")] + [TestCase("x")] + public async Task Invalid_Tags_Are_Not_Queried_For(string value) + { + // arrange + IEnumerable passedTags = null; + var tags = new string[] { value, "tag1" }; + var query = new ResourcesQuery + { + Page = 2, + Tags = tags + }; + await _resourcesRepository.FindByTagsAsync(Arg.Do>(value => passedTags = value), Arg.Any(), Arg.Any(), Arg.Any()); + + // act + var actual = await _sut.SearchAsync(query); + + //assert + passedTags.Should().NotContain(value); + } + + [TestCase("")] + [TestCase("x")] + public async Task Invalid_Tags_Are_Sanitised(string value) + { + // arrange + var tags = new string[] { value, "tag1" }; + var query = new ResourcesQuery + { + Page = 2, + Tags = tags + }; + + // act + var actual = await _sut.SearchAsync(query); + + //assert + actual.SelectedTags.Should().NotContain(value); + } +} diff --git a/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesFixedTagsSearchStrategyTests.cs b/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesFixedTagsSearchStrategyTests.cs new file mode 100644 index 00000000..4630052f --- /dev/null +++ b/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesFixedTagsSearchStrategyTests.cs @@ -0,0 +1,171 @@ +using Childrens_Social_Care_CPD.Contentful.Models; +using Childrens_Social_Care_CPD.DataAccess; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using static Childrens_Social_Care_CPD.GraphQL.Queries.SearchResourcesByTags; +using System.Collections.Generic; +using System.Threading; +using Childrens_Social_Care_CPD.Core.Resources; +using FluentAssertions; +using System.Threading.Tasks; +using Childrens_Social_Care_CPD.Controllers; +using System.Collections.ObjectModel; + +namespace Childrens_Social_Care_CPD_Tests.Core.Resources; + +public class ResourcesFixedTagsSearchStrategyTests +{ + private IResourcesRepository _resourcesRepository; + private ILogger _logger; + private IResourcesSearchStrategy _sut; + + private void SetPageContent(Content content) + { + _resourcesRepository.FetchRootPageAsync(Arg.Any()).Returns(content); + } + + private void SetSearchResults(ResponseType content) + { + _resourcesRepository + .FindByTagsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(content); + } + + [SetUp] + public void SetUp() + { + _logger = Substitute.For>(); + _resourcesRepository = Substitute.For(); + + _sut = new ResourcesFixedTagsSearchStrategy(_resourcesRepository, _logger); + } + + [Test] + public async Task Search_With_Empty_Query() + { + // act + var actual = await _sut.SearchAsync(query: null); + + // assert + actual.TotalPages.Should().Be(0); + actual.TotalResults.Should().Be(0); + actual.CurrentPage.Should().Be(0); + actual.SelectedTags.Should().BeEmpty(); + actual.Results.Should().BeNull(); + actual.Content.Should().BeNull(); + } + + [Test] + public async Task Search_Returns_Tags() + { + // act + var actual = await _sut.SearchAsync(query: null); + + // assert + actual.TagInfos.Should().HaveCount(7); + } + + [Test] + public async Task Search_Returns_CMS_Content() + { + // arrange + var content = new Content(); + SetPageContent(content); + + // act + var actual = await _sut.SearchAsync(query: null); + + // assert + actual.Content.Should().Be(content); + } + + [Test] + public async Task Search_Selected_Tags_Are_Passed_Into_View() + { + // arrange + var query = new ResourcesQuery + { + Page = 1, + Tags = new string[] { "1", "2" } + }; + + // act + var actual = await _sut.SearchAsync(query); + + // assert + actual.SelectedTags.Should().Equal(query.Tags); + } + + [Test] + public async Task Search_Page_Set_To_Be_In_Bounds() + { + // arrange + var results = new ResponseType() + { + ResourceCollection = new ResourceCollection() + { + Total = 3, + Items = new Collection() + { + new SearchResult(), + new SearchResult(), + new SearchResult(), + } + } + }; + SetSearchResults(results); + var query = new ResourcesQuery + { + Page = 2, + Tags = new string[] { "1", "2" } + }; + + // act + var actual = await _sut.SearchAsync(query); + + // assert + actual.CurrentPage.Should().Be(1); + } + + [TestCase("")] + [TestCase("-1")] + [TestCase("x")] + public async Task Invalid_Tags_Are_Not_Queried_For(string value) + { + // arrange + IEnumerable passedTags = null; + var tags = new string[] { value, "5" }; + var query = new ResourcesQuery + { + Page = 2, + Tags = tags + }; + await _resourcesRepository.FindByTagsAsync(Arg.Do>(value => passedTags = value), Arg.Any(), Arg.Any(), Arg.Any()); + + // act + var actual = await _sut.SearchAsync(query); + + //assert + passedTags.Should().NotContain(value); + } + + [TestCase("")] + [TestCase("x")] + public async Task Invalid_Tags_Are_Sanitised(string value) + { + // arrange + var tags = new string[] { value, "5" }; + var query = new ResourcesQuery + { + Page = 2, + Tags = tags + }; + + // act + var actual = await _sut.SearchAsync(query); + + //assert + actual.SelectedTags.Should().NotContain(value); + } +} diff --git a/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs b/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs new file mode 100644 index 00000000..b8e9c525 --- /dev/null +++ b/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs @@ -0,0 +1,30 @@ +using Castle.Core.Logging; +using Childrens_Social_Care_CPD.Configuration; +using Childrens_Social_Care_CPD.Core.Resources; +using Childrens_Social_Care_CPD.DataAccess; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using System; + +namespace Childrens_Social_Care_CPD_Tests.Core.Resources; + +public class ResourcesSearchStrategyFactoryTests +{ + [TestCase(true, typeof(ResourcesDynamicTagsSearchStategy))] + [TestCase(false, typeof(ResourcesFixedTagsSearchStrategy))] + public void Creates_Correct_Strategy(bool isFeatureOn, Type type) + { + // arrange + var featuresConfig = Substitute.For(); + featuresConfig.IsEnabled(Features.ResourcesUseDynamicTags).Returns(isFeatureOn); + var sut = new ResourcesSearchStrategyFactory(featuresConfig, Substitute.For(), Substitute.For>()); + + // act + var actual = sut.Create(); + + // assert + actual.Should().BeOfType(type); + } +} diff --git a/Childrens-Social-Care-CPD-Tests/DataAccess/ResourcesRepositoryTests.cs b/Childrens-Social-Care-CPD-Tests/DataAccess/ResourcesRepositoryTests.cs index 9b3127f9..5fac64a6 100644 --- a/Childrens-Social-Care-CPD-Tests/DataAccess/ResourcesRepositoryTests.cs +++ b/Childrens-Social-Care-CPD-Tests/DataAccess/ResourcesRepositoryTests.cs @@ -15,6 +15,8 @@ using static Childrens_Social_Care_CPD.GraphQL.Queries.SearchResourcesByTags; using System.Collections.ObjectModel; using System; +using Contentful.Core.Models.Management; +using System.Linq; namespace Childrens_Social_Care_CPD_Tests.DataAccess; @@ -25,6 +27,13 @@ public class ResourcesRepositoryTests private ICpdContentfulClient _contentfulClient; private IGraphQLWebSocketClient _gqlClient; + private void SetSearchResults(ResponseType responseType) + { + var response = Substitute.For>(); + response.Data = responseType; + _gqlClient.SendQueryAsync(Arg.Any(), Arg.Any()).Returns(response); + } + [SetUp] public void Setup() { @@ -39,7 +48,7 @@ public void Setup() } [Test] - public async Task FetchRootPage_Returns_Root_Page_Returned_When_Found() + public async Task FetchRootPageAsync_Returns_Root_Page_Returned_When_Found() { // arrange var content = new Content(); @@ -54,14 +63,14 @@ public async Task FetchRootPage_Returns_Root_Page_Returned_When_Found() var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); // act - var result = await sut.FetchRootPage(_cancellationTokenSource.Token); + var result = await sut.FetchRootPageAsync(_cancellationTokenSource.Token); // assert result.Should().Be(content); } [Test] - public async Task FetchRootPage_Returns_Null_When_Root_Page_Not_Found() + public async Task FetchRootPageAsync_Returns_Null_When_Root_Page_Not_Found() { // arrange var collection = new ContentfulCollection @@ -72,21 +81,14 @@ public async Task FetchRootPage_Returns_Null_When_Root_Page_Not_Found() var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); // act - var result = await sut.FetchRootPage(_cancellationTokenSource.Token); + var result = await sut.FetchRootPageAsync(_cancellationTokenSource.Token); // assert result.Should().BeNull(); } - private void SetSearchResults(ResponseType responseType) - { - var response = Substitute.For>(); - response.Data = responseType; - _gqlClient.SendQueryAsync(Arg.Any(), Arg.Any()).Returns(response); - } - [Test] - public async Task FindByTags_Returns_Results() + public async Task FindByTagsAsync_Returns_Results() { // arrange var results = new ResponseType @@ -104,14 +106,14 @@ public async Task FindByTags_Returns_Results() var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); // act - var result = await sut.FindByTags(Array.Empty(), 0, 1, _cancellationTokenSource.Token); + var result = await sut.FindByTagsAsync(Array.Empty(), 0, 1, _cancellationTokenSource.Token); // assert result.Should().Be(results); } [Test] - public async Task FindByTags_Limits_Results() + public async Task FindByTagsAsync_Limits_Results() { // arrange var results = new ResponseType(); @@ -123,7 +125,7 @@ public async Task FindByTags_Limits_Results() var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); // act - await sut.FindByTags(Array.Empty(), 0, 1, _cancellationTokenSource.Token); + await sut.FindByTagsAsync(Array.Empty(), 0, 1, _cancellationTokenSource.Token); // assert dynamic variables = request.Variables; @@ -131,7 +133,7 @@ public async Task FindByTags_Limits_Results() } [Test] - public async Task FindByTags_Skips_Results() + public async Task FindByTagsAsync_Skips_Results() { // arrange var results = new ResponseType(); @@ -143,7 +145,7 @@ public async Task FindByTags_Skips_Results() var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); // act - await sut.FindByTags(Array.Empty(), 5, 1, _cancellationTokenSource.Token); + await sut.FindByTagsAsync(Array.Empty(), 5, 1, _cancellationTokenSource.Token); // assert dynamic variables = request.Variables; @@ -151,7 +153,7 @@ public async Task FindByTags_Skips_Results() } [Test] - public async Task FindByTags_Preview_Flag_Is_False_By_Default() + public async Task FindByTagsAsync_Preview_Flag_Is_False_By_Default() { // arrange var response = Substitute.For>(); @@ -162,7 +164,7 @@ public async Task FindByTags_Preview_Flag_Is_False_By_Default() var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); // act - await sut.FindByTags(Array.Empty(), 5, 1, _cancellationTokenSource.Token); + await sut.FindByTagsAsync(Array.Empty(), 5, 1, _cancellationTokenSource.Token); // assert dynamic variables = request.Variables; @@ -170,7 +172,7 @@ public async Task FindByTags_Preview_Flag_Is_False_By_Default() } [Test] - public async Task FindByTags_Sets_Preview_Flag() + public async Task FindByTagsAsync_Sets_Preview_Flag() { // arrange _applicationConfiguration.ContentfulEnvironment.Returns(new StringConfigSetting(() => ApplicationEnvironment.PreProduction)); @@ -183,10 +185,67 @@ public async Task FindByTags_Sets_Preview_Flag() var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); // act - await sut.FindByTags(Array.Empty(), 5, 1, _cancellationTokenSource.Token); + await sut.FindByTagsAsync(Array.Empty(), 5, 1, _cancellationTokenSource.Token); // assert dynamic variables = request.Variables; (variables.preview as object).Should().Be(true); } + + [Test] + public async Task GetSearchTagsAsync_Strips_Ungrouped_Tags() + { + // arrange + var tags = new List + { + new ContentTag { Name = "Foo", SystemProperties = new SystemProperties { Id = "foo" } }, + new ContentTag { Name = "Topic: Foo", SystemProperties = new SystemProperties { Id = "topicFoo" } }, + }; + _contentfulClient.GetTags().Returns(tags); + var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); + + // act + var actual = await sut.GetSearchTagsAsync(); + + // assert + actual.Any(x => x.DisplayName == "Foo").Should().BeFalse(); + } + + [Test] + public async Task GetSearchTagsAsync_Gets_Valid_Tags() + { + // arrange + var tags = new List + { + new ContentTag { Name = "Topic: Foo", SystemProperties = new SystemProperties { Id = "topicFoo" } }, + }; + _contentfulClient.GetTags().Returns(tags); + var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); + + // act + var actual = await sut.GetSearchTagsAsync(); + + // assert + actual.Should().HaveCount(1); + actual.First().TagName.Should().Be("topicFoo"); + } + + [Test] + public async Task GetSearchTagsAsync_Strips_Unwanted_Grouped_Tags() + { + // arrange + var tags = new List + { + new ContentTag { Name = "Topic: Foo", SystemProperties = new SystemProperties { Id = "topicFoo" } }, + new ContentTag { Name = "Foo: Foo", SystemProperties = new SystemProperties { Id = "fooFoo" } }, + }; + _contentfulClient.GetTags().Returns(tags); + var sut = new ResourcesRepository(_applicationConfiguration, _contentfulClient, _gqlClient); + + // act + var actual = await sut.GetSearchTagsAsync(); + + // assert + actual.Any(x => x.TagName == "fooFoo").Should().BeFalse(); + } } diff --git a/Childrens-Social-Care-CPD/Childrens-Social-Care-CPD.csproj b/Childrens-Social-Care-CPD/Childrens-Social-Care-CPD.csproj index 723ad4a2..285d098d 100644 --- a/Childrens-Social-Care-CPD/Childrens-Social-Care-CPD.csproj +++ b/Childrens-Social-Care-CPD/Childrens-Social-Care-CPD.csproj @@ -11,6 +11,7 @@ + diff --git a/Childrens-Social-Care-CPD/Configuration/Features.cs b/Childrens-Social-Care-CPD/Configuration/Features.cs new file mode 100644 index 00000000..772980a5 --- /dev/null +++ b/Childrens-Social-Care-CPD/Configuration/Features.cs @@ -0,0 +1,6 @@ +namespace Childrens_Social_Care_CPD.Configuration; + +public static class Features +{ + public const string ResourcesUseDynamicTags = "resources-search-use-dynamic-tags"; +} diff --git a/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs b/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs index 895ffad7..bd62688c 100644 --- a/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs +++ b/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs @@ -1,104 +1,39 @@ -using Childrens_Social_Care_CPD.DataAccess; -using Childrens_Social_Care_CPD.GraphQL.Queries; +using Childrens_Social_Care_CPD.Core.Resources; using Childrens_Social_Care_CPD.Models; using Microsoft.AspNetCore.Mvc; namespace Childrens_Social_Care_CPD.Controllers; -public record TagInfo(string Category, string DisplayName, string TagName); - public class ResourcesQuery { - public int[] Tags { get; set; } + public string[] Tags { get; set; } public int Page { get; set; } = 1; -} -public partial class ResourcesController : Controller -{ - private const int PAGE_SIZE = 8; - private readonly ILogger _logger; - private readonly IResourcesRepository _resourcesRepository; - private static readonly List _tagInfos = new() { - new TagInfo("Type", "Case studies", "caseStudies"), - new TagInfo("Type", "CPD", "cpd"), - new TagInfo("Type", "Direct tools", "directTools"), - new TagInfo("Type", "Knowledge articles", "knowledgeArticles"), - new TagInfo("Career stage", "Practitioner", "practitioner"), - new TagInfo("Career stage", "Experienced practitioner", "experiencedPractitioner"), - new TagInfo("Career stage", "Manager", "manager"), - }; - private static readonly IEnumerable _allTags = _tagInfos.Select(x => x.TagName); - - public ResourcesController(ILogger logger, IResourcesRepository resourcesRepository) + public ResourcesQuery() { - _logger = logger; - _resourcesRepository = resourcesRepository; + Tags = Array.Empty(); } +} - private IEnumerable GetQueryTags(int[] tags) - { - if (tags.Length == 0) - { - return _allTags; - } - - if (tags.Any(x => (x < 0) || (x >= _tagInfos.Count))) - { - _logger.LogWarning("Passed tag values do not match known values: {Passed Values}", tags); - return Array.Empty(); - } - - return tags.Select(x => { return _tagInfos[x].TagName; }); - } - - private static Tuple CalculatePageStats(SearchResourcesByTags.ResponseType searchResults, int page) - { - var totalResults = searchResults?.ResourceCollection?.Total ?? 0; - var totalPages = (int)Math.Ceiling((decimal)totalResults / PAGE_SIZE); - - return Tuple.Create(totalResults, totalPages, Math.Min(page, totalPages)); - } +public class ResourcesController : Controller +{ + private readonly IResourcesSearchStrategyFactory _factory; - private static string GetPagingFormatString(int[] tags) + public ResourcesController(IResourcesSearchStrategyFactory factory) { - if (tags.Any()) - { - var tagStrings = tags.Select(x => $"tags={x}"); - var allTags = string.Join("&", tagStrings); - return $"/resources?page={{0}}&{allTags}"; - } - - return $"/resources?page={{0}}"; + _factory = factory; } [Route("resources", Name = "Resource")] [HttpGet] - public async Task Search([FromQuery] ResourcesQuery query, CancellationToken cancellationToken, bool preferencesSet = false) + public async Task Search([FromQuery] ResourcesQuery query, bool preferencesSet = false, CancellationToken cancellationToken = default) { - query ??= new ResourcesQuery(); - query.Tags ??= Array.Empty(); - - var page = Math.Max(query.Page, 1); - var skip = (page - 1) * PAGE_SIZE; - var pageContentTask = _resourcesRepository.FetchRootPage(cancellationToken); - var searchResults = await _resourcesRepository.FindByTags(GetQueryTags(query.Tags), skip, PAGE_SIZE, cancellationToken); - var pageContent = await pageContentTask; - (var totalResults, var totalPages, var currentPage) = CalculatePageStats(searchResults, page); + var strategy = _factory.Create(); var contextModel = new ContextModel(string.Empty, "Resources", "Resources", "Resources", true, preferencesSet); ViewData["ContextModel"] = contextModel; - var viewModel = new ResourcesListViewModel( - pageContent, - searchResults?.ResourceCollection, - _tagInfos, - query.Tags, - currentPage, - totalPages, - totalResults, - GetPagingFormatString(query.Tags) - ); - + var viewModel = await strategy.SearchAsync(query, cancellationToken); return View(viewModel); } -} \ No newline at end of file +} diff --git a/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategy.cs b/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategy.cs new file mode 100644 index 00000000..ce0d83c5 --- /dev/null +++ b/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategy.cs @@ -0,0 +1,9 @@ +using Childrens_Social_Care_CPD.Controllers; +using Childrens_Social_Care_CPD.Models; + +namespace Childrens_Social_Care_CPD.Core.Resources; + +public interface IResourcesSearchStrategy +{ + Task SearchAsync(ResourcesQuery query, CancellationToken cancellationToken = default); +} diff --git a/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs b/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs new file mode 100644 index 00000000..af410870 --- /dev/null +++ b/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs @@ -0,0 +1,6 @@ +namespace Childrens_Social_Care_CPD.Core.Resources; + +public interface IResourcesSearchStrategyFactory +{ + public IResourcesSearchStrategy Create(); +} \ No newline at end of file diff --git a/Childrens-Social-Care-CPD/Core/Resources/ResourcesDynamicTagsSearchStategy.cs b/Childrens-Social-Care-CPD/Core/Resources/ResourcesDynamicTagsSearchStategy.cs new file mode 100644 index 00000000..28878085 --- /dev/null +++ b/Childrens-Social-Care-CPD/Core/Resources/ResourcesDynamicTagsSearchStategy.cs @@ -0,0 +1,80 @@ +using Childrens_Social_Care_CPD.Controllers; +using Childrens_Social_Care_CPD.DataAccess; +using Childrens_Social_Care_CPD.GraphQL.Queries; +using Childrens_Social_Care_CPD.Models; + +namespace Childrens_Social_Care_CPD.Core.Resources; + +internal class ResourcesDynamicTagsSearchStategy : IResourcesSearchStrategy +{ + private const int PAGE_SIZE = 8; + private readonly IResourcesRepository _resourcesRepository; + + public ResourcesDynamicTagsSearchStategy(IResourcesRepository resourcesRepository) + { + _resourcesRepository = resourcesRepository; + } + + private static IEnumerable GetQueryTags(string[] tags, HashSet tagIds) + { + if (tags.Length == 0) + { + return tagIds; + } + + return tagIds.Where(x => tags.Contains(x)); + } + + private static IEnumerable SanitiseTags(IEnumerable tags, HashSet tagIds) + { + return tagIds.Where(x => tags.Contains(x)); + } + + private static Tuple CalculatePageStats(SearchResourcesByTags.ResponseType searchResults, int page) + { + var totalResults = searchResults?.ResourceCollection?.Total ?? 0; + var totalPages = (int)Math.Ceiling((decimal)totalResults / PAGE_SIZE); + + return Tuple.Create(totalResults, totalPages, Math.Min(page, totalPages)); + } + + private static string GetPagingFormatString(IEnumerable tags) + { + if (tags.Any()) + { + var tagStrings = tags.Select(x => $"tags={x}"); + var allTags = string.Join("&", tagStrings); + return $"/resources?page={{0}}&{allTags}"; + } + + return $"/resources?page={{0}}"; + } + + public async Task SearchAsync(ResourcesQuery query, CancellationToken cancellationToken = default) + { + query ??= new ResourcesQuery(); + + var tagInfos = await _resourcesRepository.GetSearchTagsAsync(); + var tagIds = new HashSet(tagInfos.Select(x => x.TagName)); + query.Tags = SanitiseTags(query.Tags, tagIds).ToArray(); + + var page = Math.Max(query.Page, 1); + var skip = (page - 1) * PAGE_SIZE; + + var pageContentTask = _resourcesRepository.FetchRootPageAsync(cancellationToken); + var searchResults = await _resourcesRepository.FindByTagsAsync(GetQueryTags(query.Tags, tagIds), skip, PAGE_SIZE, cancellationToken); + var pageContent = await pageContentTask; + (var totalResults, var totalPages, var currentPage) = CalculatePageStats(searchResults, page); + + return new ResourcesListViewModel( + pageContent, + searchResults?.ResourceCollection, + tagInfos, + query.Tags, + currentPage, + totalPages, + totalResults, + GetPagingFormatString(query.Tags) + ); + } +} \ No newline at end of file diff --git a/Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs b/Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs new file mode 100644 index 00000000..81ecbd87 --- /dev/null +++ b/Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs @@ -0,0 +1,89 @@ +using Childrens_Social_Care_CPD.Controllers; +using Childrens_Social_Care_CPD.DataAccess; +using Childrens_Social_Care_CPD.GraphQL.Queries; +using Childrens_Social_Care_CPD.Models; + +namespace Childrens_Social_Care_CPD.Core.Resources; + +internal class ResourcesFixedTagsSearchStrategy : IResourcesSearchStrategy +{ + private const int PAGE_SIZE = 8; + private readonly ILogger _logger; + private readonly IResourcesRepository _resourcesRepository; + private static readonly List _tagInfos = new() { + new TagInfo("Type", "Case studies", "caseStudies"), + new TagInfo("Type", "CPD", "cpd"), + new TagInfo("Type", "Direct tools", "directTools"), + new TagInfo("Type", "Knowledge articles", "knowledgeArticles"), + new TagInfo("Career stage", "Practitioner", "practitioner"), + new TagInfo("Career stage", "Experienced practitioner", "experiencedPractitioner"), + new TagInfo("Career stage", "Manager", "manager"), + }; + private static readonly HashSet _allTags = _tagInfos.Select(x => x.TagName).ToHashSet(); + + public ResourcesFixedTagsSearchStrategy(IResourcesRepository resourcesRepository, ILogger logger) + { + _resourcesRepository = resourcesRepository; + _logger = logger; + } + + private IEnumerable GetQueryTags(int[] tags) + { + if (tags.Length == 0) + { + return _allTags; + } + + if (tags.Any(x => x < 0 || x >= _tagInfos.Count)) + { + _logger.LogWarning("Passed tag values do not match known values: {Passed Values}", tags); + return Array.Empty(); + } + + return tags.Select(x => { return _tagInfos[x].TagName; }); + } + + private static Tuple CalculatePageStats(SearchResourcesByTags.ResponseType searchResults, int page) + { + var totalResults = searchResults?.ResourceCollection?.Total ?? 0; + var totalPages = (int)Math.Ceiling((decimal)totalResults / PAGE_SIZE); + + return Tuple.Create(totalResults, totalPages, Math.Min(page, totalPages)); + } + + private static string GetPagingFormatString(int[] tags) + { + if (tags.Any()) + { + var tagStrings = tags.Select(x => $"tags={x}"); + var allTags = string.Join("&", tagStrings); + return $"/resources?page={{0}}&{allTags}"; + } + + return $"/resources?page={{0}}"; + } + + public async Task SearchAsync(ResourcesQuery query, CancellationToken cancellationToken = default) + { + query ??= new ResourcesQuery(); + var queryTags = query.Tags.Select(x => int.TryParse(x, out var value) ? value : 0).ToHashSet().ToArray(); + + var page = Math.Max(query.Page, 1); + var skip = (page - 1) * PAGE_SIZE; + var pageContentTask = _resourcesRepository.FetchRootPageAsync(cancellationToken); + var searchResults = await _resourcesRepository.FindByTagsAsync(GetQueryTags(queryTags), skip, PAGE_SIZE, cancellationToken); + var pageContent = await pageContentTask; + (var totalResults, var totalPages, var currentPage) = CalculatePageStats(searchResults, page); + + return new ResourcesListViewModel( + pageContent, + searchResults?.ResourceCollection, + _tagInfos, + queryTags.Select(x => x.ToString()), + currentPage, + totalPages, + totalResults, + GetPagingFormatString(queryTags) + ); + } +} \ No newline at end of file diff --git a/Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs b/Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs new file mode 100644 index 00000000..7c435952 --- /dev/null +++ b/Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs @@ -0,0 +1,25 @@ +using Childrens_Social_Care_CPD.Configuration; +using Childrens_Social_Care_CPD.DataAccess; + +namespace Childrens_Social_Care_CPD.Core.Resources; + +internal class ResourcesSearchStrategyFactory : IResourcesSearchStrategyFactory +{ + private readonly IFeaturesConfig _featuresConfig; + private readonly IResourcesRepository _resourcesRepository; + private readonly ILogger _fixedLogger; + + public ResourcesSearchStrategyFactory(IFeaturesConfig featuresConfig, IResourcesRepository resourcesRepository, ILogger fixedLogger) + { + _featuresConfig = featuresConfig; + _resourcesRepository = resourcesRepository; + _fixedLogger = fixedLogger; + } + + public IResourcesSearchStrategy Create() + { + return _featuresConfig.IsEnabled(Features.ResourcesUseDynamicTags) + ? new ResourcesDynamicTagsSearchStategy(_resourcesRepository) + : new ResourcesFixedTagsSearchStrategy(_resourcesRepository, _fixedLogger) ; + } +} diff --git a/Childrens-Social-Care-CPD/Core/Resources/TagInfo.cs b/Childrens-Social-Care-CPD/Core/Resources/TagInfo.cs new file mode 100644 index 00000000..2e39b4fa --- /dev/null +++ b/Childrens-Social-Care-CPD/Core/Resources/TagInfo.cs @@ -0,0 +1,3 @@ +namespace Childrens_Social_Care_CPD.Core.Resources; + +public record TagInfo(string Category, string DisplayName, string TagName); diff --git a/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs b/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs index f9af87d6..fdc73324 100644 --- a/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs +++ b/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs @@ -1,20 +1,24 @@ using Childrens_Social_Care_CPD.Configuration; using Childrens_Social_Care_CPD.Contentful; using Childrens_Social_Care_CPD.Contentful.Models; +using Childrens_Social_Care_CPD.Core.Resources; using Childrens_Social_Care_CPD.GraphQL.Queries; using Contentful.Core.Search; using GraphQL.Client.Abstractions.Websocket; +using System.Diagnostics; namespace Childrens_Social_Care_CPD.DataAccess; public interface IResourcesRepository { - Task FetchRootPage(CancellationToken cancellationToken = default); - Task FindByTags(IEnumerable tags, int skip, int take, CancellationToken cancellationToken = default); + Task FetchRootPageAsync(CancellationToken cancellationToken = default); + Task FindByTagsAsync(IEnumerable tags, int skip, int take, CancellationToken cancellationToken = default); + Task> GetSearchTagsAsync(); } public class ResourcesRepository : IResourcesRepository { + private static readonly string[] _tagPrefixes = new string[] { "Topic", "Format", "Career stage" }; private readonly ICpdContentfulClient _cpdClient; private readonly IGraphQLWebSocketClient _gqlClient; private readonly bool _isPreview; @@ -26,7 +30,7 @@ public ResourcesRepository(IApplicationConfiguration applicationConfiguration, I _isPreview = ContentfulConfiguration.IsPreviewEnabled(applicationConfiguration); } - public Task FetchRootPage(CancellationToken cancellationToken = default) + public Task FetchRootPageAsync(CancellationToken cancellationToken = default) { var queryBuilder = QueryBuilder.New .ContentTypeIs("content") @@ -38,10 +42,38 @@ public Task FetchRootPage(CancellationToken cancellationToken = default .ContinueWith(x => x.Result.FirstOrDefault()); } - public Task FindByTags(IEnumerable tags, int skip, int take, CancellationToken cancellationToken = default) + public Task FindByTagsAsync(IEnumerable tags, int skip, int take, CancellationToken cancellationToken = default) { return _gqlClient .SendQueryAsync(SearchResourcesByTags.Query(tags, take, skip, _isPreview), cancellationToken) .ContinueWith(x => x.Result.Data); } + + public async Task> GetSearchTagsAsync() + { + var allTags = await _cpdClient.GetTags(); + + var tags = allTags + .Where(x => _tagPrefixes.Any(prefix => x.Name.StartsWith($"{prefix}:"))) + .Select(x => + { + var i = x.Name.IndexOf(':'); + var category = x.Name[..i]; + return KeyValuePair.Create(category, x); + }); + + var list = new List(); + + foreach (var category in _tagPrefixes) + { + list.AddRange( + tags + .Where(x => x.Key == category) + .Select(x => new TagInfo(x.Key, x.Value.Name[(x.Value.Name.IndexOf(':') + 1)..], x.Value.SystemProperties.Id)) + .OrderBy(x => x.TagName) + ); + } + + return list; + } } diff --git a/Childrens-Social-Care-CPD/Models/ResourcesListViewModel.cs b/Childrens-Social-Care-CPD/Models/ResourcesListViewModel.cs index 03957f7a..deb94a7c 100644 --- a/Childrens-Social-Care-CPD/Models/ResourcesListViewModel.cs +++ b/Childrens-Social-Care-CPD/Models/ResourcesListViewModel.cs @@ -1,5 +1,5 @@ using Childrens_Social_Care_CPD.Contentful.Models; -using Childrens_Social_Care_CPD.Controllers; +using Childrens_Social_Care_CPD.Core.Resources; using Childrens_Social_Care_CPD.GraphQL.Queries; namespace Childrens_Social_Care_CPD.Models; @@ -8,7 +8,7 @@ public record ResourcesListViewModel( Content Content, SearchResourcesByTags.ResourceCollection Results, IEnumerable TagInfos, - int[] SelectedTags, + IEnumerable SelectedTags, int CurrentPage = 0, int TotalPages = 0, int TotalResults = 0, diff --git a/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml b/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml index c9aaf1a1..36ae7916 100644 --- a/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml +++ b/Childrens-Social-Care-CPD/Views/Resources/Search.cshtml @@ -1,8 +1,11 @@ -@using Childrens_Social_Care_CPD.Controllers +@using Childrens_Social_Care_CPD.Configuration +@using Childrens_Social_Care_CPD.Core.Resources; @using Microsoft.AspNetCore.Html @model ResourcesListViewModel +@inject IFeaturesConfig featuresConfig + @{ Layout = "_SearchPageLayout"; } @@ -12,11 +15,33 @@ } @section SearchCriteria { - @{ - var tagsDict = new Dictionary(Model.TagInfos.Select((tagInfo, index) => KeyValuePair.Create(index, tagInfo))); - } - + @if (featuresConfig.IsEnabled(Features.ResourcesUseDynamicTags)) + { + + @{ + var groups = Model.TagInfos.GroupBy(x => x.Category); + var index = 1; // gov uk js requires starting at 1 for this control + foreach (var group in groups) + { + + @foreach (var tagInfo in group) + { + var isChecked = Model.SelectedTags.Contains(tagInfo.TagName); + + @tagInfo.DisplayName + + } + + index++; + } + } + + } + else + { + var tagsDict = new Dictionary(Model.TagInfos.Select((tagInfo, index) => KeyValuePair.Create(index, tagInfo))); + @{ var groupsx = tagsDict.GroupBy(kvp => kvp.Value.Category); var index = 1; // gov uk js requires starting at 1 for this control @@ -25,7 +50,7 @@ @foreach (var kvp in group) { - var isChecked = Model.SelectedTags.Contains(kvp.Key); + var isChecked = Model.SelectedTags.Contains(kvp.Key.ToString()); @kvp.Value.DisplayName @@ -34,7 +59,8 @@ index++; } } - + + } } @{ diff --git a/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs b/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs index 8fe96b86..5132234d 100644 --- a/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs +++ b/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs @@ -1,6 +1,7 @@ using Childrens_Social_Care_CPD.Configuration; using Childrens_Social_Care_CPD.Contentful; using Childrens_Social_Care_CPD.Contentful.Renderers; +using Childrens_Social_Care_CPD.Core.Resources; using Childrens_Social_Care_CPD.DataAccess; using Contentful.AspNetCore; using Contentful.Core.Configuration; @@ -29,6 +30,7 @@ public static void AddDependencies(this WebApplicationBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddScoped(); builder.Services.AddScoped(services => { var config = services.GetService(); From 645f1544e659a2fc3b35c0aaec13aca4f584f331 Mon Sep 17 00:00:00 2001 From: cairnsj <51908793+cairnsj@users.noreply.github.com> Date: Tue, 31 Oct 2023 13:14:59 +0000 Subject: [PATCH 5/6] Sonar recommendations --- .../Core/Resources/ResourcesFixedTagsSearchStrategy.cs | 2 +- Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs b/Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs index 81ecbd87..d94ec47a 100644 --- a/Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs +++ b/Childrens-Social-Care-CPD/Core/Resources/ResourcesFixedTagsSearchStrategy.cs @@ -34,7 +34,7 @@ private IEnumerable GetQueryTags(int[] tags) return _allTags; } - if (tags.Any(x => x < 0 || x >= _tagInfos.Count)) + if (Array.Exists(tags, x => x < 0 || x >= _tagInfos.Count)) { _logger.LogWarning("Passed tag values do not match known values: {Passed Values}", tags); return Array.Empty(); diff --git a/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs b/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs index fdc73324..0197b466 100644 --- a/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs +++ b/Childrens-Social-Care-CPD/DataAccess/ResourcesRepository.cs @@ -54,7 +54,7 @@ public async Task> GetSearchTagsAsync() var allTags = await _cpdClient.GetTags(); var tags = allTags - .Where(x => _tagPrefixes.Any(prefix => x.Name.StartsWith($"{prefix}:"))) + .Where(x => Array.Exists(_tagPrefixes, prefix => x.Name.StartsWith($"{prefix}:"))) .Select(x => { var i = x.Name.IndexOf(':'); From a4bfef41c99a546c5b6daf77cff7bd9d68ea56e5 Mon Sep 17 00:00:00 2001 From: cairnsj <51908793+cairnsj@users.noreply.github.com> Date: Tue, 31 Oct 2023 13:37:21 +0000 Subject: [PATCH 6/6] Remove the factory and 'implement' in the container --- .../Controllers/ResourcesControllerTests.cs | 12 ++++---- .../ResourcesSearchStrategyFactoryTests.cs | 30 ------------------- .../Controllers/ResourcesController.cs | 12 ++++---- .../IResourcesSearchStrategyFactory.cs | 6 ---- .../ResourcesSearchStrategyFactory.cs | 25 ---------------- .../WebApplicationBuilderExtensions.cs | 15 ++++++++-- 6 files changed, 24 insertions(+), 76 deletions(-) delete mode 100644 Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs delete mode 100644 Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs delete mode 100644 Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs diff --git a/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs b/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs index acd1bc04..bf011a98 100644 --- a/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs +++ b/Childrens-Social-Care-CPD-Tests/Controllers/ResourcesControllerTests.cs @@ -14,7 +14,7 @@ namespace Childrens_Social_Care_CPD_Tests.Controllers; public class ResourcesControllerTests { - private IResourcesSearchStrategyFactory _searchStrategyFactory; + private IResourcesSearchStrategy _searchStrategy; private ResourcesController _resourcesController; private IRequestCookieCollection _cookies; private HttpContext _httpContext; @@ -31,9 +31,9 @@ public void SetUp() _httpRequest.Cookies.Returns(_cookies); _httpContext.Request.Returns(_httpRequest); controllerContext.HttpContext = _httpContext; - - _searchStrategyFactory = Substitute.For(); - _resourcesController = new ResourcesController(_searchStrategyFactory) + + _searchStrategy = Substitute.For(); + _resourcesController = new ResourcesController(_searchStrategy) { ControllerContext = controllerContext, TempData = Substitute.For() @@ -44,10 +44,8 @@ public void SetUp() public async Task Search_Returns_Strategy_Model() { // arrange - var strategy = Substitute.For(); - _searchStrategyFactory.Create().Returns(strategy); var model = new ResourcesListViewModel(null, null, null, null); - strategy.SearchAsync(Arg.Any(), Arg.Any()).Returns(model); + _searchStrategy.SearchAsync(Arg.Any(), Arg.Any()).Returns(model); // act var actual = await _resourcesController.Search(query: null) as ViewResult; diff --git a/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs b/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs deleted file mode 100644 index b8e9c525..00000000 --- a/Childrens-Social-Care-CPD-Tests/Core/Resources/ResourcesSearchStrategyFactoryTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Castle.Core.Logging; -using Childrens_Social_Care_CPD.Configuration; -using Childrens_Social_Care_CPD.Core.Resources; -using Childrens_Social_Care_CPD.DataAccess; -using FluentAssertions; -using Microsoft.Extensions.Logging; -using NSubstitute; -using NUnit.Framework; -using System; - -namespace Childrens_Social_Care_CPD_Tests.Core.Resources; - -public class ResourcesSearchStrategyFactoryTests -{ - [TestCase(true, typeof(ResourcesDynamicTagsSearchStategy))] - [TestCase(false, typeof(ResourcesFixedTagsSearchStrategy))] - public void Creates_Correct_Strategy(bool isFeatureOn, Type type) - { - // arrange - var featuresConfig = Substitute.For(); - featuresConfig.IsEnabled(Features.ResourcesUseDynamicTags).Returns(isFeatureOn); - var sut = new ResourcesSearchStrategyFactory(featuresConfig, Substitute.For(), Substitute.For>()); - - // act - var actual = sut.Create(); - - // assert - actual.Should().BeOfType(type); - } -} diff --git a/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs b/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs index bd62688c..f8e26575 100644 --- a/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs +++ b/Childrens-Social-Care-CPD/Controllers/ResourcesController.cs @@ -17,23 +17,23 @@ public ResourcesQuery() public class ResourcesController : Controller { - private readonly IResourcesSearchStrategyFactory _factory; + private readonly IResourcesSearchStrategy _strategy; - public ResourcesController(IResourcesSearchStrategyFactory factory) + public ResourcesController(IResourcesSearchStrategy strategy) { - _factory = factory; + ArgumentNullException.ThrowIfNull(strategy); + + _strategy = strategy; } [Route("resources", Name = "Resource")] [HttpGet] public async Task Search([FromQuery] ResourcesQuery query, bool preferencesSet = false, CancellationToken cancellationToken = default) { - var strategy = _factory.Create(); - var contextModel = new ContextModel(string.Empty, "Resources", "Resources", "Resources", true, preferencesSet); ViewData["ContextModel"] = contextModel; - var viewModel = await strategy.SearchAsync(query, cancellationToken); + var viewModel = await _strategy.SearchAsync(query, cancellationToken); return View(viewModel); } } diff --git a/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs b/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs deleted file mode 100644 index af410870..00000000 --- a/Childrens-Social-Care-CPD/Core/Resources/IResourcesSearchStrategyFactory.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Childrens_Social_Care_CPD.Core.Resources; - -public interface IResourcesSearchStrategyFactory -{ - public IResourcesSearchStrategy Create(); -} \ No newline at end of file diff --git a/Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs b/Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs deleted file mode 100644 index 7c435952..00000000 --- a/Childrens-Social-Care-CPD/Core/Resources/ResourcesSearchStrategyFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Childrens_Social_Care_CPD.Configuration; -using Childrens_Social_Care_CPD.DataAccess; - -namespace Childrens_Social_Care_CPD.Core.Resources; - -internal class ResourcesSearchStrategyFactory : IResourcesSearchStrategyFactory -{ - private readonly IFeaturesConfig _featuresConfig; - private readonly IResourcesRepository _resourcesRepository; - private readonly ILogger _fixedLogger; - - public ResourcesSearchStrategyFactory(IFeaturesConfig featuresConfig, IResourcesRepository resourcesRepository, ILogger fixedLogger) - { - _featuresConfig = featuresConfig; - _resourcesRepository = resourcesRepository; - _fixedLogger = fixedLogger; - } - - public IResourcesSearchStrategy Create() - { - return _featuresConfig.IsEnabled(Features.ResourcesUseDynamicTags) - ? new ResourcesDynamicTagsSearchStategy(_resourcesRepository) - : new ResourcesFixedTagsSearchStrategy(_resourcesRepository, _fixedLogger) ; - } -} diff --git a/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs b/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs index 5132234d..7eff7dd5 100644 --- a/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs +++ b/Childrens-Social-Care-CPD/WebApplicationBuilderExtensions.cs @@ -9,6 +9,7 @@ using GraphQL.Client.Http; using GraphQL.Client.Serializer.SystemTextJson; using Microsoft.ApplicationInsights.AspNetCore.Extensions; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.ApplicationInsights; using Microsoft.Extensions.Logging.AzureAppServices; using System.Diagnostics.CodeAnalysis; @@ -29,8 +30,18 @@ public static void AddDependencies(this WebApplicationBuilder builder) builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddScoped(); + builder.Services.AddTransient(); + + // Resources search feature + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(services => + { + var featuresConfig = services.GetService(); + return featuresConfig.IsEnabled(Features.ResourcesUseDynamicTags) + ? services.GetService() + : services.GetService(); + }); builder.Services.AddScoped(services => { var config = services.GetService();