Add table context URL serializer

This commit is contained in:
2026-03-21 14:00:41 +01:00
parent 58df648bd5
commit aa0639ef66
7 changed files with 258 additions and 6 deletions

View File

@@ -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.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.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.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 ### 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. - 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 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. - 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 ## 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.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.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.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.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.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` | Pending | Table-selection logic still lives inside individual pages. |

View File

@@ -4,10 +4,12 @@
@using System.Collections.Generic @using System.Collections.Generic
@using System.Diagnostics.CodeAnalysis @using System.Diagnostics.CodeAnalysis
@using System.Linq @using System.Linq
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject LookupService LookupService @inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState @inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState @inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState
@inject RolemasterDb.App.Frontend.AppState.TableContextUrlSerializer TableContextUrlSerializer
<PageTitle>Critical Tables</PageTitle> <PageTitle>Critical Tables</PageTitle>
@@ -285,6 +287,7 @@
isTableMenuOpen = false; isTableMenuOpen = false;
await PersistSelectedTableAsync(tableSlug); await PersistSelectedTableAsync(tableSlug);
await LoadTableDetailAsync(); await LoadTableDetailAsync();
SyncTableContextUrl();
} }
private async Task LoadTableDetailAsync() private async Task LoadTableDetailAsync()
@@ -335,16 +338,18 @@
{ {
try try
{ {
var routeContext = TableContextUrlSerializer.Parse(NavigationManager.Uri);
var storedTableSlug = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", SelectedTableStorageKey); var storedTableSlug = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", SelectedTableStorageKey);
hasResolvedStoredTableSelection = true; hasResolvedStoredTableSelection = true;
var resolvedTableSlug = ResolveSelectedTableSlug(storedTableSlug); var resolvedTableSlug = ResolveSelectedTableSlug(routeContext.TableSlug ?? storedTableSlug);
if (string.IsNullOrWhiteSpace(selectedTableSlug) || if (string.IsNullOrWhiteSpace(selectedTableSlug) ||
!string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase))
{ {
selectedTableSlug = resolvedTableSlug; selectedTableSlug = resolvedTableSlug;
await LoadTableDetailAsync(); await LoadTableDetailAsync();
await PersistSelectedTableAsync(selectedTableSlug); await PersistSelectedTableAsync(selectedTableSlug);
SyncTableContextUrl();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
return; return;
} }
@@ -353,6 +358,8 @@
{ {
await PersistSelectedTableAsync(selectedTableSlug); await PersistSelectedTableAsync(selectedTableSlug);
} }
SyncTableContextUrl();
} }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {
@@ -793,6 +800,27 @@
private Task PersistSelectedTableAsync(string tableSlug) => private Task PersistSelectedTableAsync(string tableSlug) =>
JSRuntime.InvokeVoidAsync("localStorage.setItem", SelectedTableStorageKey, tableSlug).AsTask(); 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() private Task TogglePinnedTableAsync()
{ {
if (SelectedTableReference is not { } selectedTable) if (SelectedTableReference is not { } selectedTable)

View File

@@ -1,7 +1,9 @@
@using System @using System
@using System.Collections.Generic @using System.Collections.Generic
@using System.Linq @using System.Linq
@inject NavigationManager NavigationManager
@inject LookupService LookupService @inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.TableContextUrlSerializer TableContextUrlSerializer
<section class="panel diagnostics-page tooling-surface"> <section class="panel diagnostics-page tooling-surface">
<header class="diagnostics-page-header"> <header class="diagnostics-page-header">
@@ -164,8 +166,9 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
referenceData = await LookupService.GetReferenceDataAsync(); referenceData = await LookupService.GetReferenceDataAsync();
selectedTableSlug = referenceData.CriticalTables.FirstOrDefault()?.Key ?? string.Empty; var routeContext = TableContextUrlSerializer.Parse(NavigationManager.Uri);
await LoadTableDetailAsync(); selectedTableSlug = ResolveSelectedTableSlug(routeContext.TableSlug);
await LoadTableDetailAsync(routeContext);
} }
private async Task HandleTableChanged(ChangeEventArgs args) private async Task HandleTableChanged(ChangeEventArgs args)
@@ -178,6 +181,7 @@
{ {
selectedRollBand = args.Value?.ToString() ?? string.Empty; selectedRollBand = args.Value?.ToString() ?? string.Empty;
ResolveSelectedCell(); ResolveSelectedCell();
SyncRouteContext();
await LoadSelectedCellDiagnosticsAsync(); await LoadSelectedCellDiagnosticsAsync();
} }
@@ -185,6 +189,7 @@
{ {
selectedGroupKey = NormalizeOptionalText(args.Value?.ToString()); selectedGroupKey = NormalizeOptionalText(args.Value?.ToString());
ResolveSelectedCell(); ResolveSelectedCell();
SyncRouteContext();
await LoadSelectedCellDiagnosticsAsync(); await LoadSelectedCellDiagnosticsAsync();
} }
@@ -192,10 +197,11 @@
{ {
selectedColumnKey = args.Value?.ToString() ?? string.Empty; selectedColumnKey = args.Value?.ToString() ?? string.Empty;
ResolveSelectedCell(); ResolveSelectedCell();
SyncRouteContext();
await LoadSelectedCellDiagnosticsAsync(); await LoadSelectedCellDiagnosticsAsync();
} }
private async Task LoadTableDetailAsync() private async Task LoadTableDetailAsync(RolemasterDb.App.Frontend.AppState.TableContextSnapshot? routeContext = null)
{ {
if (string.IsNullOrWhiteSpace(selectedTableSlug)) if (string.IsNullOrWhiteSpace(selectedTableSlug))
{ {
@@ -220,8 +226,19 @@
return; return;
} }
if (!TryApplySelectionFromContext(tableDetail, routeContext))
{
SetDefaultSelection(tableDetail);
}
ResolveSelectedCell();
if (selectedCell is null && routeContext is not null)
{
SetDefaultSelection(tableDetail); SetDefaultSelection(tableDetail);
ResolveSelectedCell(); ResolveSelectedCell();
}
SyncRouteContext();
await LoadSelectedCellDiagnosticsAsync(); await LoadSelectedCellDiagnosticsAsync();
} }
catch (Exception exception) catch (Exception exception)
@@ -290,6 +307,47 @@
string.Equals(cell.GroupKey ?? string.Empty, selectedGroupKey ?? string.Empty, StringComparison.Ordinal)); 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() private async Task LoadSelectedCellDiagnosticsAsync()
{ {
diagnosticsError = null; diagnosticsError = null;
@@ -312,6 +370,7 @@
} }
diagnosticsModel = CriticalCellEditorModel.FromResponse(response); diagnosticsModel = CriticalCellEditorModel.FromResponse(response);
SyncRouteContext();
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -325,4 +384,66 @@
private static string? NormalizeOptionalText(string? value) => private static string? NormalizeOptionalText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); 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;
}
} }

View File

@@ -0,0 +1,8 @@
namespace RolemasterDb.App.Frontend.AppState;
public enum TableContextMode
{
Reference,
Curation,
Diagnostics
}

View File

@@ -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);

View File

@@ -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<string, string?>();
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<string, string?> parameters, string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
parameters[key] = value.Trim();
}
}
private static string? ReadValue(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
query.TryGetValue(key, out var value)
? value.ToString()
: null;
private static int? ReadInt(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> 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
};
}

View File

@@ -15,6 +15,7 @@ builder.Services.AddScoped<LookupService>();
builder.Services.AddScoped<BrowserStorageService>(); builder.Services.AddScoped<BrowserStorageService>();
builder.Services.AddScoped<PinnedTablesState>(); builder.Services.AddScoped<PinnedTablesState>();
builder.Services.AddScoped<RecentTablesState>(); builder.Services.AddScoped<RecentTablesState>();
builder.Services.AddSingleton<TableContextUrlSerializer>();
builder.Services.AddScoped<ThemeState>(); builder.Services.AddScoped<ThemeState>();
var app = builder.Build(); var app = builder.Build();