diff --git a/docs/Tenant-Admin-User-Manual.md b/docs/Tenant-Admin-User-Manual.md index 2cb8a80..664137e 100644 --- a/docs/Tenant-Admin-User-Manual.md +++ b/docs/Tenant-Admin-User-Manual.md @@ -21,6 +21,7 @@ You do not need to be a developer to use this manual. Where a change is made in 9. [Roles](#9-roles) 10. [Permissions — how they work](#10-permissions--how-they-work) 11. [Organisation settings](#11-organisation-settings) + - [11.4 Application submitted page](#114-application-submitted-page) 12. [Event mappings](#12-event-mappings) - [12.1 What this page is for](#121-what-this-page-is-for) - [12.2 Events in plain English](#122-events-in-plain-english) @@ -626,6 +627,25 @@ Show a GOV.UK notification banner on **every page** (for example “This is a te Listing options do **not** change which columns appear. Columns come from the template JSON ([section 7](#7-dashboard-columns-via-the-template)). +### 11.4 Application submitted page + +Customise the confirmation page after submit (`/application-submitted/{reference}`). This category is **per template**. + +| Field | Purpose | +|-------|---------| +| **Template** | A form from this tenant’s catalogue, or **Default (all templates)** as the fallback | +| **Confirmation title** | Green panel heading (for example `Plan submitted`). Leave blank to use **{Singular} submitted** | +| **Page body** | Markdown for everything below the reference number | + +Markdown uses the same **Markdig** renderer as form hints. Supported: + +- `## Heading` / `### Subheading` +- Bullet lists (`- item`) and numbered lists (`1. item`) +- **Bold** and *italic* +- Links: `[text](https://…)` or `[email](mailto:name@example.com)` + +Raw HTML is stripped. Save settings before switching template if you have unsaved changes. + --- ## 12. Event mappings @@ -1519,6 +1539,7 @@ Prefer the dedicated screens first. If you must use this page, these categories | **ApplicationTerminology** | Web | Singular / plural labels | Organisation settings | | **NotificationBanner** | Web | Site-wide banner | Organisation settings | | **Dashboard** | Web | Page size, filters, and dashboard display text | Organisation settings | +| **ApplicationSubmittedPage** | Web | Per-template confirmation title and markdown body | Organisation settings | | **EventMappings** | Shared | Field mappings | Event mappings | | **SchemaEvents** | Shared | Tenant event shapes | Event mappings | | **EventTriggers** | Shared | Submit / upload publish bindings | Event mappings | diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs index ae101ec..1cb2396 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs @@ -1,22 +1,26 @@ using System.Text.Json; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Options; using Microsoft.Extensions.Logging; namespace GovUK.Dfe.FlexForms.Application.Admin; /// -/// Loads and saves non-secret organisation settings (terminology, banner, dashboard, application preview). +/// Loads and saves non-secret organisation settings (terminology, banner, dashboard, preview, submitted page). /// public interface IOrganisationSettingsAdmin { Task LoadAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default); + Task LoadTemplateOptionsAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default); + Task SaveAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default); } public sealed class OrganisationSettingsAdminService( ITenantAdminClient tenantAdminClient, + ITemplatesClient templatesClient, ILogger logger) : IOrganisationSettingsAdmin { private const string TargetWeb = "Web"; @@ -24,6 +28,7 @@ public sealed class OrganisationSettingsAdminService( private const string CategoryBanner = "NotificationBanner"; private const string CategoryDashboard = "Dashboard"; private const string CategoryApplicationPreview = "ApplicationPreview"; + private const string CategoryApplicationSubmittedPage = "ApplicationSubmittedPage"; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -42,6 +47,9 @@ public async Task LoadAsync(OrganisationSettingsWorkState state, CancellationTok { ApplySettingJson(state, setting.Category, setting.SettingsJson); } + + await LoadTemplateOptionsAsync(state, cancellationToken); + ApplySelectedSubmittedCopy(state); } catch (Exception ex) { @@ -100,6 +108,13 @@ await UpsertCategoryAsync( }, cancellationToken); + MergeSelectedSubmittedCopy(state); + await UpsertCategoryAsync( + state.TenantId, + CategoryApplicationSubmittedPage, + state.SubmittedPageByTemplate, + cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); return AdminPageOutcome.Redirect( @@ -188,6 +203,10 @@ private void ApplySettingJson(OrganisationSettingsWorkState state, string catego if (TryGetBool(root, "HideSubmitSection", out var hideSubmit)) state.PreviewHideSubmitSection = hideSubmit; } + else if (string.Equals(category, CategoryApplicationSubmittedPage, StringComparison.OrdinalIgnoreCase)) + { + ApplySubmittedPageJson(state, root); + } } catch (JsonException ex) { @@ -244,4 +263,96 @@ private static bool TryGetProperty(JsonElement root, string name, out JsonElemen property = default; return false; } + + public Task LoadTemplateOptionsAsync( + OrganisationSettingsWorkState state, + CancellationToken cancellationToken = default) => + LoadSubmittedTemplateOptionsAsync(state, cancellationToken); + + private async Task LoadSubmittedTemplateOptionsAsync( + OrganisationSettingsWorkState state, + CancellationToken cancellationToken) + { + var options = new List + { + new( + "Default (all templates)", + ApplicationSubmittedPageCopy.DefaultTemplateKey, + string.Equals( + state.SubmittedTemplateId, + ApplicationSubmittedPageCopy.DefaultTemplateKey, + StringComparison.OrdinalIgnoreCase)) + }; + + try + { + var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? []; + options.AddRange( + templates + .Where(t => t.TemplateId != Guid.Empty) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .Select(t => + { + var id = t.TemplateId.ToString(); + var label = string.IsNullOrWhiteSpace(t.Name) ? id : $"{t.Name} ({id})"; + return new AdminSelectOption( + label, + id, + string.Equals(id, state.SubmittedTemplateId, StringComparison.OrdinalIgnoreCase)); + })); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load templates for application submitted page settings"); + } + + if (string.IsNullOrWhiteSpace(state.SubmittedTemplateId)) + { + state.SubmittedTemplateId = options[0].Value; + options[0] = options[0] with { Selected = true }; + } + + state.SubmittedTemplateOptions = options; + } + + private static void ApplySelectedSubmittedCopy(OrganisationSettingsWorkState state) + { + if (string.IsNullOrWhiteSpace(state.SubmittedTemplateId)) + return; + + if (!state.SubmittedPageByTemplate.TryGetValue(state.SubmittedTemplateId, out var copy)) + return; + + state.SubmittedPanelTitle = copy.PanelTitle; + state.SubmittedBodyMarkdown = copy.BodyMarkdown; + } + + private static void MergeSelectedSubmittedCopy(OrganisationSettingsWorkState state) + { + if (string.IsNullOrWhiteSpace(state.SubmittedTemplateId)) + return; + + state.SubmittedPageByTemplate[state.SubmittedTemplateId] = new ApplicationSubmittedPageCopy + { + PanelTitle = state.SubmittedPanelTitle?.Trim() ?? string.Empty, + BodyMarkdown = state.SubmittedBodyMarkdown ?? string.Empty + }; + } + + private static void ApplySubmittedPageJson(OrganisationSettingsWorkState state, JsonElement root) + { + foreach (var property in root.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Object) + continue; + + var copy = new ApplicationSubmittedPageCopy(); + if (TryGetString(property.Value, "PanelTitle", out var title)) + copy.PanelTitle = title; + if (TryGetString(property.Value, "BodyMarkdown", out var body)) + copy.BodyMarkdown = body; + + state.SubmittedPageByTemplate[property.Name] = copy; + } + } } diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs index f3fad8b..fbb1727 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs @@ -1,3 +1,5 @@ +using GovUK.Dfe.FlexForms.Application.Options; + namespace GovUK.Dfe.FlexForms.Application.Admin; /// @@ -43,6 +45,17 @@ public sealed class OrganisationSettingsWorkState public bool PreviewHideSubmitSection { get; set; } + public string? SubmittedTemplateId { get; set; } + + public IReadOnlyList SubmittedTemplateOptions { get; set; } = []; + + public string? SubmittedPanelTitle { get; set; } + + public string? SubmittedBodyMarkdown { get; set; } + + public Dictionary SubmittedPageByTemplate { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + public bool HasError { get; set; } public string? ErrorMessage { get; set; } diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/ApplicationSubmittedPageDefaults.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/ApplicationSubmittedPageDefaults.cs new file mode 100644 index 0000000..409c473 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/ApplicationSubmittedPageDefaults.cs @@ -0,0 +1,39 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; + +namespace GovUK.Dfe.FlexForms.Application.Applications; + +/// +/// Platform fallback copy for the application-submitted page when a tenant has not configured a template. +/// +public static class ApplicationSubmittedPageDefaults +{ + public static string PanelTitle(IApplicationTerminologyProvider terminology) => + $"{terminology.SingularCapitalised} submitted"; + + public static string BodyMarkdown(IApplicationTerminologyProvider terminology) + { + var singular = terminology.Singular; + return + $""" + We've sent you a confirmation email with your reference number. + + ## What happens next + + Your {singular} will be assigned to a staff member in DfE's Regions Group. + + The staff member will contact you: + + - if we need anything else + - when the {singular} is ready for decision + + You must have approval for your {singular} from DfE's Regions Group before you: + + - make any changes to the articles of association or any other trust documents + - engage with stakeholders of the trust that academies are leaving + + ## Contact us + + If you have any questions about your {singular}, you can email [RegionalServices.RG@education.gov.uk](mailto:RegionalServices.RG@education.gov.uk). + """; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/ApplicationSubmittedWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/ApplicationSubmittedWorkState.cs new file mode 100644 index 0000000..dd22696 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/ApplicationSubmittedWorkState.cs @@ -0,0 +1,13 @@ +namespace GovUK.Dfe.FlexForms.Application.Applications; + +/// +/// Display state for the post-submit confirmation page. +/// +public sealed class ApplicationSubmittedWorkState +{ + public string ReferenceNumber { get; set; } = string.Empty; + + public string PanelTitle { get; set; } = string.Empty; + + public string BodyMarkdown { get; set; } = string.Empty; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/PrepareApplicationSubmittedPageService.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/PrepareApplicationSubmittedPageService.cs new file mode 100644 index 0000000..852f19c --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/PrepareApplicationSubmittedPageService.cs @@ -0,0 +1,152 @@ +using System.Text; +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Options; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Applications; + +/// +/// Resolves per-template confirmation copy for the application-submitted page. +/// +public interface IPrepareApplicationSubmittedPage +{ + Task ExecuteAsync(ApplicationSubmittedWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class PrepareApplicationSubmittedPageService( + IApplicationsClient applicationsClient, + IRequestAppConfiguration requestAppConfiguration, + IApplicationTerminologyProvider terminology, + ILogger logger) : IPrepareApplicationSubmittedPage +{ + public const string SettingsCategory = "ApplicationSubmittedPage"; + + public async Task ExecuteAsync( + ApplicationSubmittedWorkState state, + CancellationToken cancellationToken = default) + { + var copy = await ResolveCopyAsync(state.ReferenceNumber, cancellationToken); + state.PanelTitle = FirstNonEmpty(copy.PanelTitle, ApplicationSubmittedPageDefaults.PanelTitle(terminology)); + state.BodyMarkdown = FirstNonEmpty(copy.BodyMarkdown, ApplicationSubmittedPageDefaults.BodyMarkdown(terminology)); + } + + private async Task ResolveCopyAsync( + string referenceNumber, + CancellationToken cancellationToken) + { + var configured = BindConfiguredCopy(); + if (configured.Count == 0) + return new ApplicationSubmittedPageCopy(); + + var keys = await ResolveTemplateKeysAsync(referenceNumber, cancellationToken); + keys.Add(ApplicationSubmittedPageCopy.DefaultTemplateKey); + + foreach (var key in keys) + { + if (configured.TryGetValue(key, out var copy)) + return copy; + } + + return new ApplicationSubmittedPageCopy(); + } + + private Dictionary BindConfiguredCopy() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var section = requestAppConfiguration.GetSection(SettingsCategory); + + foreach (var child in section.GetChildren()) + { + if (string.IsNullOrWhiteSpace(child.Key)) + continue; + + result[child.Key] = new ApplicationSubmittedPageCopy + { + PanelTitle = child["PanelTitle"], + BodyMarkdown = child["BodyMarkdown"] + }; + } + + return result; + } + + private async Task> ResolveTemplateKeysAsync( + string referenceNumber, + CancellationToken cancellationToken) + { + var keys = new List(); + if (string.IsNullOrWhiteSpace(referenceNumber)) + return keys; + + try + { + var application = await applicationsClient.GetApplicationByReferenceAsync( + referenceNumber, + cancellationToken); + + var templateId = application.TemplateSchema?.TemplateId; + if (templateId is Guid guid && guid != Guid.Empty) + keys.Add(guid.ToString()); + + foreach (var alias in EmbeddedSchemaTemplateIds(application.TemplateSchema)) + { + if (!keys.Contains(alias, StringComparer.OrdinalIgnoreCase)) + keys.Add(alias); + } + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Could not resolve template for submitted page copy using reference {ReferenceNumber}", + referenceNumber); + } + + return keys; + } + + private static IEnumerable EmbeddedSchemaTemplateIds(TemplateSchemaDto? schema) + { + if (string.IsNullOrWhiteSpace(schema?.JsonSchema)) + yield break; + + var schemaText = schema.JsonSchema.Trim(); + if (!schemaText.StartsWith('{') && !schemaText.StartsWith('[')) + { + try + { + schemaText = Encoding.UTF8.GetString(Convert.FromBase64String(schemaText)); + } + catch (FormatException) + { + yield break; + } + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(schemaText); + } + catch (JsonException) + { + yield break; + } + + using (doc) + { + if (doc.RootElement.TryGetProperty("templateId", out var embeddedId) + && embeddedId.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(embeddedId.GetString())) + { + yield return embeddedId.GetString()!.Trim(); + } + } + } + + private static string FirstNonEmpty(string? value, string fallback) => + string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Options/ApplicationSubmittedPageCopy.cs b/src/GovUK.Dfe.FlexForms.Application/Options/ApplicationSubmittedPageCopy.cs new file mode 100644 index 0000000..3646ff2 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Options/ApplicationSubmittedPageCopy.cs @@ -0,0 +1,13 @@ +namespace GovUK.Dfe.FlexForms.Application.Options; + +/// +/// Per-template confirmation copy stored under TenantConfig category ApplicationSubmittedPage. +/// +public sealed class ApplicationSubmittedPageCopy +{ + public const string DefaultTemplateKey = "_default"; + + public string? PanelTitle { get; set; } + + public string? BodyMarkdown { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs index 4c08bd2..ff8c80c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using GovUK.Dfe.FlexForms.Application.Admin; +using GovUK.Dfe.FlexForms.Application.Applications; using GovUK.Dfe.FlexForms.Application.Dashboard; using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; @@ -76,6 +77,7 @@ public static IServiceCollection AddWebLayerServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml index bd8f928..d290e11 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml @@ -54,11 +54,11 @@

Organisation settings

- Update display terminology, the site-wide notification banner, dashboard, and check-your-answers options for + Update display terminology, the site-wide notification banner, dashboard, check-your-answers, and application submitted page options for @(string.IsNullOrWhiteSpace(Model.TenantName) ? Model.TenantId.ToString() : Model.TenantName).

- Changes apply after save. You may need to refresh the page to see banner, terminology, dashboard, or preview text updates. + Changes apply after save. You may need to refresh the page to see banner, terminology, dashboard, preview, or submitted page text updates.

@@ -233,6 +233,44 @@ +
+
+

Application submitted page

+
+
+

+ Customise the confirmation page shown after someone submits a form. + Choose a template, or Default (all templates) as the fallback. +

+

+ Leave a field blank to use the default. Save settings before switching template if you have unsaved changes. +

+ +
+ + +
+ +
+ +
Shown in the green panel, for example Plan submitted. Leave blank to use “{singular} submitted”.
+ +
+ +
+ +
+ Markdown for everything below the reference number. Use ## for headings, - for bullet lists, + **bold**, and [text](mailto:name@example.com) or https links. +
+ +
+
+
+ diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs index 3c0200d..f1fdf16 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.Rendering; namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; @@ -98,6 +99,19 @@ public sealed class OrganisationSettingsModel( [BindProperty] public bool PreviewHideSubmitSection { get; set; } + [BindProperty(SupportsGet = true)] + public string? SubmittedTemplateId { get; set; } + + public IReadOnlyList SubmittedTemplateOptions { get; private set; } = []; + + [BindProperty] + [StringLength(200)] + public string? SubmittedPanelTitle { get; set; } + + [BindProperty] + [StringLength(20000)] + public string? SubmittedBodyMarkdown { get; set; } + public async Task OnGetAsync(CancellationToken cancellationToken) { ApplyTempData(); @@ -124,7 +138,10 @@ public async Task OnPostAsync(CancellationToken cancellationToken } if (!ModelState.IsValid) + { + await ReloadSubmittedTemplateOptionsAsync(cancellationToken); return Page(); + } TerminologySingular = TerminologySingular?.Trim() ?? string.Empty; TerminologyPlural = TerminologyPlural?.Trim() ?? string.Empty; @@ -139,12 +156,13 @@ public async Task OnPostAsync(CancellationToken cancellationToken PreviewSubmitHeading = PreviewSubmitHeading?.Trim() ?? string.Empty; PreviewSubmitHint = PreviewSubmitHint?.Trim() ?? string.Empty; PreviewSubmitButtonText = PreviewSubmitButtonText?.Trim() ?? string.Empty; + SubmittedPanelTitle = SubmittedPanelTitle?.Trim() ?? string.Empty; var outcome = await organisationSettingsAdmin.SaveAsync(CaptureWorkState(), cancellationToken); - return MapOutcome(outcome); + return await MapOutcome(outcome, cancellationToken); } - private IActionResult MapOutcome(AdminPageOutcome outcome) + private async Task MapOutcome(AdminPageOutcome outcome, CancellationToken cancellationToken) { if (outcome.RefreshLocalCaches) { @@ -163,10 +181,21 @@ private IActionResult MapOutcome(AdminPageOutcome outcome) ErrorMessage = outcome.ErrorMessage; } + await ReloadSubmittedTemplateOptionsAsync(cancellationToken); return Page(); } - return RedirectToPage(); + return RedirectToPage(new { SubmittedTemplateId }); + } + + private async Task ReloadSubmittedTemplateOptionsAsync(CancellationToken cancellationToken) + { + var state = CaptureWorkState(); + await organisationSettingsAdmin.LoadTemplateOptionsAsync(state, cancellationToken); + SubmittedTemplateOptions = state.SubmittedTemplateOptions + .Select(o => new SelectListItem(o.Text, o.Value, o.Selected)) + .ToList(); + SubmittedTemplateId = state.SubmittedTemplateId; } private OrganisationSettingsWorkState CaptureWorkState() => @@ -190,7 +219,10 @@ private OrganisationSettingsWorkState CaptureWorkState() => PreviewSubmitHeading = PreviewSubmitHeading, PreviewSubmitHint = PreviewSubmitHint, PreviewSubmitButtonText = PreviewSubmitButtonText, - PreviewHideSubmitSection = PreviewHideSubmitSection + PreviewHideSubmitSection = PreviewHideSubmitSection, + SubmittedTemplateId = SubmittedTemplateId, + SubmittedPanelTitle = SubmittedPanelTitle, + SubmittedBodyMarkdown = SubmittedBodyMarkdown }; private void ApplyWorkState(OrganisationSettingsWorkState state) @@ -214,6 +246,12 @@ private void ApplyWorkState(OrganisationSettingsWorkState state) PreviewSubmitHint = state.PreviewSubmitHint; PreviewSubmitButtonText = state.PreviewSubmitButtonText; PreviewHideSubmitSection = state.PreviewHideSubmitSection; + SubmittedTemplateId = state.SubmittedTemplateId; + SubmittedTemplateOptions = state.SubmittedTemplateOptions + .Select(o => new SelectListItem(o.Text, o.Value, o.Selected)) + .ToList(); + SubmittedPanelTitle = state.SubmittedPanelTitle; + SubmittedBodyMarkdown = state.SubmittedBodyMarkdown; if (state.HasError) { HasError = true; diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml index b756cec..fc1b73c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml @@ -1,43 +1,23 @@ @page "/application-submitted/{referenceNumber}" @model ApplicationSubmittedModel @{ - ViewData["Title"] = $"{AppTerminology.SingularCapitalised} submitted"; + ViewData["Title"] = Model.PanelTitle; }
-

@AppTerminology.SingularCapitalised submitted

+

@Model.PanelTitle

Your reference number
@Model.ReferenceNumber
- -

We've sent you a confirmation email with your reference number.

-

What happens next

- -

Your @AppTerminology.Singular will be assigned to a staff member in DfE's Regions Group.

- -

The staff member will contact you:

- -
    -
  • if we need anything else
  • -
  • when the @AppTerminology.Singular is ready for decision
  • -
- -

You must have approval for your @AppTerminology.Singular from DfE's Regions Group before you:

- -
    -
  • make any changes to the articles of association or any other trust documents
  • -
  • engage with stakeholders of the trust that academies are leaving
  • -
- -

Contact us

- -

- If you have any questions about your @AppTerminology.Singular, you can email - RegionalServices.RG@education.gov.uk. -

+ @if (!string.IsNullOrWhiteSpace(Model.BodyHtml)) + { + + }
-
\ No newline at end of file + diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml.cs index b94b2dc..7a3a37c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/ApplicationSubmitted.cshtml.cs @@ -1,18 +1,30 @@ -using System.Diagnostics.CodeAnalysis; +using GovUK.Dfe.FlexForms.Application.Applications; +using GovUK.Dfe.FlexForms.Web.Utilities; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -namespace GovUK.Dfe.FlexForms.Web.Pages.Applications +namespace GovUK.Dfe.FlexForms.Web.Pages.Applications; + +public sealed class ApplicationSubmittedModel( + IPrepareApplicationSubmittedPage prepareApplicationSubmittedPage) : PageModel { - [ExcludeFromCodeCoverage] - public class ApplicationSubmittedModel : PageModel - { - [BindProperty(SupportsGet = true, Name = "referenceNumber")] - public string ReferenceNumber { get; set; } + [BindProperty(SupportsGet = true, Name = "referenceNumber")] + public string ReferenceNumber { get; set; } = string.Empty; + + public string PanelTitle { get; private set; } = string.Empty; + + public string BodyHtml { get; private set; } = string.Empty; - public void OnGet() + public async Task OnGetAsync(CancellationToken cancellationToken) + { + var state = new ApplicationSubmittedWorkState { - // Page loads with reference number from route - } + ReferenceNumber = ReferenceNumber ?? string.Empty + }; + + await prepareApplicationSubmittedPage.ExecuteAsync(state, cancellationToken); + + PanelTitle = state.PanelTitle; + BodyHtml = MarkdownSafe.ToSafeGovUkHtml(state.BodyMarkdown); } -} \ No newline at end of file +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Utilities/MarkdownSafe.cs b/src/GovUK.Dfe.FlexForms.Web/Utilities/MarkdownSafe.cs index 72157ef..ac6c12c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Utilities/MarkdownSafe.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Utilities/MarkdownSafe.cs @@ -23,28 +23,58 @@ public static class MarkdownSafe private static readonly HtmlSanitizer SanitizerHttpsOnly = CreateSanitizer(allowHttp: false); private static readonly HtmlSanitizer SanitizerHttpAndHttps = CreateSanitizer(allowHttp: true); + private static readonly HtmlSanitizer SanitizerGovUkContent = CreateSanitizer( + allowHttp: false, + allowMailto: true, + allowHeadings: true); + + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(250); // Strip anchors without href private static readonly Regex AnchorWithoutHref = - new(@"]*\bhref=)[^>]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + new(@"]*\bhref=)[^>]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled, RegexTimeout); // Empty paragraphs private static readonly Regex EmptyParagraph = - new(@"

\s*

", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + new(@"

\s*

", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled, RegexTimeout); // Exactly one

...

private static readonly Regex SingleParagraph = - new(@"^\s*

([\s\S]*)<\/p>\s*$", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + new(@"^\s*

([\s\S]*)<\/p>\s*$", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled, RegexTimeout); // Count paragraph tags private static readonly Regex ParagraphTag = - new(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + new(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled, RegexTimeout); // Presence of a list block private static readonly Regex HasListBlock = - new(@"<\s*(ul|ol)\b", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + new(@"<\s*(ul|ol)\b", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled, RegexTimeout); + + private static readonly Regex ParagraphOpen = + new(@"]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout); + + private static readonly Regex UnorderedListOpen = + new(@"]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout); + + private static readonly Regex OrderedListOpen = + new(@"]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout); + + private static readonly Regex Heading1Open = + new(@"]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout); - private static HtmlSanitizer CreateSanitizer(bool allowHttp) + private static readonly Regex Heading2Open = + new(@"]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout); + + private static readonly Regex Heading3Open = + new(@"]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout); + + private static readonly Regex AnchorOpen = + new(@"]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout); + + private static HtmlSanitizer CreateSanitizer( + bool allowHttp, + bool allowMailto = false, + bool allowHeadings = false) { var s = new HtmlSanitizer(); @@ -58,17 +88,26 @@ private static HtmlSanitizer CreateSanitizer(bool allowHttp) s.AllowedTags.Add("ol"); s.AllowedTags.Add("li"); s.AllowedTags.Add("a"); + if (allowHeadings) + { + s.AllowedTags.Add("h1"); + s.AllowedTags.Add("h2"); + s.AllowedTags.Add("h3"); + } // Allowed attributes s.AllowedAttributes.Clear(); s.AllowedAttributes.Add("href"); s.AllowedAttributes.Add("target"); s.AllowedAttributes.Add("rel"); + if (allowHeadings) + s.AllowedAttributes.Add("class"); // Allowed schemes s.AllowedSchemes.Clear(); s.AllowedSchemes.Add("https"); if (allowHttp) s.AllowedSchemes.Add("http"); + if (allowMailto) s.AllowedSchemes.Add("mailto"); // Normalise anchors after sanitising s.PostProcessNode += (_, e) => @@ -127,6 +166,49 @@ public static string ToSafeHtml(string? markdown, int maxChars = 8000, bool allo return safe; } + ///

+ /// Convert Markdown to sanitised HTML with GOV.UK content classes. + /// Allows headings and mailto links for admin-authored confirmation copy. + /// + public static string ToSafeGovUkHtml(string? markdown, int maxChars = 20000) + { + if (string.IsNullOrWhiteSpace(markdown)) + return string.Empty; + + if (markdown.Length > maxChars) + markdown = markdown.Substring(0, maxChars); + + markdown = NormaliseWhitespace(markdown); + + var rawHtml = Markdig.Markdown.ToHtml(markdown, Pipeline); + var safe = SanitizerGovUkContent.Sanitize(rawHtml); + + safe = AnchorWithoutHref.Replace(safe, "$1"); + safe = EmptyParagraph.Replace(safe, string.Empty); + + return ApplyGovUkContentClasses(safe); + } + + private static string ApplyGovUkContentClasses(string html) + { + html = ParagraphOpen.Replace(html, "

"); + html = UnorderedListOpen.Replace(html, "