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 0000000..54737a3 Binary files /dev/null and b/src/Dfe.ContentSupport.Web/wwwroot/assets/icon-print.png differ diff --git a/src/Dfe.ContentSupport.Web/wwwroot/css/cands-site.css b/src/Dfe.ContentSupport.Web/wwwroot/css/cands-site.css index f66be84..c3cc664 100644 --- a/src/Dfe.ContentSupport.Web/wwwroot/css/cands-site.css +++ b/src/Dfe.ContentSupport.Web/wwwroot/css/cands-site.css @@ -10,62 +10,62 @@ video { position: relative; } -.attachment:after { - content: ''; - display: block; - clear: both; -} + .attachment:after { + content: ''; + display: block; + clear: both; + } -.attachment .attachment-title { - font-weight: 400; - font-size: 1.6875rem; - line-height: 1.1111111111; - margin: 0 0 15px; -} + .attachment .attachment-title { + font-weight: 400; + font-size: 1.6875rem; + line-height: 1.1111111111; + margin: 0 0 15px; + } -.attachment .attachment-title .attachment-link { - line-height: 1.29; -} + .attachment .attachment-title .attachment-link { + line-height: 1.29; + } -.attachment .attachment-details { - padding-left: 134px; -} + .attachment .attachment-details { + padding-left: 134px; + } -.attachment .attachment-thumbnail { - position: relative; - float: left; - margin-right: 25px; - margin-bottom: 15px; - padding: 5px; - display: block; - max-height: 140px; - max-width: 99px; - border: #e6e6e6; - outline: 5px solid #e6e6e6; - background: #ffffff; - box-shadow: 0 2px 2px #999999; - fill: #d8d8d8; - stroke: #d8d8d8; -} + .attachment .attachment-thumbnail { + position: relative; + float: left; + margin-right: 25px; + margin-bottom: 15px; + padding: 5px; + display: block; + max-height: 140px; + max-width: 99px; + border: #e6e6e6; + outline: 5px solid #e6e6e6; + background: #ffffff; + box-shadow: 0 2px 2px #999999; + fill: #d8d8d8; + stroke: #d8d8d8; + } -.attachment .attachment-metadata { - font-weight: 400; - font-size: 1.1875rem; - line-height: 1.32; - margin: 0 0 15px; - color: #505a5f; -} + .attachment .attachment-metadata { + font-weight: 400; + font-size: 1.1875rem; + line-height: 1.32; + margin: 0 0 15px; + color: #505a5f; + } -.attachment .attachment-metadata:last-of-type { - margin-bottom: 0; -} + .attachment .attachment-metadata:last-of-type { + margin-bottom: 0; + } -.attachment .attachment-metadata .attachment-attribute { - word-wrap: break-word; - overflow-wrap: break-word; - text-decoration: none; - cursor: help; -} + .attachment .attachment-metadata .attachment-attribute { + word-wrap: break-word; + overflow-wrap: break-word; + text-decoration: none; + cursor: help; + } .attachment-thumbnail { display: block; @@ -84,6 +84,43 @@ video { background-color: #ebf2f6; } +.dfe-feedback-banner { + background-color: #f3f2f1; + border: 1px solid #b1b4b6; + margin-bottom: 30px; +} + +.dfe-feedback-banner--content { + padding: 20px 15px; + display: block; + line-height: 1.333; +} + +.dfe-feedback-banner--content-questions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.dfe-feedback-banner--content-question { + display: flex; + align-items: center; +} + +.dfe-feedback-banner +.govuk-button, +.dfe-feedback-banner +.govuk-button-group { + margin-bottom: 0; +} + +.dfe-feedback-banner--content-question +.dfe-feedback-banner--content-question-text { + margin-bottom: 0; + margin-right: 20px; +} + .guidance-container { border: 1px solid #b1b4b6; } @@ -108,9 +145,9 @@ video { line-height: 1.25; } - .gem-c-metadata a { - font-family: sans-serif; - } + .gem-c-metadata a { + font-family: sans-serif; + } .gem-c-metadata--inverse-padded .gem-c-metadata__list { margin: 15px; @@ -124,9 +161,9 @@ video { margin-top: 0; } - .gem-c-metadata__term .gem-c-metadata__definition { - line-height: 1.4; - } + .gem-c-metadata__term .gem-c-metadata__definition { + line-height: 1.4; + } .gem-c-metadata__definition:not(:last-of-type) { margin-bottom: 5px; @@ -156,24 +193,24 @@ video { color: #ffffff; } -.gem-c-metadata-inverse a:link, -.gem-c-metadata-inverse a:hover, -.gem-c-metadata-inverse a:visited, -.gem-c-metadata-inverse a:active { - color: #ffffff; -} + .gem-c-metadata-inverse a:link, + .gem-c-metadata-inverse a:hover, + .gem-c-metadata-inverse a:visited, + .gem-c-metadata-inverse a:active { + color: #ffffff; + } -.gem-c-metadata-inverse a:focus { - color: #0b0c0c; -} + .gem-c-metadata-inverse a:focus { + color: #0b0c0c; + } .gem-c-metadata-inverse-padded { padding: 10px; } -.gem-c-metadata-inverse-padded .gem-c-metadata-inverse.gem-c-metadata__list { - margin: 10px; -} + .gem-c-metadata-inverse-padded .gem-c-metadata-inverse.gem-c-metadata__list { + margin: 10px; + } .gem-c-metadata__definition { margin: 0; @@ -230,4 +267,197 @@ dl.gem-c-metadata__list { padding: 15px 10px 15px 20px; margin-top: 60px; border-left: 5px solid #347ca9; -} \ No newline at end of file +} + + + +.dfe-vertical-nav { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 400; + font-size: 16px; + font-size: 0.875rem; + line-height: 1.14286; +} + + .dfe-vertical-nav .dfe-vertical-nav--section-header { + color: #505a5f; + font-size: 19px; + line-height: 1.25; + font-weight: 600; + color: #505a5f; + margin-bottom: 15px; + padding-top: 0; + } + +@media (min-width: 40.0625em) { + .dfe-vertical-nav { + padding-left: 15px; + } +} + +@media print { + .dfe-vertical-nav { + font-size: 14pt; + line-height: 1.2; + } +} + +@media (min-width: 40.0625em) { + .dfe-vertical-nav { + margin-left: -15px; + } +} + +.dfe-vertical-nav__section { + margin: 0 0 20px; + padding: 0; + list-style-type: none; +} + +.dfe-vertical-nav__link { + display: block; + padding: 7px 30px 8px 10px; + text-decoration: none; + margin-bottom: 5px; + color: #003a69; +} + +.dfe-vertical-nav__section-item { + border-left: 4px solid #b1b4b6; + font-size: 16px; + font-size: 1rem; + line-height: 1.25; +} + + .dfe-vertical-nav__section-item:hover { + border-left: 4px solid #347ca9; + } + +.dfe-vertical-nav__link:focus { + background: inherit; +} + +.dfe-vertical-nav__section-item--current { + border-left: 4px solid #003a69; + font-weight: 700; + background: #f3f2f1; +} + +.dfe-vertical-nav__link:active, .dfe-vertical-nav__link:hover { + color: #1d70b8; + border-left-color: #347ca9; + text-decoration: none; + box-shadow: none; + outline: 0; +} + +.dfe-vertical-nav__link:focus { + background: #fd0; + color: #0b0c0c; + text-decoration: none; + box-shadow: none; + outline: 0; +} + +.dfe-vertical-nav__section-item--current .dfe-vertical-nav__link { + border-left-color: #003a69; + font-weight: 700; + color: #003a69; +} + + .dfe-vertical-nav__section-item--current .dfe-vertical-nav__link:hover { + text-decoration: none; + } + +.dfe-vertical-nav__section--nested { + margin-bottom: 5px; +} + + .dfe-vertical-nav__section--nested .dfe-vertical-nav__link { + padding-left: 20px; + font-weight: 400; + margin-bottom: 0; + margin-top: -5px; + } + + .dfe-vertical-nav__section--nested .dfe-vertical-nav__section-item::before { + content: "�"; + margin-left: -20px; + color: #505a5f; + } + +.dfe-vertical-nav--count { + float: right; + background: #b1b4b6; + padding: 9px 5px 9px 5px; + border-radius: 0; + color: #0b0c0c; + font-weight: 700; + min-width: 25px; + text-align: center; + font-size: 12px; +} + +@media print { + .dfe-vertical-nav__theme { + font-family: sans-serif; + } +} + +@media (min-width: 40.0625em) { + .dfe-vertical-nav__theme { + font-size: 19px; + font-size: 1.1875rem; + line-height: 1.31579; + } + + .dfe-vertical-nav--count { + padding: 12px 8px 12px 8px; + } +} + +.dfe-vertical-nav__section .dfe-vertical-nav__section-item--current--child-active .dfe-vertical-nav__link { + font-weight: 400; +} + +.dfe-vertical-nav__section .dfe-vertical-nav__section { + margin-bottom: 0; +} + + .dfe-vertical-nav__section .dfe-vertical-nav__section .dfe-vertical-nav__section-item { + border-left: none; + } + + + .dfe-vertical-nav__section .dfe-vertical-nav__section.dfe-vertical-nav__section-item--current { + border-left: none; + background: #347ca9; + } + + .dfe-vertical-nav__section .dfe-vertical-nav__section.dfe-vertical-nav__section-item--current .dfe-vertical-nav__link { + font-weight: 700; + } + +.print-link-button { + background: url(/assets/icon-print.png) no-repeat 10px 50%; + background-size: 16px 18px; + padding: 10px 10px 10px 36px; + border: 1px solid #b1b4b6; + color: #1d70b8; + cursor: pointer; + margin: 0; + margin-bottom: 15px; + font-weight: 400; + font-size: 14px; + font-size: 0.875rem; + line-height: 1.1429; +} + +.print-button { + margin-top: 2rem; +} + +.govuk-list.govuk-list--bullet li .govuk-body { + margin-bottom: 0; +} diff --git a/tests/Dfe.ContentSupport.Web.Tests/Controllers/ContentControllerTests.cs b/tests/Dfe.ContentSupport.Web.Tests/Controllers/ContentControllerTests.cs index dd60afa..eb5f3de 100644 --- a/tests/Dfe.ContentSupport.Web.Tests/Controllers/ContentControllerTests.cs +++ b/tests/Dfe.ContentSupport.Web.Tests/Controllers/ContentControllerTests.cs @@ -11,7 +11,7 @@ public class ContentControllerTests private ContentController GetController() { - return new ContentController(_contentServiceMock.Object); + return new ContentController(_contentServiceMock.Object, new LayoutService()); } @@ -45,7 +45,7 @@ public async Task Index_Calls_Service_GetContent() const bool isPreview = true; var sut = GetController(); - await sut.Index(dummySlug, isPreview); + await sut.Index(dummySlug, "", isPreview); _contentServiceMock.Verify(o => 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); + } + + } +}