Skip to content

Commit 03967fc

Browse files
Feature/233953 back button improvements (#78)
* Added back link to dashboard from tasklist * Removed unneeded back link and close notification button * Back button now returns to the page that the user came from
1 parent 4bdb132 commit 03967fc

11 files changed

Lines changed: 257 additions & 44 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using Microsoft.AspNetCore.Http;
2+
3+
namespace DfE.ExternalApplications.Application.Interfaces
4+
{
5+
/// <summary>
6+
/// Provides simple per-scope navigation history storage for computing back navigation.
7+
/// Scope typically includes reference number, task ID, and optionally flow/instance IDs.
8+
/// </summary>
9+
public interface INavigationHistoryService
10+
{
11+
/// <summary>
12+
/// Pushes a URL onto the navigation history stack for the given scope.
13+
/// </summary>
14+
/// <param name="scopeKey">A unique key identifying the navigation scope (e.g. reference:task[:flow:instance]).</param>
15+
/// <param name="url">The URL to push.</param>
16+
/// <param name="session">The HTTP session to store history in.</param>
17+
void Push(string scopeKey, string url, ISession session);
18+
19+
/// <summary>
20+
/// Returns, without removing, the most recent URL for the scope, or null if none.
21+
/// </summary>
22+
/// <param name="scopeKey">A unique key identifying the navigation scope.</param>
23+
/// <param name="session">The HTTP session to read from.</param>
24+
/// <returns>The last URL or null.</returns>
25+
string? Peek(string scopeKey, ISession session);
26+
27+
/// <summary>
28+
/// Pops and returns the most recent URL for the scope, or null if none.
29+
/// </summary>
30+
/// <param name="scopeKey">A unique key identifying the navigation scope.</param>
31+
/// <param name="session">The HTTP session to read/write.</param>
32+
/// <returns>The popped URL or null.</returns>
33+
string? Pop(string scopeKey, ISession session);
34+
35+
/// <summary>
36+
/// Clears the navigation history for the scope.
37+
/// </summary>
38+
/// <param name="scopeKey">A unique key identifying the navigation scope.</param>
39+
/// <param name="session">The HTTP session to clear from.</param>
40+
void Clear(string scopeKey, ISession session);
41+
}
42+
}
43+
44+

src/DfE.ExternalApplications.Infrastructure/Services/FormNavigationService.cs

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using DfE.ExternalApplications.Application.Interfaces;
2+
using Microsoft.AspNetCore.Http;
23

34
namespace DfE.ExternalApplications.Infrastructure.Services
45
{
@@ -7,6 +8,14 @@ namespace DfE.ExternalApplications.Infrastructure.Services
78
/// </summary>
89
public class FormNavigationService : IFormNavigationService
910
{
11+
private readonly INavigationHistoryService _history;
12+
private readonly IHttpContextAccessor _httpContextAccessor;
13+
14+
public FormNavigationService(INavigationHistoryService history, IHttpContextAccessor httpContextAccessor)
15+
{
16+
_history = history;
17+
_httpContextAccessor = httpContextAccessor;
18+
}
1019
/// <summary>
1120
/// Gets the URL for the next page in the form
1221
/// </summary>
@@ -87,19 +96,33 @@ public bool CanNavigateToPage(string pageId, string taskId, string referenceNumb
8796
/// <returns>The back link URL</returns>
8897
public string GetBackLinkUrl(string currentPageId, string taskId, string referenceNumber)
8998
{
90-
// If we're on a specific page, go back to task summary
99+
// Build scope: reference:task[:flow:instance]
100+
var scope = BuildScope(referenceNumber, taskId, currentPageId);
101+
var session = _httpContextAccessor.HttpContext?.Session;
102+
103+
// Prefer history when available
104+
var last = session != null ? _history.Peek(scope, session) : null;
105+
if (!string.IsNullOrEmpty(last))
106+
{
107+
// Append nav=back so GET can pop
108+
var sep = last.Contains('?') ? "&" : "?";
109+
return last + sep + "nav=back";
110+
}
111+
112+
// Fallbacks
91113
if (!string.IsNullOrEmpty(currentPageId))
92114
{
115+
// If this is a sub-flow page, use collection summary
116+
if (!string.IsNullOrEmpty(taskId) && IsSubFlowPage(currentPageId))
117+
{
118+
return GetCollectionFlowSummaryUrl(taskId, referenceNumber);
119+
}
93120
return GetTaskSummaryUrl(taskId, referenceNumber);
94121
}
95-
96-
// If we're on task summary, go back to task list
97122
if (!string.IsNullOrEmpty(taskId))
98123
{
99124
return GetTaskListUrl(referenceNumber);
100125
}
101-
102-
// Default to task list
103126
return GetTaskListUrl(referenceNumber);
104127
}
105128

@@ -170,5 +193,27 @@ public string GetSubFlowPageUrl(string taskId, string referenceNumber, string fl
170193
{
171194
return $"/applications/{referenceNumber}/{taskId}/flow/{flowId}/{instanceId}/{pageId}";
172195
}
196+
197+
private static bool IsSubFlowPage(string currentPageId)
198+
{
199+
return !string.IsNullOrEmpty(currentPageId) && currentPageId.StartsWith("flow/", StringComparison.OrdinalIgnoreCase);
200+
}
201+
202+
private static string BuildScope(string referenceNumber, string taskId, string currentPageId)
203+
{
204+
if (string.IsNullOrEmpty(currentPageId))
205+
{
206+
return $"{referenceNumber}:{taskId}";
207+
}
208+
// Extract flow/instance if present: flow/{flowId}/{instanceId}/...
209+
var parts = currentPageId.Split('/', StringSplitOptions.RemoveEmptyEntries);
210+
if (parts.Length >= 3 && string.Equals(parts[0], "flow", StringComparison.OrdinalIgnoreCase))
211+
{
212+
var flowId = parts[1];
213+
var instanceId = parts[2];
214+
return $"{referenceNumber}:{taskId}:flow:{flowId}:{instanceId}";
215+
}
216+
return $"{referenceNumber}:{taskId}";
217+
}
173218
}
174219
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System.Text.Json;
2+
using DfE.ExternalApplications.Application.Interfaces;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace DfE.ExternalApplications.Infrastructure.Services
7+
{
8+
/// <summary>
9+
/// Session-backed implementation of INavigationHistoryService.
10+
/// Stores a capped stack per scope key.
11+
/// </summary>
12+
public class NavigationHistoryService(ILogger<NavigationHistoryService> logger) : INavigationHistoryService
13+
{
14+
private const string SessionPrefix = "NavHistory_";
15+
private const int MaxDepth = 25;
16+
17+
public void Push(string scopeKey, string url, ISession session)
18+
{
19+
if (string.IsNullOrWhiteSpace(scopeKey) || string.IsNullOrWhiteSpace(url)) return;
20+
var key = SessionPrefix + scopeKey;
21+
var stack = Load(session, key);
22+
23+
// Avoid pushing duplicates of the latest entry
24+
if (stack.Count == 0 || !string.Equals(stack[^1], url, StringComparison.OrdinalIgnoreCase))
25+
{
26+
stack.Add(url);
27+
if (stack.Count > MaxDepth)
28+
{
29+
// Trim oldest
30+
stack.RemoveAt(0);
31+
}
32+
Save(session, key, stack);
33+
}
34+
}
35+
36+
public string? Peek(string scopeKey, ISession session)
37+
{
38+
if (string.IsNullOrWhiteSpace(scopeKey)) return null;
39+
var key = SessionPrefix + scopeKey;
40+
var stack = Load(session, key);
41+
return stack.Count > 0 ? stack[^1] : null;
42+
}
43+
44+
public string? Pop(string scopeKey, ISession session)
45+
{
46+
if (string.IsNullOrWhiteSpace(scopeKey)) return null;
47+
var key = SessionPrefix + scopeKey;
48+
var stack = Load(session, key);
49+
if (stack.Count == 0) return null;
50+
var last = stack[^1];
51+
stack.RemoveAt(stack.Count - 1);
52+
Save(session, key, stack);
53+
return last;
54+
}
55+
56+
public void Clear(string scopeKey, ISession session)
57+
{
58+
if (string.IsNullOrWhiteSpace(scopeKey)) return;
59+
var key = SessionPrefix + scopeKey;
60+
try
61+
{
62+
session.Remove(key);
63+
}
64+
catch (Exception ex)
65+
{
66+
logger.LogWarning(ex, "Failed to clear navigation history for scope {ScopeKey}", scopeKey);
67+
}
68+
}
69+
70+
private static List<string> Load(ISession session, string key)
71+
{
72+
try
73+
{
74+
var bytes = session.Get(key);
75+
if (bytes == null) return new List<string>();
76+
var json = System.Text.Encoding.UTF8.GetString(bytes);
77+
var list = JsonSerializer.Deserialize<List<string>>(json);
78+
return list ?? new List<string>();
79+
}
80+
catch
81+
{
82+
return new List<string>();
83+
}
84+
}
85+
86+
private static void Save(ISession session, string key, List<string> values)
87+
{
88+
try
89+
{
90+
var json = JsonSerializer.Serialize(values);
91+
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
92+
session.Set(key, bytes);
93+
}
94+
catch
95+
{
96+
// swallow
97+
}
98+
}
99+
}
100+
}
101+
102+

src/DfE.ExternalApplications.Web/Extensions/ServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ public static IServiceCollection AddWebLayerServices(this IServiceCollection ser
5151
// Form Engine Services
5252
services.AddScoped<IFormStateManager, FormStateManager>();
5353
services.AddScoped<IFormNavigationService, FormNavigationService>();
54+
services.AddScoped<INavigationHistoryService, NavigationHistoryService>();
5455
services.AddScoped<IFormDataManager, FormDataManager>();
5556
services.AddScoped<IFormValidationOrchestrator, DfE.ExternalApplications.Infrastructure.Services.FormValidationOrchestrator>();
5657
services.AddScoped<IFormConfigurationService, FormConfigurationService>();
58+
services.AddHttpContextAccessor();
5759

5860
// Confirmation Services
5961
services.AddScoped<IButtonConfirmationService, ButtonConfirmationService>();

src/DfE.ExternalApplications.Web/Pages/FormEngine/BaseFormEngineModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ protected string GetBackLinkUrl()
6565
return _formNavigationService.GetBackLinkUrl(CurrentPageId, TaskId, ReferenceNumber);
6666
}
6767

68+
/// <summary>
69+
/// Exposes the back link URL to Razor views that cannot call protected methods.
70+
/// </summary>
71+
public string BackLinkUrl => GetBackLinkUrl();
72+
6873
/// <summary>
6974
/// Gets the task summary URL for the current task
7075
/// </summary>

src/DfE.ExternalApplications.Web/Pages/FormEngine/RenderForm.cshtml.cs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public class RenderFormModel(
3636
IFormErrorStore formErrorStore,
3737
IComplexFieldConfigurationService complexFieldConfigurationService,
3838
IDerivedCollectionFlowService derivedCollectionFlowService,
39-
ILogger<RenderFormModel> logger)
39+
ILogger<RenderFormModel> logger,
40+
INavigationHistoryService navigationHistoryService)
4041
: BaseFormEngineModel(renderer, applicationResponseService, fieldFormattingService, templateManagementService,
4142
applicationStateService, formStateManager, formNavigationService, formDataManager, formValidationOrchestrator, formConfigurationService, logger)
4243
{
@@ -46,9 +47,12 @@ public class RenderFormModel(
4647
private readonly IFormErrorStore _formErrorStore = formErrorStore;
4748
private readonly IComplexFieldConfigurationService _complexFieldConfigurationService = complexFieldConfigurationService;
4849
private readonly IDerivedCollectionFlowService _derivedCollectionFlowService = derivedCollectionFlowService;
50+
private readonly INavigationHistoryService _navigationHistoryService = navigationHistoryService;
4951

5052
[BindProperty] public Dictionary<string, object> Data { get; set; } = new();
5153

54+
public string BackLinkUrl => GetBackLinkUrl();
55+
5256
[BindProperty] public bool IsTaskCompleted { get; set; }
5357

5458
// Collection flow properties from form submission
@@ -260,7 +264,7 @@ public async Task OnGetAsync()
260264
{
261265
// For sub-flows, still apply conditional logic using the current Data
262266
await ApplyConditionalLogicAsync();
263-
}
267+
}
264268

265269
// Initialize task completion status for summaries (standard or derived)
266270
if (CurrentTask != null)
@@ -273,6 +277,32 @@ public async Task OnGetAsync()
273277
IsTaskCompleted = taskStatus == Domain.Models.TaskStatus.Completed;
274278
}
275279
}
280+
// If this GET was reached via back navigation, pop history entry for the current scope
281+
try
282+
{
283+
if (Request.Query.ContainsKey("nav") && string.Equals(Request.Query["nav"], "back", StringComparison.OrdinalIgnoreCase))
284+
{
285+
var scope = BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId);
286+
_navigationHistoryService.Pop(scope, HttpContext.Session);
287+
}
288+
}
289+
catch { }
290+
}
291+
292+
public static string BuildHistoryScope(string referenceNumber, string taskId, string currentPageId)
293+
{
294+
if (string.IsNullOrEmpty(currentPageId))
295+
{
296+
return $"{referenceNumber}:{taskId}";
297+
}
298+
var parts = currentPageId.Split('/', StringSplitOptions.RemoveEmptyEntries);
299+
if (parts.Length >= 3 && string.Equals(parts[0], "flow", StringComparison.OrdinalIgnoreCase))
300+
{
301+
var flowId = parts[1];
302+
var instanceId = parts[2];
303+
return $"{referenceNumber}:{taskId}:flow:{flowId}:{instanceId}";
304+
}
305+
return $"{referenceNumber}:{taskId}";
276306
}
277307

