From 1f77a3a8ad545a4811ddf50e08e4257377f11936 Mon Sep 17 00:00:00 2001 From: ThomasWhittington <46750921+ThomasWhittington@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:50:17 +0100 Subject: [PATCH] Added new blocks and some refactors (#138) * Added test project * Added ADR docs * Added basic unit tests * added ADR * updated folder structure * restructure * Added architecture diagram * More unit tests * Added DI test * Added contentful model for richtext * Updated content service (#82) * Updated content service * uncommented tests * undid spacing change --------- Co-authored-by: Simon FIRTH Co-authored-by: Tom Whittington * Created contentful stub * Updated to query builder * Add renderer for rich text content (#75) * add renderer for rich text content * Add interface for content renderer * remove unused using statements * refactor: use partial views to handle rich text rendering * Add new project for e2e cypress tests * remove magic strings * wip: Add mock content for e2e tests * Update page heading and add support for subheadings * update mockContent for e2e tests * add rich text support for horizontal rule * add support for embedded image assets * Add support for embedded video * add support for embedded vimeo player * Changed views to use explicit models * Added dev settings to gitignore * Removed main --------- Co-authored-by: Tom Whittington * updated e2e and app settings * Updated tests * Fixed/ added some tests for coverage * Added tests for HttpClients * Added service tests * Refactored tests --------- Co-authored-by: Tom Whittington * Feature/ rework/linking (#86) * Lowered HR to Hr * Added support for linking to content --------- Co-authored-by: Tom Whittington * wip: render linked entry and asset from within rich text * wip: render download component * display corresponding icon for download filetype * update e2e tests * Feature/ rework/embedded entries (#87) * Added basic support for embeddedEntries * Added includes to configuration * Updated tests for new model * Simplified include logic * Fixed client test --------- Co-authored-by: Tom Whittington * update entry partial * revert ContentService changes * use target variable in entry partial * tweak entry partial * update file icons * Added terraform scripts * add accordion component * Added workflows and actions * adding missing WAF and updated contentful environment * update integration tests * wip: add accessibility tweaks, accessibilty tests and page layout updates * Added support for cards and grid containers. Updated dfeFrontend to 2.0.1 (#91) * Updated DfeFrontend * Added basic card support * Card cleanup * Added grid container * Updated svgs --------- Co-authored-by: Tom Whittington * use dfe container width and update header styling * updated workflow * TF changes to get inital build going * fixed tflint * removed unused vars from pr-check * added dev environment * lock updated * reformatted locals.tf * terraform-docs: automated action * include hero in header * fix e2e tests * Updated JS + CSS files * add support for multiple accordion sections * resolve assets for e2e testing * Capitalise development branch name (#93) * Capitalise development branch name * force workflow to appear in actions * Matrix deploy push * made context root * moved docker file location * adjusted docker file location * lowercase dockerfile name * moved docker file * updated matrix deploy * updated deploy script * updated image name * revision create change * Added env variable * updated to main * updated cli version * removed env * updated workflow to dispatch * Feature/app insights (#97) * Capitalise development branch name * force workflow to appear in actions * Matrix deploy push * made context root * moved docker file location * adjusted docker file location * lowercase dockerfile name * moved docker file * updated matrix deploy * updated deploy script * updated image name * revision create change * Added env variable * updated to main * updated cli version * removed env * updated workflow to dispatch * Added application insights + secret management * Feature/vault (#98) * Capitalise development branch name * force workflow to appear in actions * Matrix deploy push * made context root * moved docker file location * adjusted docker file location * lowercase dockerfile name * moved docker file * updated matrix deploy * updated deploy script * updated image name * revision create change * Added env variable * updated to main * updated cli version * removed env * updated workflow to dispatch * Added application insights + secret management * modified matrix deploy to run on completed PR * Added Keyvault secret API to application startup * fixed YAML * modifed matrix deloy (#100) * Updated workflow so it can be manually fired (#102) * modifed matrix deloy * changed to workflow dispatch * removed env * changed keyvault name * changed keyvault name * re-added env * Adding landing page (#104) * Created temp home page * updated is Preview to true for home page * Preview mode bug fixes for home page --------- Co-authored-by: Tom Whittington * Added mapping layer to produce cleaner models (#96) * Added basic caching layer (#106) Co-authored-by: Tom Whittington * Added cache clear endpoint and ignoring cache when in preview (#107) * Added cache clear endpoint and ignoring cache when in preview * Added some tests for caching * modifed cache controller to API type + respond with OK * removed unused controller map --------- Co-authored-by: Tom Whittington Co-authored-by: simonjfirth * add gtm and clarity tracking to application * remove unnecessary ms clarity tag * add cookie consent banner * remove comments * change consent cookie values to true or false * add download last updated date and update styling * Feature/model mapping refactor (#110) * Model rework for better testability * Added better tests for model mapping --------- Co-authored-by: Tom Whittington * Feature/plantech prep (#112) * Model rework for better testability * Added better tests for model mapping * Made modifications to allow c&s to run as part of plantech --------- Co-authored-by: Tom Whittington * Made app settings not publish * Removed some code smells * Explicit types in tests * Feature/update contentful (#113) * Updated contentful secret value to it doesn't collide with PT integration * removed temp code * Cleaned up some more smells (#114) * Cleaned up some more smells * changed to index --------- Co-authored-by: Tom Whittington * Hidden cookie banner (#115) Co-authored-by: Tom Whittington * Added default page to handle base route (#116) * feat: removed header text (#117) Co-authored-by: Tom Whittington * feat: allowed accordions to display richtext (#118) Co-authored-by: Tom Whittington * feat: added support for excel sheets (#119) Co-authored-by: Tom Whittington * feat: added back to top button (#120) Co-authored-by: Tom Whittington * Update download component mapping and styling * Added citation block (#121) * feat: added citation block * fix: fixed tests --------- Co-authored-by: Tom Whittington * feat: use current tab (#122) Co-authored-by: Tom Whittington * Updated styling to elements and added missing classes (#123) * feature: add feedback banner to all pages * Removed route sitemap base route which was causing a multiple route match in PT (#125) * add thankyou message to feedback banner * feat: render or hide feedback banner based on contentful boolean * wip: reenable content cookies to test conditional rendering for feedback banner * Feature/side nav (#128) * Started to create vertical navigation * First draft of unit tests * update to tests * cleaned unit tests * Updated JS + CSS files * Cleanup * Update to broken unit tests * reverted flag * removed ref * removed unused controller tests --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * remove comment * Added basic print button (#130) Co-authored-by: Tom Whittington * add plan tech name to header (#131) * Tidy up (#132) * Added basic sonar check (#135) * Added basic sonar check * Build and test enabled * Removed manual trigger on deploy * Removed push builds * Fixed branch name --------- Co-authored-by: Tom Whittington * feat: update beta banner feedback link * feat: added basic retry on contentful api calls (#137) Co-authored-by: Tom Whittington --------- Co-authored-by: Tom Whittington Co-authored-by: Simon FIRTH Co-authored-by: simonjfirth Co-authored-by: jack-coggin <119428483+jack-coggin@users.noreply.github.com> Co-authored-by: jack.coggin Co-authored-by: Iain STANGER Co-authored-by: github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/code-pr-check.yml | 72 +++- .github/workflows/matrix-deploy.yml | 8 +- .../styles/scss/vertical-navigation.scss | 41 ++ .../Configuration/CsContentfulOptions.cs | 1 + .../Controllers/ContentController.cs | 11 +- .../WebApplicationBuilderExtensions.cs | 1 + .../Http/HttpContentfulClient.cs | 18 +- .../Models/ContentBase.cs | 4 + .../Models/Mapped/CsContentItem.cs | 2 + .../Models/Mapped/CsPage.cs | 5 + .../Models/Mapped/PageLink.cs | 10 + src/Dfe.ContentSupport.Web/Program.cs | 4 +- .../Services/ILayoutService.cs | 10 + .../Services/LayoutService.cs | 85 ++++ .../Services/ModelMapper.cs | 19 +- .../ViewModels/ContentSupportPage.cs | 4 + .../Views/Content/CsIndex.cshtml | 37 +- .../Views/Shared/Error.cshtml | 2 +- .../Shared/RichText/Custom/_Attachment.cshtml | 2 +- .../Views/Shared/_BetaHeader.cshtml | 3 +- .../Views/Shared/_CsHeader.cshtml | 3 + .../Views/Shared/_CsLayout.cshtml | 4 +- .../Views/Shared/_Feedback.cshtml | 36 ++ .../Views/Shared/_Print.cshtml | 3 + .../wwwroot/assets/icon-print.png | Bin 0 -> 213 bytes .../wwwroot/css/cands-site.css | 366 ++++++++++++++---- .../Controllers/ContentControllerTests.cs | 4 +- .../Mapped/Custom/CustomAccordionTests.cs | 4 +- .../Mapped/Custom/CustomAttachmentTests.cs | 8 +- .../Services/LayoutServiceTests.cs | 231 +++++++++++ 30 files changed, 881 insertions(+), 117 deletions(-) create mode 100644 src/Dfe.ContentSupport.Web.Node/styles/scss/vertical-navigation.scss create mode 100644 src/Dfe.ContentSupport.Web/Models/Mapped/PageLink.cs create mode 100644 src/Dfe.ContentSupport.Web/Services/ILayoutService.cs create mode 100644 src/Dfe.ContentSupport.Web/Services/LayoutService.cs create mode 100644 src/Dfe.ContentSupport.Web/Views/Shared/_Feedback.cshtml create mode 100644 src/Dfe.ContentSupport.Web/Views/Shared/_Print.cshtml create mode 100644 src/Dfe.ContentSupport.Web/wwwroot/assets/icon-print.png create mode 100644 tests/Dfe.ContentSupport.Web.Tests/Services/LayoutServiceTests.cs diff --git a/.github/workflows/code-pr-check.yml b/.github/workflows/code-pr-check.yml index 655db94..0c43a2b 100644 --- a/.github/workflows/code-pr-check.yml +++ b/.github/workflows/code-pr-check.yml @@ -1,11 +1,6 @@ name: Code PR Check on: - push: - branches: ["main", "Development"] - paths: - - "src/**" - - "tests/**" pull_request: branches: ["main", "Development"] paths: @@ -30,16 +25,61 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore - - - name: Test - run: dotnet test --no-build --verbosity normal - + dotnet-version: ${{ env.DOTNET_VERSION }} + cache: false + - name: Install dotnet coverage run: dotnet tool install --global dotnet-coverage --version 17.9.3 + + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~\sonar\cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Install SonarCloud scanners + run: dotnet tool install --global dotnet-sonarscanner + + - name: Install latest JDK + uses: actions/setup-java@v4 + with: + distribution: "microsoft" + java-version: "17" + +# - name: Start SonarCloud scanner +# run: | +# dotnet-sonarscanner begin \ +# /k:"DFE-Digital_sts-content-support" \ +# /o:"dfe-digital" \ +# /d:sonar.login="${{ secrets.SONAR_TOKEN }}" \ +# /d:sonar.host.url="https://sonarcloud.io" \ +# /d:sonar.cs.vscoveragexml.reportsPaths=coverage.xml \ +# /d:sonar.coverage.exclusions=**/Program.cs,**/wwwroot/** \ +# /d:sonar.issue.ignore.multicriteria=e1 \ +# /d:sonar.issue.ignore.multicriteria.e1.ruleKey=csharpsquid:S6602 \ +# /d:sonar.issue.ignore.multicriteria.e1.resourceKey=src/**/*.cs + + - name: Build web app + uses: ./.github/actions/build-dotnet-app + with: + dotnet_version: ${{ env.DOTNET_VERSION }} + solution_filename: sts-contentsupport.sln + + - name: Run unit tests + uses: ./.github/actions/run-unit-tests + with: + solution_filename: sts-contentsupport.sln + +# - name: Merge test results +# run: dotnet-coverage merge -f xml -o "coverage.xml" -s "coverage.settings.xml" -r coverage.cobertura.xml +# +# - name: End SonarCloud Scanner +# run: dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" +# +# - name: Archive code coverage results +# uses: actions/upload-artifact@v4 +# with: +# name: code-coverage-report +# path: coverage.xml +# \ No newline at end of file diff --git a/.github/workflows/matrix-deploy.yml b/.github/workflows/matrix-deploy.yml index b567062..ed258f9 100644 --- a/.github/workflows/matrix-deploy.yml +++ b/.github/workflows/matrix-deploy.yml @@ -3,7 +3,7 @@ name: Multi stage build & deploy on: workflow_dispatch: push: - branches: ["main", "development"] + branches: [ "main", "Development" ] paths: - "src/**" - ".github/workflows/matrix-deploy.yml" @@ -37,20 +37,20 @@ jobs: checked-out-sha: ${{ needs.set-env.outputs.checked-out-sha }} create-and-tag-release: - needs: [set-env, create-and-publish-image] + needs: [ set-env, create-and-publish-image ] name: Create & Tag Release uses: ./.github/workflows/create-tag-release.yml secrets: inherit deploy-to-dev: - needs: [set-env, create-and-publish-image] + needs: [ set-env, create-and-publish-image ] name: Deployment to Dev & Test strategy: max-parallel: 1 fail-fast: true matrix: - target: [Dev] + target: [ Dev ] uses: ./.github/workflows/deploy-image.yml with: environment: ${{ matrix.target }} diff --git a/src/Dfe.ContentSupport.Web.Node/styles/scss/vertical-navigation.scss b/src/Dfe.ContentSupport.Web.Node/styles/scss/vertical-navigation.scss new file mode 100644 index 0000000..6327975 --- /dev/null +++ b/src/Dfe.ContentSupport.Web.Node/styles/scss/vertical-navigation.scss @@ -0,0 +1,41 @@ +:root { + --govuk-link-color: #1d70b8; + --govuk-black: #0b0c0c; +} + +.dfe-vertical-nav__section-item { + list-style-type: none; + font-size: 1rem; +} + +.dfe-vertical-nav__link { + display: block; + padding: 7px 30px 8px 10px; + border-left: 4px solid #b1b4b6; + text-decoration: none; +} + +.dfe-vertical-nav__link--selected { + border-left: 4px solid var(--govuk-link-color); + background-color: #f3f2f1; + font-weight: bold; +} + +.dfe-vertical-nav__link, +.dfe-vertical-nav__link--selected { + color: var(--govuk-link-color); + + &:active, &:hover { + background-color: #fd0; + color: var(--govuk-black); + border-left: 4px solid var(--govuk-black); + font-weight: normal; + } +} + +.dfe-vertical-nav__theme { + border-top: 1px solid var(--govuk-link-color); + padding-top: 5px; + margin-top: 10px; + margin-left: 0; +} diff --git a/src/Dfe.ContentSupport.Web/Configuration/CsContentfulOptions.cs b/src/Dfe.ContentSupport.Web/Configuration/CsContentfulOptions.cs index 6cdc72d..6441cf0 100644 --- a/src/Dfe.ContentSupport.Web/Configuration/CsContentfulOptions.cs +++ b/src/Dfe.ContentSupport.Web/Configuration/CsContentfulOptions.cs @@ -5,4 +5,5 @@ namespace Dfe.ContentSupport.Web.Configuration; public class CsContentfulOptions : ContentfulOptions { public int IncludeDepth { get; set; } = 10; + public int RetryAttempts { get; set; } = 3; } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs b/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs index d9c4ec0..681091a 100644 --- a/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs +++ b/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs @@ -9,7 +9,7 @@ namespace Dfe.ContentSupport.Web.Controllers; [Route("/content")] [AllowAnonymous] -public class ContentController(IContentService contentService) +public class ContentController(IContentService contentService, ILayoutService layoutService) : Controller { public async Task Home() @@ -28,14 +28,17 @@ public async Task Home() return View(defaultModel); } - [HttpGet("{slug}")] - public async Task Index(string slug, bool isPreview = false) + [HttpGet("{slug}/{page?}")] + public async Task Index(string slug, string page = "", bool isPreview = false) { if (!ModelState.IsValid) return RedirectToAction("error"); if (string.IsNullOrEmpty(slug)) return RedirectToAction("error"); var resp = await contentService.GetContent(slug, isPreview); if (resp is null) return RedirectToAction("error"); + + resp = layoutService.GenerateLayout(resp, Request, page); + return View("CsIndex", resp); } @@ -48,6 +51,6 @@ public IActionResult Privacy() public IActionResult Error() { return View(new ErrorViewModel - { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs b/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs index 16a9078..71ea637 100644 --- a/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs @@ -24,6 +24,7 @@ public static void InitCsDependencyInjection(this WebApplicationBuilder app) app.Services.AddTransient(); app.Services.AddTransient(); app.Services.AddTransient(); + app.Services.AddTransient(); app.Services.Configure(options => { diff --git a/src/Dfe.ContentSupport.Web/Http/HttpContentfulClient.cs b/src/Dfe.ContentSupport.Web/Http/HttpContentfulClient.cs index 0ff6a75..32c446a 100644 --- a/src/Dfe.ContentSupport.Web/Http/HttpContentfulClient.cs +++ b/src/Dfe.ContentSupport.Web/Http/HttpContentfulClient.cs @@ -12,6 +12,22 @@ public async Task> Query(QueryBuilder queryBuilder CancellationToken cancellationToken = default) where T : class { queryBuilder = queryBuilder.Include(options.IncludeDepth); - return await GetEntries(queryBuilder, cancellationToken); + + for (int attempt = 1; attempt <= options.RetryAttempts; attempt++) + { + try + { + return await GetEntries(queryBuilder, cancellationToken); + } + catch (Exception) + { + if (attempt == options.RetryAttempts) + { + throw; + } + } + } + + return default!; } } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Models/ContentBase.cs b/src/Dfe.ContentSupport.Web/Models/ContentBase.cs index 636506b..c1b04e3 100644 --- a/src/Dfe.ContentSupport.Web/Models/ContentBase.cs +++ b/src/Dfe.ContentSupport.Web/Models/ContentBase.cs @@ -6,4 +6,8 @@ namespace Dfe.ContentSupport.Web.Models; public class ContentBase : ContentType { public string InternalName { get; set; } = null!; + + public string? Title { get; set; } = null; + + public string? Subtitle { get; set; } = null; } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Models/Mapped/CsContentItem.cs b/src/Dfe.ContentSupport.Web/Models/Mapped/CsContentItem.cs index ce02e65..7255ede 100644 --- a/src/Dfe.ContentSupport.Web/Models/Mapped/CsContentItem.cs +++ b/src/Dfe.ContentSupport.Web/Models/Mapped/CsContentItem.cs @@ -6,4 +6,6 @@ namespace Dfe.ContentSupport.Web.Models.Mapped; public class CsContentItem { public string InternalName { get; set; } = null!; + public string? Title { get; set; } = null; + public string? Subtitle { get; set; } = null; } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Models/Mapped/CsPage.cs b/src/Dfe.ContentSupport.Web/Models/Mapped/CsPage.cs index 2017016..24863d3 100644 --- a/src/Dfe.ContentSupport.Web/Models/Mapped/CsPage.cs +++ b/src/Dfe.ContentSupport.Web/Models/Mapped/CsPage.cs @@ -9,8 +9,13 @@ public class CsPage public string Slug { get; set; } = null!; public bool IsSitemap { get; set; } public bool HasCitation { get; set; } + public bool ShowVerticalNavigation { get; set; } public bool HasBackToTop { get; set; } + public bool HasPrint { get; set; } public List Content { get; set; } = null!; public DateTime? CreatedAt { get; init; } public DateTime? UpdatedAt { get; init; } + public bool HasFeedbackBanner { get; set; } + public List? MenuItems { get; set; } + } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Models/Mapped/PageLink.cs b/src/Dfe.ContentSupport.Web/Models/Mapped/PageLink.cs new file mode 100644 index 0000000..8e4c640 --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Models/Mapped/PageLink.cs @@ -0,0 +1,10 @@ +namespace Dfe.ContentSupport.Web.Models.Mapped +{ + public class PageLink + { + public string? Title { get; set; } = null; + public string? Subtitle { get; set; } = null; + public required string Url { get; set; } + public required bool IsActive { get; set; } + } +} diff --git a/src/Dfe.ContentSupport.Web/Program.cs b/src/Dfe.ContentSupport.Web/Program.cs index 62b2e40..b1b5f4c 100644 --- a/src/Dfe.ContentSupport.Web/Program.cs +++ b/src/Dfe.ContentSupport.Web/Program.cs @@ -21,6 +21,7 @@ public static void Main(string[] args) builder.Services.AddControllers(); builder.Services.AddControllersWithViews(); builder.Services.AddApplicationInsightsTelemetry(); + builder.Services.AddHealthChecks(); builder.Services.AddGovUkFrontend(); builder.Services.AddContentful(builder.Configuration); @@ -39,6 +40,7 @@ public static void Main(string[] args) app.UseRouting(); app.UseAuthorization(); app.UseCookiePolicy(); + app.MapHealthChecks("/healthz"); app.MapControllerRoute( "Default", @@ -55,7 +57,7 @@ public static void Main(string[] args) app.MapControllerRoute( name: "slug", - pattern: "{slug}", + pattern: "{slug}/{page?}", defaults: new { controller = "Content", action = "Index" }); diff --git a/src/Dfe.ContentSupport.Web/Services/ILayoutService.cs b/src/Dfe.ContentSupport.Web/Services/ILayoutService.cs new file mode 100644 index 0000000..d028752 --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Services/ILayoutService.cs @@ -0,0 +1,10 @@ +using Dfe.ContentSupport.Web.Models.Mapped; + +namespace Dfe.ContentSupport.Web.Services +{ + public interface ILayoutService + { + CsPage GenerateLayout(CsPage page, HttpRequest request, string pageName); + + } +} diff --git a/src/Dfe.ContentSupport.Web/Services/LayoutService.cs b/src/Dfe.ContentSupport.Web/Services/LayoutService.cs new file mode 100644 index 0000000..1f559c0 --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Services/LayoutService.cs @@ -0,0 +1,85 @@ +using Dfe.ContentSupport.Web.Models; +using Dfe.ContentSupport.Web.Models.Mapped; + + +namespace Dfe.ContentSupport.Web.Services +{ + public class LayoutService : ILayoutService + { + public CsPage GenerateLayout(CsPage page, HttpRequest request, string pageName) + { + if (!page.ShowVerticalNavigation) return page; + + return new() + { + Heading = GetHeading(page, pageName), + MenuItems = GenerateVerticalNavigation(page, request, pageName), + Content = GetVisiblePageList(page, pageName), + UpdatedAt = page.UpdatedAt, + CreatedAt = page.CreatedAt, + HasCitation = page.HasCitation, + HasBackToTop = page.HasBackToTop, + IsSitemap = page.IsSitemap, + ShowVerticalNavigation = page.ShowVerticalNavigation, + Slug = page.Slug, + }; + } + + + public Heading GetHeading(CsPage page, string pageName) + { + var selectedPage = page.Content.Find(o => o.InternalName == pageName); + + if (selectedPage != null) + return new() + { + Title = selectedPage.Title ?? "", + Subtitle = selectedPage.Subtitle ?? "" + }; + + + return new() + { + Title = page.Content[0]?.Title ?? "", + Subtitle = page.Content[0]?.Subtitle ?? "" + }; + } + + + public List GenerateVerticalNavigation(CsPage page, HttpRequest request, string pageName) + { + var baseUrl = GetNavigationUrl(request); + + var menuItems = page.Content.Select(o => new PageLink() + { + Title = o.Title ?? "", + Subtitle = o.Subtitle ?? "", + Url = $"{baseUrl}/{o.InternalName}", + IsActive = pageName == o.InternalName + }).ToList(); + + if (string.IsNullOrEmpty(pageName) && menuItems.Count > 0) + menuItems[0].IsActive = true; + + return menuItems; + } + + + public List GetVisiblePageList(CsPage page, string pageName) + { + if (!string.IsNullOrEmpty(pageName)) + return page.Content.Where(o => o.InternalName == pageName).ToList(); + + + return page.Content.GetRange(0, 1); + } + + + public string GetNavigationUrl(HttpRequest request) + { + var splitUrl = request.Path.ToString().Split("/"); + return string.Join("/", splitUrl.Take(3)); + } + + } +} diff --git a/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs b/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs index a9b8262..d976b76 100644 --- a/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs +++ b/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs @@ -25,7 +25,10 @@ public CsPage MapToCsPage(ContentSupportPage incoming) IsSitemap = incoming.IsSitemap, HasCitation = incoming.HasCitation, HasBackToTop = incoming.HasBackToTop, + HasFeedbackBanner = incoming.HasFeedbackBanner, + HasPrint = incoming.HasPrint, Content = MapEntriesToContent(incoming.Content), + ShowVerticalNavigation = incoming.ShowVerticalNavigation, CreatedAt = incoming.Sys.CreatedAt, UpdatedAt = incoming.Sys.UpdatedAt }; @@ -40,18 +43,20 @@ private List MapEntriesToContent(List entries) public CsContentItem ConvertEntryToContentItem(Entry entry) { CsContentItem item = entry.RichText is not null - ? MapRichTextContent(entry.RichText)! - : new CsContentItem { InternalName = entry.InternalName }; + ? MapRichTextContent(entry.RichText, entry)! + : new CsContentItem { InternalName = entry.InternalName, Title = entry.Title, Subtitle = entry.Subtitle }; return item; } - public RichTextContentItem? MapRichTextContent(ContentItemBase? richText) + public RichTextContentItem? MapRichTextContent(ContentItemBase? richText, Entry entry) { if (richText is null) return null; RichTextContentItem item = new RichTextContentItem { - InternalName = richText.InternalName, + InternalName = entry.InternalName, + Title = entry.Title, + Subtitle = entry.Subtitle, NodeType = ConvertToRichTextNodeType(richText.NodeType), Content = MapRichTextNodes(richText.Content), }; @@ -61,7 +66,7 @@ public CsContentItem ConvertEntryToContentItem(Entry entry) public List MapRichTextNodes(List nodes) { return nodes.Select(node => MapContent(node) ?? new RichTextContentItem - { NodeType = RichTextNodeType.Unknown, InternalName = node.InternalName }).ToList(); + { NodeType = RichTextNodeType.Unknown, InternalName = node.InternalName }).ToList(); } public RichTextContentItem? MapContent(ContentItem contentItem) @@ -101,7 +106,7 @@ public List MapRichTextNodes(List nodes) item = new EmbeddedEntry { JumpIdentifier = target.JumpIdentifier, - RichText = MapRichTextContent(target.RichText), + RichText = MapRichTextContent(target.RichText, target), CustomComponent = GenerateCustomComponent(target) }; break; @@ -154,7 +159,7 @@ private CustomAccordion GenerateCustomAccordion(Target target) return new CustomAccordion { InternalName = target.InternalName, - Body = MapRichTextContent(target.RichText), + Body = MapRichTextContent(target.RichText, target), SummaryLine = target.SummaryLine, Title = target.Title, Accordions = target.Content.Select(GenerateCustomAccordion).ToList() diff --git a/src/Dfe.ContentSupport.Web/ViewModels/ContentSupportPage.cs b/src/Dfe.ContentSupport.Web/ViewModels/ContentSupportPage.cs index 90b0f42..7668062 100644 --- a/src/Dfe.ContentSupport.Web/ViewModels/ContentSupportPage.cs +++ b/src/Dfe.ContentSupport.Web/ViewModels/ContentSupportPage.cs @@ -17,4 +17,8 @@ public class ContentSupportPage : ContentBase public bool IsSitemap { get; init; } public bool HasCitation { get; init; } public bool HasBackToTop { get; init; } + public bool HasFeedbackBanner { get; init; } + public bool HasPrint { get; init; } + public bool ShowVerticalNavigation { get; init; } + } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Views/Content/CsIndex.cshtml b/src/Dfe.ContentSupport.Web/Views/Content/CsIndex.cshtml index 940966e..8ae4e3f 100644 --- a/src/Dfe.ContentSupport.Web/Views/Content/CsIndex.cshtml +++ b/src/Dfe.ContentSupport.Web/Views/Content/CsIndex.cshtml @@ -5,21 +5,52 @@ }
+ + @if (Model.MenuItems is not null) + { +
+ +
+ } +
@foreach (var content in Model.Content) { - + } +
+
@if (Model.HasCitation) { - + } @if (Model.HasBackToTop) { - + + } + + @if (Model.HasPrint) + { + + } + + @if (Model.HasFeedbackBanner) + { + }
\ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/Error.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/Error.cshtml index e44e743..5c54262 100644 --- a/src/Dfe.ContentSupport.Web/Views/Shared/Error.cshtml +++ b/src/Dfe.ContentSupport.Web/Views/Shared/Error.cshtml @@ -10,7 +10,7 @@ {

Request ID: @Model.RequestId -

+

}

Development Mode

diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/RichText/Custom/_Attachment.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/RichText/Custom/_Attachment.cshtml index 1b528f2..e7076ef 100644 --- a/src/Dfe.ContentSupport.Web/Views/Shared/RichText/Custom/_Attachment.cshtml +++ b/src/Dfe.ContentSupport.Web/Views/Shared/RichText/Custom/_Attachment.cshtml @@ -44,7 +44,7 @@ - \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/_CsLayout.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/_CsLayout.cshtml index d4a3147..fa842bf 100644 --- a/src/Dfe.ContentSupport.Web/Views/Shared/_CsLayout.cshtml +++ b/src/Dfe.ContentSupport.Web/Views/Shared/_CsLayout.cshtml @@ -7,7 +7,7 @@ ViewData["Title"] = Model.Heading.Title; ViewData["containerClasses"] = "dfe-width-container"; var consentCookie = Context.Request.Cookies[".AspNet.Consent"]; - var track = false;// consentCookie == "true"; + var track = consentCookie == "true"; } @section Head { @@ -32,9 +32,7 @@
- @* - *@
diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/_Feedback.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/_Feedback.cshtml new file mode 100644 index 0000000..09a9d3f --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Views/Shared/_Feedback.cshtml @@ -0,0 +1,36 @@ +@{ + var consentCookie = Context.Request.Cookies[".AspNet.Consent"]; + var track = consentCookie == "true"; +} + +@if (track) +{ +
+
+
+
+
+

Is this page + useful?

+
+ + +
+
+
+ +
+
+
+ + +} \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/_Print.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/_Print.cshtml new file mode 100644 index 0000000..402ac26 --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Views/Shared/_Print.cshtml @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/wwwroot/assets/icon-print.png b/src/Dfe.ContentSupport.Web/wwwroot/assets/icon-print.png new file mode 100644 index 0000000000000000000000000000000000000000..54737a3739c0d7ef981a79103756663725a82038 GIT binary patch literal 213 zcmeAS@N?(olHy`uVBq!ia0vp^0zfRp!3HFQtmCqP6lZ})WHAE+w=f7ZGR&GI0Tg5` z4sv&5Sa(k5C6L3C?&#~tz_78O`%fY(ke}u0;uuoFcy^MtP=f-G3-4sFhyU1Fb4xXJ ze(10`9bBF8De-U{-+|1x-AVo&U*_)&t*MSbqW65#ZpWiRJ o.GetContent(dummySlug, isPreview), Times.Once); } diff --git a/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAccordionTests.cs b/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAccordionTests.cs index d34d40f..dd1b845 100644 --- a/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAccordionTests.cs +++ b/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAccordionTests.cs @@ -46,7 +46,7 @@ public class CustomAccordionTests ], RichText = new ContentItem { - InternalName = ContentInternalName, + InternalName = null, NodeType = "paragraph" } } @@ -75,7 +75,7 @@ public void MapCorrectly() var expectedBody= new RichTextContentItem { - InternalName = ContentInternalName, + InternalName = InternalName, NodeType = RichTextNodeType.Paragraph, Content = [] }; diff --git a/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAttachmentTests.cs b/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAttachmentTests.cs index 62831d2..7b2ffdf 100644 --- a/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAttachmentTests.cs +++ b/tests/Dfe.ContentSupport.Web.Tests/Models/Mapped/Custom/CustomAttachmentTests.cs @@ -50,10 +50,12 @@ public class CustomAttachmentTests Details = new FileDetails { Size = Size - } + }, }, - SystemProperties =new SystemProperties() - + SystemProperties = new SystemProperties + { + UpdatedAt = DateTime.Now + } } } } diff --git a/tests/Dfe.ContentSupport.Web.Tests/Services/LayoutServiceTests.cs b/tests/Dfe.ContentSupport.Web.Tests/Services/LayoutServiceTests.cs new file mode 100644 index 0000000..d1c405a --- /dev/null +++ b/tests/Dfe.ContentSupport.Web.Tests/Services/LayoutServiceTests.cs @@ -0,0 +1,231 @@ + +using Dfe.ContentSupport.Web.Models.Mapped; +using Microsoft.AspNetCore.Http; + + +namespace Dfe.ContentSupport.Web.Tests.Services +{ + public class LayoutServiceTests + { + private readonly LayoutService _layoutService = new(); + + private readonly string Home = "Home"; + private readonly string About = "About"; + private readonly string Contact = "Contact"; + private readonly string HomeTitle = "Home Title"; + private readonly string AboutTitle = "About Title"; + private readonly string HomeSubtitle = "Home Subtitle"; + private readonly string AboutSubtitle = "About Subtitle"; + + private CsPage GetPage() + { + return new CsPage + { + Content = new() + { + new () { InternalName = Home, Title = HomeTitle, Subtitle = HomeSubtitle }, + new () { InternalName = About, Title = AboutTitle, Subtitle = AboutSubtitle } + } + }; + } + + + private string GetSegmentLength(int length) + { + var segment = ""; + for (var i = 1; i <= length; i++) + { + segment += $"/segment{i}"; + } + + return segment; + } + + + [Fact] + public void GetHeading_PageExists_ReturnsCorrectHeading() + { + // Arrange + var page = GetPage(); + + // Act + var result = _layoutService.GetHeading(page, About); + + // Assert + Assert.Equal(AboutTitle, result.Title); + Assert.Equal(AboutSubtitle, result.Subtitle); + } + + + [Fact] + public void GetHeading_PageDoesNotExist_ReturnsFirstPageHeading() + { + // Arrange + var page = GetPage(); + + // Act + var result = _layoutService.GetHeading(page, Contact); + + // Assert + Assert.Equal(HomeTitle, result.Title); + Assert.Equal(HomeSubtitle, result.Subtitle); + } + + + [Fact] + public void GenerateVerticalNavigation_PageNameMatches_ReturnsCorrectMenuItems() + { + // Arrange + var page = GetPage(); + + var request = new DefaultHttpContext().Request; + + // Act + var result = _layoutService.GenerateVerticalNavigation(page, request, About); + + // Assert + Assert.Equal(page.Content.Count, result.Count); + Assert.Equal(AboutTitle, result[1].Title); + Assert.True(result[1].IsActive); + } + + + [Fact] + public void GenerateVerticalNavigation_PageNameDoesNotMatch_ReturnsMenuItemsWithFirstActive() + { + // Arrange + var page = GetPage(); + + var request = new DefaultHttpContext().Request; + + // Act + var result = _layoutService.GenerateVerticalNavigation(page, request, Contact); + + // Assert + Assert.Equal(page.Content.Count, result.Count); + Assert.Equal(HomeTitle, result[0].Title); + Assert.Equal(0, result.Count(o => o.IsActive)); + } + + + [Fact] + public void GetVisiblePageList_PageNameProvidedAndMatches_ReturnsMatchingItems() + { + // Arrange + var page = GetPage(); + + // Act + var result = _layoutService.GetVisiblePageList(page, About); + + // Assert + Assert.Single(result); + Assert.Equal(About, result[0].InternalName); + } + + + [Fact] + public void GetVisiblePageList_PageNameProvidedAndDoesNotMatch_ReturnsEmptyList() + { + // Arrange + var page = GetPage(); + + // Act + var result = _layoutService.GetVisiblePageList(page, Contact); + + // Assert + Assert.Empty(result); + } + + + [Fact] + public void GetVisiblePageList_PageNameIsNullOrEmpty_ReturnsFirstItem() + { + // Arrange + var page = GetPage(); + + // Act + var result = _layoutService.GetVisiblePageList(page, string.Empty); + + // Assert + Assert.Single(result); + Assert.Equal(Home, result[0].InternalName); + } + + + [Fact] + public void GetVisiblePageList_ContentListIsEmpty_ReturnsEmptyList() + { + // Arrange + var page = GetPage(); + page.Content = new(); + + // Act + var result = _layoutService.GetVisiblePageList(page, Home); + + // Assert + Assert.Empty(result); + } + + + [Fact] + public void GetNavigationUrl_MoreThanTwoSegments_ReturnsFirstTwoSegments() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.Path = GetSegmentLength(4); + + // Act + var result = _layoutService.GetNavigationUrl(context.Request); + + // Assert + Assert.Equal(GetSegmentLength(2), result); + } + + + [Fact] + public void GetNavigationUrl_ExactlyTwoSegments_ReturnsAllSegments() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.Path = GetSegmentLength(2); + + // Act + var result = _layoutService.GetNavigationUrl(context.Request); + + // Assert + Assert.Equal(GetSegmentLength(2), result); + } + + + [Fact] + public void GetNavigationUrl_FewerThanTwoSegments_ReturnsAllSegments() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.Path = GetSegmentLength(1); + + // Act + var result = _layoutService.GetNavigationUrl(context.Request); + + // Assert + Assert.Equal(GetSegmentLength(1), result); + } + + + [Fact] + public void GetNavigationUrl_EmptyUrl_ReturnsEmptyString() + { + // Arrange + var context = new DefaultHttpContext(); + var emptyRequestPath = string.Empty; + context.Request.Path = emptyRequestPath; + + // Act + var result = _layoutService.GetNavigationUrl(context.Request); + + // Assert + Assert.Equal(emptyRequestPath, result); + } + + } +}