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 menuItem in Model.MenuItems)
+ {
+
+
+
+ }
+
+
+
+
+ }
+
@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 @@
-
+
@fileExtension.ToUpper() ,
@(Model.Size / 1024) KB
diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml
index a0c8d92..70580a5 100644
--- a/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml
+++ b/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml
@@ -3,7 +3,8 @@
@* Beta *@
Beta
This is a new service - your
-
+
feedback
will
help us to improve it.
diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/_CsHeader.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/_CsHeader.cshtml
index 94f8019..0fb2a92 100644
--- a/src/Dfe.ContentSupport.Web/Views/Shared/_CsHeader.cshtml
+++ b/src/Dfe.ContentSupport.Web/Views/Shared/_CsHeader.cshtml
@@ -7,4 +7,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)
+{
+
+
+
+}
\ 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 @@
+
+ Print this page
+
\ 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);
+ }
+
+ }
+}