278308
public async Task<IActionResult> OnPostTaskSummaryAsync()
@@ -813,6 +843,24 @@ public async Task<IActionResult> OnPostPageAsync()
813843
}
814844
}
815845

846+
// Before deciding where to go, push current page URL to navigation history so Back returns here
847+
try
848+
{
849+
if (!string.IsNullOrEmpty(CurrentPageId))
850+
{
851+
var scope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId);
852+
var currentUrl = $"/applications/{ReferenceNumber}/{TaskId}/{CurrentPageId}";
853+
_navigationHistoryService.Push(scope, currentUrl, HttpContext.Session);
854+
}
855+
else if (!string.IsNullOrEmpty(TaskId))
856+
{
857+
var scope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId);
858+
var currentUrl = $"/applications/{ReferenceNumber}/{TaskId}";
859+
_navigationHistoryService.Push(scope, currentUrl, HttpContext.Session);
860+
}
861+
}
862+
catch { }
863+
816864
// Use the new navigation logic to determine where to go after saving
817865
if (CurrentTask != null && CurrentPage != null)
818866
{

src/DfE.ExternalApplications.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252

5353
<div class="govuk-button-group govuk-!-margin-top-6">
5454
<govuk-button type="submit" id="save-task-summary-button">Save and continue</govuk-button>
55-
<a class="govuk-link" id="return-to-application-overview" data-testid="return-to-application-overview" href="/applications/@Model.ReferenceNumber">Return to application overview</a>
5655
</div>
5756
</form>
5857
}
@@ -62,10 +61,6 @@
6261
<p>This application has been submitted and can no longer be changed.</p>
6362
<p>Status: <strong>@Model.ApplicationStatus</strong></p>
6463
</div>
65-
66-
<div class="govuk-!-margin-top-6">
67-
<a class="govuk-link" id="return-to-application-overview" data-testid="return-to-application-overview" href="/applications/@Model.ReferenceNumber">Return to application overview</a>
68-
</div>
6964
}
7065
</div>
7166
</div>

