diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 37245c7..b4df18d 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -55,6 +55,7 @@ It is intentionally implementation-focused: | 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. | +| 2026-03-21 | P2.8 | Completed | Added a shared `TableContextState` service on top of browser storage and the URL serializer, moved the `Tables` page off page-local table selection persistence, and switched diagnostics to the same restore/persist/build-URI flow so table context logic now lives in shared frontend state instead of being reinvented per page. | ### Lessons Learned @@ -79,6 +80,7 @@ It is intentionally implementation-focused: - 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. +- The serializer alone is not the reusable boundary. The maintainable seam is a shared context-state helper that owns restore, persist, normalization, and URI-building conventions while pages keep only workflow-specific selection logic. ## Target Outcomes @@ -373,7 +375,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest | `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. | +| `P2.8` | Completed | Table-context restore, persistence, normalization, and URI building now flow through one shared state service used by `Tables` and diagnostics. | ### Goal diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index ae09846..7a0b763 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -5,11 +5,10 @@ @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 +@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState Critical Tables @@ -82,6 +81,10 @@ {

No critical tables are available yet.

} + else if (!hasResolvedStoredTableSelection) + { +

Restoring table context...

+ } else if (isDetailLoading) {

Loading the selected table...

@@ -227,7 +230,7 @@ } @code { - private const string SelectedTableStorageKey = "rolemaster.tables.selectedTable"; + private const string ContextDestination = "tables"; private LookupReferenceData? referenceData; private CriticalTableDetail? tableDetail; private string selectedTableSlug = string.Empty; @@ -285,9 +288,8 @@ { selectedTableSlug = tableSlug; isTableMenuOpen = false; - await PersistSelectedTableAsync(tableSlug); await LoadTableDetailAsync(); - SyncTableContextUrl(); + await PersistAndSyncTableContextAsync(); } private async Task LoadTableDetailAsync() @@ -336,35 +338,26 @@ if (!hasResolvedStoredTableSelection && referenceData?.CriticalTables.Count > 0) { - try + var initialContext = await TableContextState.RestoreAsync( + NavigationManager.Uri, + ContextDestination, + referenceData.CriticalTables, + RolemasterDb.App.Frontend.AppState.TableContextMode.Reference); + + hasResolvedStoredTableSelection = true; + + var resolvedTableSlug = initialContext.TableSlug ?? string.Empty; + if (string.IsNullOrWhiteSpace(selectedTableSlug) || + !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) { - var routeContext = TableContextUrlSerializer.Parse(NavigationManager.Uri); - var storedTableSlug = await JSRuntime.InvokeAsync("localStorage.getItem", SelectedTableStorageKey); - hasResolvedStoredTableSelection = true; - - 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; - } - - if (!string.Equals(storedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) - { - await PersistSelectedTableAsync(selectedTableSlug); - } - - SyncTableContextUrl(); - } - catch (InvalidOperationException) - { - // During prerender localStorage is unavailable. Retry after interactive render. + selectedTableSlug = resolvedTableSlug; + await LoadTableDetailAsync(); + await PersistAndSyncTableContextAsync(); + await InvokeAsync(StateHasChanged); + return; } + + await PersistAndSyncTableContextAsync(); } } @@ -781,46 +774,6 @@ private string GetSelectedTableLabel() => SelectedTableReference?.Label ?? "Select a table"; - private string ResolveSelectedTableSlug(string? storedTableSlug) - { - if (referenceData is null || referenceData.CriticalTables.Count == 0) - { - return string.Empty; - } - - if (!string.IsNullOrWhiteSpace(storedTableSlug) && - referenceData.CriticalTables.Any(item => string.Equals(item.Key, storedTableSlug, StringComparison.OrdinalIgnoreCase))) - { - return storedTableSlug; - } - - return referenceData.CriticalTables.First().Key; - } - - 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) @@ -849,6 +802,25 @@ selectedTable.CurationPercentage); } + private async Task PersistAndSyncTableContextAsync() + { + var snapshot = BuildCurrentTableContext(); + await TableContextState.PersistAsync(ContextDestination, snapshot); + + var targetUri = TableContextState.BuildUri("/tables", snapshot); + if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal)) + { + return; + } + + NavigationManager.NavigateTo(targetUri, replace: true); + } + + private RolemasterDb.App.Frontend.AppState.TableContextSnapshot BuildCurrentTableContext() => + new( + TableSlug: selectedTableSlug, + Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference); + private string GetTableOptionCssClass(CriticalTableReference table) { var classes = new List(); diff --git a/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor b/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor index b8a58bf..4694ce3 100644 --- a/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor +++ b/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor @@ -3,7 +3,7 @@ @using System.Linq @inject NavigationManager NavigationManager @inject LookupService LookupService -@inject RolemasterDb.App.Frontend.AppState.TableContextUrlSerializer TableContextUrlSerializer +@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
@@ -21,6 +21,10 @@ {

No critical tables are available yet.

} + else if (!hasInitializedContext) + { +

Restoring diagnostic context...

+ } else {
@@ -160,15 +164,33 @@ private bool isDiagnosticsLoading; private string? detailError; private string? diagnosticsError; + private bool hasInitializedContext; private bool isBusy => isDetailLoading || isDiagnosticsLoading; + private string ContextDestination => "tools.diagnostics"; protected override async Task OnInitializedAsync() { referenceData = await LookupService.GetReferenceDataAsync(); - var routeContext = TableContextUrlSerializer.Parse(NavigationManager.Uri); - selectedTableSlug = ResolveSelectedTableSlug(routeContext.TableSlug); - await LoadTableDetailAsync(routeContext); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || hasInitializedContext || referenceData?.CriticalTables.Count is not > 0) + { + return; + } + + var initialContext = await TableContextState.RestoreAsync( + NavigationManager.Uri, + ContextDestination, + referenceData.CriticalTables, + RolemasterDb.App.Frontend.AppState.TableContextMode.Diagnostics); + + selectedTableSlug = initialContext.TableSlug ?? string.Empty; + hasInitializedContext = true; + await LoadTableDetailAsync(initialContext); + await InvokeAsync(StateHasChanged); } private async Task HandleTableChanged(ChangeEventArgs args) @@ -181,7 +203,7 @@ { selectedRollBand = args.Value?.ToString() ?? string.Empty; ResolveSelectedCell(); - SyncRouteContext(); + await PersistAndSyncRouteContextAsync(); await LoadSelectedCellDiagnosticsAsync(); } @@ -189,7 +211,7 @@ { selectedGroupKey = NormalizeOptionalText(args.Value?.ToString()); ResolveSelectedCell(); - SyncRouteContext(); + await PersistAndSyncRouteContextAsync(); await LoadSelectedCellDiagnosticsAsync(); } @@ -197,7 +219,7 @@ { selectedColumnKey = args.Value?.ToString() ?? string.Empty; ResolveSelectedCell(); - SyncRouteContext(); + await PersistAndSyncRouteContextAsync(); await LoadSelectedCellDiagnosticsAsync(); } @@ -238,7 +260,7 @@ ResolveSelectedCell(); } - SyncRouteContext(); + await PersistAndSyncRouteContextAsync(); await LoadSelectedCellDiagnosticsAsync(); } catch (Exception exception) @@ -370,7 +392,7 @@ } diagnosticsModel = CriticalCellEditorModel.FromResponse(response); - SyncRouteContext(); + await PersistAndSyncRouteContextAsync(); } catch (Exception exception) { @@ -385,39 +407,24 @@ 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() + private async Task PersistAndSyncRouteContextAsync() { 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)); + var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot( + TableSlug: selectedTableSlug, + GroupKey: selectedGroupKey, + ColumnKey: selectedColumnKey, + RollBand: selectedRollBand, + ResultId: selectedCell?.ResultId, + Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Diagnostics); + await TableContextState.PersistAsync(ContextDestination, snapshot); + + var targetUri = TableContextState.BuildUri("/tools/diagnostics", snapshot); if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal)) { return; diff --git a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs index d7de01a..ef65c19 100644 --- a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs +++ b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs @@ -5,4 +5,7 @@ public static class BrowserStorageKeys public const string ThemeMode = "rolemaster.theme.mode"; public const string PinnedTables = "rolemaster.tables.pinned"; public const string RecentTables = "rolemaster.tables.recent"; + + public static string TableContext(string destination) => + $"rolemaster.tables.context.{destination}"; } diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextState.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextState.cs new file mode 100644 index 0000000..ee06c70 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/TableContextState.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using RolemasterDb.App.Features; + +namespace RolemasterDb.App.Frontend.AppState; + +public sealed class TableContextState( + BrowserStorageService browserStorage, + TableContextUrlSerializer serializer) +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public async Task RestoreAsync( + string currentUri, + string destination, + IReadOnlyList availableTables, + TableContextMode defaultMode) + { + var routeContext = serializer.Parse(currentUri); + if (!string.IsNullOrWhiteSpace(routeContext.TableSlug)) + { + return Normalize(routeContext, availableTables, defaultMode); + } + + try + { + var storedValue = await browserStorage.GetItemAsync(BrowserStorageKeys.TableContext(destination)); + if (!string.IsNullOrWhiteSpace(storedValue)) + { + var storedContext = JsonSerializer.Deserialize(storedValue, SerializerOptions); + if (storedContext is not null) + { + return Normalize(storedContext, availableTables, defaultMode); + } + } + } + catch (InvalidOperationException) + { + // JS interop is unavailable during prerender. Fall back to route/default context. + } + catch (JsonException) + { + // Ignore malformed storage payloads and fall back to route/default context. + } + + return Normalize(routeContext, availableTables, defaultMode); + } + + public Task PersistAsync(string destination, TableContextSnapshot context) + { + var serialized = JsonSerializer.Serialize(context, SerializerOptions); + return browserStorage.SetItemAsync(BrowserStorageKeys.TableContext(destination), serialized).AsTask(); + } + + public string BuildUri(string basePath, TableContextSnapshot context) => + serializer.BuildRelativeUri(basePath, context); + + public string ResolveTableSlug(IReadOnlyList availableTables, string? preferredSlug) + { + if (availableTables.Count == 0) + { + return string.Empty; + } + + if (!string.IsNullOrWhiteSpace(preferredSlug) && + availableTables.Any(item => string.Equals(item.Key, preferredSlug, StringComparison.OrdinalIgnoreCase))) + { + return preferredSlug; + } + + return availableTables[0].Key; + } + + private TableContextSnapshot Normalize( + TableContextSnapshot context, + IReadOnlyList availableTables, + TableContextMode defaultMode) => + context with + { + TableSlug = ResolveTableSlug(availableTables, context.TableSlug), + Mode = context.Mode ?? defaultMode + }; +} diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index 5a373c3..5580603 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.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped();