From aa0639ef66bd066a91fca9482b5b375f7aa614d7 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 21 Mar 2026 14:00:41 +0100 Subject: [PATCH] Add table context URL serializer --- ...s_frontend_overhaul_implementation_plan.md | 4 +- .../Components/Pages/Tables.razor | 30 +++- .../Tools/DiagnosticsPageContent.razor | 129 +++++++++++++++++- .../Frontend/AppState/TableContextMode.cs | 8 ++ .../Frontend/AppState/TableContextSnapshot.cs | 10 ++ .../AppState/TableContextUrlSerializer.cs | 82 +++++++++++ src/RolemasterDb.App/Program.cs | 1 + 7 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 src/RolemasterDb.App/Frontend/AppState/TableContextMode.cs create mode 100644 src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs create mode 100644 src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 504f31b..37245c7 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -54,6 +54,7 @@ It is intentionally implementation-focused: | 2026-03-21 | P2.2 | Completed | Replaced the old `/diagnostics` and `/api` pages with lightweight compatibility routes that reuse a shared redirect component, preserve query strings and fragments, and replace browser history while forwarding into the canonical `Tools` routes. | | 2026-03-21 | P2.3 | Completed | Added a shared `RecentTablesState` service backed by browser storage, centralized the recents storage key, and started recording successful table visits from the `Tables` page so later omnibox and rail work has real shared data. | | 2026-03-21 | P2.4 | Completed | Added a shared `PinnedTablesState` service with browser persistence, centralized the pin storage key, initialized pin state in the `Tables` page, and added a first live pin/unpin action plus pinned status chips so later omnibox and navigation work have real saved pins to consume. | +| 2026-03-21 | P2.5 | Completed | Added shared table-context URL types and a serializer, registered the serializer centrally, and started round-tripping table context through `/tables` and `/tools/diagnostics` so direct links restore the selected table and diagnostic cell context instead of relying only on local storage defaults. | ### Lessons Learned @@ -77,6 +78,7 @@ It is intentionally implementation-focused: - Compatibility routes should stay declarative and shared. A single redirect component is enough for route forwarding and avoids copy-pasted `NavigationManager` lifecycle logic in page files. - Shared browser-backed state becomes more useful once one real page writes to it immediately. Recording recents from `Tables` now keeps later omnibox work from being blocked on synthetic placeholder data. - Shared pinned-state services also need one live writer early. A minimal pin/unpin affordance in the current `Tables` page is enough to validate persistence before the larger navigation surfaces consume pins. +- URL serializers only pay off when wired into real pages. Using the shared serializer in both `Tables` and diagnostics now gives the project one verified round-trip path before the larger table-context service lands. ## Target Outcomes @@ -368,7 +370,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest | `P2.2` | Completed | Old `/diagnostics` and `/api` routes now forward into the canonical `Tools` routes with shared redirect behavior. | | `P2.3` | Completed | Recent critical-table visits now persist through a shared app-state service and are recorded from the `Tables` page. | | `P2.4` | Completed | Pinned tables now persist through a shared app-state service and can already be toggled from the `Tables` page. | -| `P2.5` | Pending | Table-context URL parsing and serialization still needs a shared model. | +| `P2.5` | Completed | Shared table-context URLs now parse and serialize through one helper and are consumed by `/tables` and `/tools/diagnostics`. | | `P2.6` | Pending | The shell omnibox is still a placeholder trigger. | | `P2.7` | Pending | Shared primitives for chips, tabs, drawers, and inspector sections are not extracted yet. | | `P2.8` | Pending | Table-selection logic still lives inside individual pages. | diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 543c525..ae09846 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -4,10 +4,12 @@ @using System.Collections.Generic @using System.Diagnostics.CodeAnalysis @using System.Linq +@inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime @inject LookupService LookupService @inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState @inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState +@inject RolemasterDb.App.Frontend.AppState.TableContextUrlSerializer TableContextUrlSerializer Critical Tables @@ -285,6 +287,7 @@ isTableMenuOpen = false; await PersistSelectedTableAsync(tableSlug); await LoadTableDetailAsync(); + SyncTableContextUrl(); } private async Task LoadTableDetailAsync() @@ -335,16 +338,18 @@ { try { + var routeContext = TableContextUrlSerializer.Parse(NavigationManager.Uri); var storedTableSlug = await JSRuntime.InvokeAsync("localStorage.getItem", SelectedTableStorageKey); hasResolvedStoredTableSelection = true; - var resolvedTableSlug = ResolveSelectedTableSlug(storedTableSlug); + var resolvedTableSlug = ResolveSelectedTableSlug(routeContext.TableSlug ?? storedTableSlug); if (string.IsNullOrWhiteSpace(selectedTableSlug) || !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) { selectedTableSlug = resolvedTableSlug; await LoadTableDetailAsync(); await PersistSelectedTableAsync(selectedTableSlug); + SyncTableContextUrl(); await InvokeAsync(StateHasChanged); return; } @@ -353,6 +358,8 @@ { await PersistSelectedTableAsync(selectedTableSlug); } + + SyncTableContextUrl(); } catch (InvalidOperationException) { @@ -793,6 +800,27 @@ private Task PersistSelectedTableAsync(string tableSlug) => JSRuntime.InvokeVoidAsync("localStorage.setItem", SelectedTableStorageKey, tableSlug).AsTask(); + private void SyncTableContextUrl() + { + if (string.IsNullOrWhiteSpace(selectedTableSlug)) + { + return; + } + + var targetUri = TableContextUrlSerializer.BuildRelativeUri( + "/tables", + new RolemasterDb.App.Frontend.AppState.TableContextSnapshot( + TableSlug: selectedTableSlug, + Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference)); + + if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal)) + { + return; + } + + NavigationManager.NavigateTo(targetUri, replace: true); + } + private Task TogglePinnedTableAsync() { if (SelectedTableReference is not { } selectedTable) diff --git a/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor b/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor index f89d924..b8a58bf 100644 --- a/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor +++ b/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor @@ -1,7 +1,9 @@ @using System @using System.Collections.Generic @using System.Linq +@inject NavigationManager NavigationManager @inject LookupService LookupService +@inject RolemasterDb.App.Frontend.AppState.TableContextUrlSerializer TableContextUrlSerializer
@@ -164,8 +166,9 @@ protected override async Task OnInitializedAsync() { referenceData = await LookupService.GetReferenceDataAsync(); - selectedTableSlug = referenceData.CriticalTables.FirstOrDefault()?.Key ?? string.Empty; - await LoadTableDetailAsync(); + var routeContext = TableContextUrlSerializer.Parse(NavigationManager.Uri); + selectedTableSlug = ResolveSelectedTableSlug(routeContext.TableSlug); + await LoadTableDetailAsync(routeContext); } private async Task HandleTableChanged(ChangeEventArgs args) @@ -178,6 +181,7 @@ { selectedRollBand = args.Value?.ToString() ?? string.Empty; ResolveSelectedCell(); + SyncRouteContext(); await LoadSelectedCellDiagnosticsAsync(); } @@ -185,6 +189,7 @@ { selectedGroupKey = NormalizeOptionalText(args.Value?.ToString()); ResolveSelectedCell(); + SyncRouteContext(); await LoadSelectedCellDiagnosticsAsync(); } @@ -192,10 +197,11 @@ { selectedColumnKey = args.Value?.ToString() ?? string.Empty; ResolveSelectedCell(); + SyncRouteContext(); await LoadSelectedCellDiagnosticsAsync(); } - private async Task LoadTableDetailAsync() + private async Task LoadTableDetailAsync(RolemasterDb.App.Frontend.AppState.TableContextSnapshot? routeContext = null) { if (string.IsNullOrWhiteSpace(selectedTableSlug)) { @@ -220,8 +226,19 @@ return; } - SetDefaultSelection(tableDetail); + if (!TryApplySelectionFromContext(tableDetail, routeContext)) + { + SetDefaultSelection(tableDetail); + } + ResolveSelectedCell(); + if (selectedCell is null && routeContext is not null) + { + SetDefaultSelection(tableDetail); + ResolveSelectedCell(); + } + + SyncRouteContext(); await LoadSelectedCellDiagnosticsAsync(); } catch (Exception exception) @@ -290,6 +307,47 @@ string.Equals(cell.GroupKey ?? string.Empty, selectedGroupKey ?? string.Empty, StringComparison.Ordinal)); } + private bool TryApplySelectionFromContext( + CriticalTableDetail detail, + RolemasterDb.App.Frontend.AppState.TableContextSnapshot? routeContext) + { + if (routeContext is null) + { + return false; + } + + CriticalTableCellDetail? matchedCell = null; + if (routeContext.ResultId is { } resultId) + { + matchedCell = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId); + } + + matchedCell ??= detail.Cells.FirstOrDefault(cell => + string.Equals(cell.RollBand, routeContext.RollBand, StringComparison.Ordinal) && + string.Equals(cell.ColumnKey, routeContext.ColumnKey, StringComparison.Ordinal) && + string.Equals(cell.GroupKey ?? string.Empty, routeContext.GroupKey ?? string.Empty, StringComparison.Ordinal)); + + if (matchedCell is not null) + { + selectedRollBand = matchedCell.RollBand; + selectedColumnKey = matchedCell.ColumnKey; + selectedGroupKey = matchedCell.GroupKey; + return true; + } + + if (string.IsNullOrWhiteSpace(routeContext.RollBand) && + string.IsNullOrWhiteSpace(routeContext.ColumnKey) && + string.IsNullOrWhiteSpace(routeContext.GroupKey)) + { + return false; + } + + selectedRollBand = ResolveRollBand(detail, routeContext.RollBand); + selectedColumnKey = ResolveColumnKey(detail, routeContext.ColumnKey); + selectedGroupKey = ResolveGroupKey(detail, routeContext.GroupKey); + return true; + } + private async Task LoadSelectedCellDiagnosticsAsync() { diagnosticsError = null; @@ -312,6 +370,7 @@ } diagnosticsModel = CriticalCellEditorModel.FromResponse(response); + SyncRouteContext(); } catch (Exception exception) { @@ -325,4 +384,66 @@ private static string? NormalizeOptionalText(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private string ResolveSelectedTableSlug(string? tableSlug) + { + if (referenceData is null || referenceData.CriticalTables.Count == 0) + { + return string.Empty; + } + + if (!string.IsNullOrWhiteSpace(tableSlug) && + referenceData.CriticalTables.Any(item => string.Equals(item.Key, tableSlug, StringComparison.OrdinalIgnoreCase))) + { + return tableSlug; + } + + return referenceData.CriticalTables.First().Key; + } + + private void SyncRouteContext() + { + if (string.IsNullOrWhiteSpace(selectedTableSlug)) + { + return; + } + + var targetUri = TableContextUrlSerializer.BuildRelativeUri( + "/tools/diagnostics", + new RolemasterDb.App.Frontend.AppState.TableContextSnapshot( + TableSlug: selectedTableSlug, + GroupKey: selectedGroupKey, + ColumnKey: selectedColumnKey, + RollBand: selectedRollBand, + ResultId: selectedCell?.ResultId, + Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Diagnostics)); + + if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal)) + { + return; + } + + NavigationManager.NavigateTo(targetUri, replace: true); + } + + private static string ResolveRollBand(CriticalTableDetail detail, string? rollBand) => + detail.RollBands.FirstOrDefault(item => string.Equals(item.Label, rollBand, StringComparison.Ordinal))?.Label + ?? detail.RollBands.FirstOrDefault()?.Label + ?? string.Empty; + + private static string ResolveColumnKey(CriticalTableDetail detail, string? columnKey) => + detail.Columns.FirstOrDefault(item => string.Equals(item.Key, columnKey, StringComparison.Ordinal))?.Key + ?? detail.Columns.FirstOrDefault()?.Key + ?? string.Empty; + + private static string? ResolveGroupKey(CriticalTableDetail detail, string? groupKey) + { + if (detail.Groups.Count == 0) + { + return null; + } + + return detail.Groups.FirstOrDefault(item => string.Equals(item.Key, groupKey, StringComparison.Ordinal))?.Key + ?? detail.Groups.First().Key; + } } diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextMode.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextMode.cs new file mode 100644 index 0000000..49b401e --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/TableContextMode.cs @@ -0,0 +1,8 @@ +namespace RolemasterDb.App.Frontend.AppState; + +public enum TableContextMode +{ + Reference, + Curation, + Diagnostics +} diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs new file mode 100644 index 0000000..5bfacc3 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs @@ -0,0 +1,10 @@ +namespace RolemasterDb.App.Frontend.AppState; + +public sealed record TableContextSnapshot( + string? TableSlug = null, + string? GroupKey = null, + string? ColumnKey = null, + string? RollBand = null, + int? RollJump = null, + int? ResultId = null, + TableContextMode? Mode = null); diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs new file mode 100644 index 0000000..4da830a --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.WebUtilities; + +namespace RolemasterDb.App.Frontend.AppState; + +public sealed class TableContextUrlSerializer +{ + private const string TableKey = "table"; + private const string GroupKey = "group"; + private const string ColumnKey = "column"; + private const string RollBandKey = "rollBand"; + private const string RollJumpKey = "roll"; + private const string ResultIdKey = "result"; + private const string ModeKey = "mode"; + + public TableContextSnapshot Parse(string uri) + { + var currentUri = new Uri(uri, UriKind.Absolute); + var query = QueryHelpers.ParseQuery(currentUri.Query); + + return new TableContextSnapshot( + ReadValue(query, TableKey), + ReadValue(query, GroupKey), + ReadValue(query, ColumnKey), + ReadValue(query, RollBandKey), + ReadInt(query, RollJumpKey), + ReadInt(query, ResultIdKey), + ReadMode(ReadValue(query, ModeKey))); + } + + public string BuildRelativeUri(string basePath, TableContextSnapshot context) + { + var parameters = new Dictionary(); + AddIfPresent(parameters, TableKey, context.TableSlug); + AddIfPresent(parameters, GroupKey, context.GroupKey); + AddIfPresent(parameters, ColumnKey, context.ColumnKey); + AddIfPresent(parameters, RollBandKey, context.RollBand); + AddIfPresent(parameters, RollJumpKey, context.RollJump?.ToString()); + AddIfPresent(parameters, ResultIdKey, context.ResultId?.ToString()); + AddIfPresent(parameters, ModeKey, WriteMode(context.Mode)); + + return parameters.Count == 0 + ? basePath + : QueryHelpers.AddQueryString(basePath, parameters); + } + + private static void AddIfPresent(IDictionary parameters, string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + parameters[key] = value.Trim(); + } + } + + private static string? ReadValue(IReadOnlyDictionary query, string key) => + query.TryGetValue(key, out var value) + ? value.ToString() + : null; + + private static int? ReadInt(IReadOnlyDictionary query, string key) + { + var value = ReadValue(query, key); + return int.TryParse(value, out var parsed) ? parsed : null; + } + + private static TableContextMode? ReadMode(string? value) => + value?.Trim().ToLowerInvariant() switch + { + "reference" => TableContextMode.Reference, + "curation" => TableContextMode.Curation, + "diagnostics" => TableContextMode.Diagnostics, + _ => null + }; + + private static string? WriteMode(TableContextMode? mode) => + mode switch + { + TableContextMode.Reference => "reference", + TableContextMode.Curation => "curation", + TableContextMode.Diagnostics => "diagnostics", + _ => null + }; +} diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index 66c9d38..5a373c3 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -15,6 +15,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); var app = builder.Build();