src/DfE.ExternalApplications.Web/Views/Shared/FormEngine/_FormPage.cshtml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
@model DfE.ExternalApplications.Web.Pages.FormEngine.RenderFormModel
55
@inject DfE.ExternalApplications.Web.Services.IFieldRendererService FieldRenderer
66
@inject DfE.ExternalApplications.Application.Interfaces.IComplexFieldConfigurationService ComplexFieldConfigurationService
7+
@inject DfE.ExternalApplications.Application.Interfaces.IFormNavigationService Nav
78

89
@{
910
bool hasUploadField = Model.CurrentPage.Fields.Any(f =>
@@ -30,7 +31,7 @@
3031
<input type="hidden" asp-for="TaskId" />
3132
<input type="hidden" asp-for="FlowId" />
3233
<input type="hidden" asp-for="InstanceId" />
33-
<govuk-back-link href="/applications/@Model.ReferenceNumber/@Model.TaskId">Back</govuk-back-link>
34+
<govuk-back-link href="@Nav.GetBackLinkUrl(Model.CurrentPageId, Model.TaskId, Model.ReferenceNumber)">Back</govuk-back-link>
3435

3536
<div class="govuk-grid-row">
3637
<div class="govuk-grid-column-two-thirds">

src/DfE.ExternalApplications.Web/Views/Shared/FormEngine/_TaskList.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
?? Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
1212
?? Context.User?.Identity?.Name;
1313
}
14-
14+
<govuk-back-link href="/applications/dashboard">Back to dashboard</govuk-back-link>
1515
<div class="govuk-grid-row">
1616
<div class="govuk-grid-column-two-thirds">
1717
<h1 class="govuk-heading-xl govuk-!-margin-bottom-6">Your application</h1>

0 commit comments

Comments
 (0)