Files
RolemasterDB/src/RolemasterDb.App/Components/Pages/Tables.razor

519 lines
18 KiB
Plaintext

@page "/tables"
@rendermode InteractiveServer
@using System
@using System.Linq
@using RolemasterDb.App.Frontend.AppState
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState
@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
<PageTitle>Critical Tables</PageTitle>
<section class="panel tables-page">
<TablesPageHeader/>
@if (referenceData is null)
{
<p class="muted">Loading table list...</p>
}
else if (!referenceData.CriticalTables.Any())
{
<p class="muted">No critical tables are available yet.</p>
}
else
{
<div class="tables-reference-layout">
<aside class="tables-reference-rail">
<TablesIndexRail
Tables="referenceData.CriticalTables"
SelectedTableSlug="selectedTableSlug"
PinnedTableSlugs="PinnedTablesState.Items.Select(item => item.Slug).ToArray()"
RecentTableSlugs="RecentTablesState.Items.Select(item => item.Slug).ToArray()"
IsPinned="PinnedTablesState.IsPinned"
OnSelectTable="SelectTableAsync"/>
</aside>
<div class="tables-reference-main">
@if (!hasResolvedStoredTableSelection)
{
<p class="muted">Restoring table context...</p>
}
else if (isDetailLoading)
{
<p class="muted">Loading the selected table...</p>
}
else if (!string.IsNullOrWhiteSpace(detailError))
{
<p class="error-text">@detailError</p>
}
else if (tableDetail is null)
{
<p class="muted">The selected table could not be loaded.</p>
}
else if (tableDetail is { } detail)
{
<div class="table-shell">
<TablesContextBar
Detail="detail"
IsPinned="PinnedTablesState.IsPinned(detail.Slug)"
OnTogglePin="TogglePinnedTableAsync"
IsLegendOpen="isLegendOpen"
OnToggleLegend="ToggleLegend"/>
<TablesCanvas
Detail="detail"
CurrentMode="referenceMode"
SelectedGroupKey="selectedGroupKey"
SelectedColumnKey="selectedColumnKey"
RollJumpValue="rollJumpValue"
DensityMode="densityMode"
SelectedCell="selectedCell"
OnSelectCell="SelectCell"/>
@if (isLegendOpen)
{
<TablesLegend LegendEntries="@(detail.Legend ?? Array.Empty<CriticalTableLegendEntry>())"/>
}
</div>
}
</div>
</div>
<TablesSelectionMenu
SelectedCellDetail="SelectedCellDetail"
OnEdit="OpenSelectedCellEditorAsync"
OnCurate="OpenSelectedCellCurationAsync"/>
}
</section>
@if (isEditorOpen)
{
<CriticalCellEditorDialog
@key="editorModel"
Model="editorModel"
ComparisonBaseline="editorComparisonBaselineModel"
IsLoading="isEditorLoading"
IsReparsing="isEditorReparsing"
IsSaving="isEditorSaving"
LoadErrorMessage="@editorLoadError"
ReparseErrorMessage="@editorReparseError"
SaveErrorMessage="@editorSaveError"
OnClose="CloseCellEditorAsync"
OnReparse="ReparseCellEditorAsync"
OnSave="SaveCellEditorAsync"/>
}
@code {
private const string ContextDestination = "tables";
private LookupReferenceData? referenceData;
private CriticalTableDetail? tableDetail;
private string selectedTableSlug = string.Empty;
private bool isDetailLoading;
private string? detailError;
private bool isEditorOpen;
private bool isEditorLoading;
private bool isEditorReparsing;
private bool isEditorSaving;
private string? editorLoadError;
private string? editorReparseError;
private string? editorSaveError;
private int? editingResultId;
private CriticalCellEditorModel? editorModel;
private CriticalCellEditorModel? editorComparisonBaselineModel;
private string referenceMode = TablesReferenceMode.Reference;
private string selectedGroupKey = string.Empty;
private string selectedColumnKey = string.Empty;
private string rollJumpValue = string.Empty;
private string densityMode = TablesDensityMode.Comfortable;
private TablesCellSelection? selectedCell;
private bool isLegendOpen;
private bool hasResolvedStoredTableSelection;
private CriticalTableReference? SelectedTableReference =>
referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase));
private CriticalTableCellDetail? SelectedCellDetail =>
selectedCell is null ? null : tableDetail?.Cells.FirstOrDefault(cell => cell.ResultId == selectedCell.ResultId);
protected override async Task OnInitializedAsync()
{
referenceData = await LookupService.GetReferenceDataAsync();
}
private async Task SelectTableAsync(string tableSlug)
{
selectedTableSlug = tableSlug;
await LoadTableDetailAsync();
await PersistAndSyncTableContextAsync();
}
private async Task LoadTableDetailAsync(TableContextSnapshot? routeContext = null)
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
tableDetail = null;
return;
}
isDetailLoading = true;
detailError = null;
tableDetail = null;
try
{
tableDetail = await LookupService.GetCriticalTableAsync(selectedTableSlug);
if (tableDetail is null)
{
detailError = "The selected table could not be loaded.";
NormalizeViewStateForCurrentDetail();
return;
}
await RecordRecentTableVisitAsync();
ApplyRouteContext(routeContext);
NormalizeViewStateForCurrentDetail();
}
catch (Exception exception)
{
detailError = exception.Message;
NormalizeViewStateForCurrentDetail();
}
finally
{
isDetailLoading = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await RecentTablesState.InitializeAsync();
await PinnedTablesState.InitializeAsync();
}
if (!hasResolvedStoredTableSelection && referenceData?.CriticalTables.Count > 0)
{
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))
{
selectedTableSlug = resolvedTableSlug;
await LoadTableDetailAsync(initialContext);
await PersistAndSyncTableContextAsync();
await InvokeAsync(StateHasChanged);
return;
}
ApplyRouteContext(initialContext);
NormalizeViewStateForCurrentDetail();
await PersistAndSyncTableContextAsync();
}
}
private async Task OpenCellEditorAsync(int resultId)
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
return;
}
editorLoadError = null;
editorReparseError = null;
editorSaveError = null;
editorModel = null;
editorComparisonBaselineModel = null;
editingResultId = resultId;
isEditorReparsing = false;
isEditorSaving = false;
isEditorLoading = true;
isEditorOpen = true;
try
{
var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, resultId);
if (response is null)
{
editorLoadError = "The selected cell could not be loaded for editing.";
editorModel = null;
return;
}
editorModel = CriticalCellEditorModel.FromResponse(response);
}
catch (Exception exception)
{
editorLoadError = exception.Message;
editorModel = null;
}
finally
{
isEditorLoading = false;
}
}
private async Task CloseCellEditorAsync()
{
isEditorOpen = false;
isEditorLoading = false;
isEditorReparsing = false;
isEditorSaving = false;
editorLoadError = null;
editorReparseError = null;
editorSaveError = null;
editingResultId = null;
editorModel = null;
editorComparisonBaselineModel = null;
await InvokeAsync(StateHasChanged);
}
private async Task ReparseCellEditorAsync()
{
if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null)
{
return;
}
isEditorReparsing = true;
editorReparseError = null;
try
{
var comparisonBaseline = editorModel.Clone();
var response = await ReparseCriticalCellAsync(editorModel, editingResultId.Value);
if (response is null)
{
editorReparseError = "The selected cell could not be re-parsed.";
return;
}
editorComparisonBaselineModel = comparisonBaseline;
editorModel = CriticalCellEditorModel.FromResponse(response);
await InvokeAsync(StateHasChanged);
}
catch (Exception exception)
{
editorReparseError = exception.Message;
}
finally
{
isEditorReparsing = false;
}
}
private Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(CriticalCellEditorModel model, int resultId) =>
LookupService.ReparseCriticalCellAsync(selectedTableSlug, resultId, model.ToRequest());
private async Task SaveCellEditorAsync()
{
if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null)
{
return;
}
isEditorSaving = true;
editorSaveError = null;
try
{
var response = await LookupService.UpdateCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.ToRequest());
if (response is null)
{
editorSaveError = "The selected cell could not be saved.";
return;
}
await LoadTableDetailAsync();
await CloseCellEditorAsync();
}
catch (Exception exception)
{
editorSaveError = exception.Message;
}
finally
{
isEditorSaving = false;
}
}
private Task TogglePinnedTableAsync()
{
if (SelectedTableReference is not { } selectedTable)
{
return Task.CompletedTask;
}
return PinnedTablesState.ToggleAsync(selectedTable.Key, selectedTable.Label, selectedTable.Family, selectedTable.CurationPercentage);
}
private Task RecordRecentTableVisitAsync()
{
if (SelectedTableReference is not { } selectedTable)
{
return Task.CompletedTask;
}
return RecentTablesState.RecordVisitAsync(selectedTable.Key, selectedTable.Label, selectedTable.Family, 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, GroupKey: selectedCell?.GroupKey, ColumnKey: selectedCell?.ColumnKey, RollBand: selectedCell?.RollBand, ResultId: selectedCell?.ResultId, Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
private void SelectCell(TablesCellSelection selection)
{
if (tableDetail?.Cells.Any(cell => cell.ResultId == selection.ResultId) != true)
{
selectedCell = null;
return;
}
selectedCell = selection;
}
private Task OpenSelectedCellEditorAsync() =>
selectedCell is null ? Task.CompletedTask : OpenCellEditorAsync(selectedCell.ResultId);
private async Task OpenSelectedCellCurationAsync()
{
if (SelectedCellDetail is not { } cellDetail || string.IsNullOrWhiteSpace(selectedTableSlug))
{
return;
}
var snapshot = new TableContextSnapshot(TableSlug: selectedTableSlug, GroupKey: cellDetail.GroupKey, ColumnKey: cellDetail.ColumnKey, RollBand: cellDetail.RollBand, ResultId: cellDetail.ResultId, Mode: TableContextMode.Curation, QueueScope: CurationQueueScopes.SelectedTable);
await TableContextState.PersistAsync("curation", snapshot);
NavigationManager.NavigateTo(TableContextState.BuildUri("/curation", snapshot));
}
private Task ToggleLegend()
{
isLegendOpen = !isLegendOpen;
return Task.CompletedTask;
}
private void ApplyRouteContext(TableContextSnapshot? routeContext)
{
if (tableDetail is null)
{
selectedCell = null;
return;
}
var resolvedCell = TableContextCellResolver.FindCell(tableDetail, routeContext);
selectedCell = resolvedCell is null ? null : new TablesCellSelection(resolvedCell.ResultId, resolvedCell.RollBand, resolvedCell.ColumnKey, resolvedCell.GroupKey);
}
private void NormalizeViewStateForCurrentDetail()
{
referenceMode = NormalizeMode(referenceMode);
if (tableDetail is null)
{
selectedGroupKey = string.Empty;
selectedColumnKey = string.Empty;
rollJumpValue = string.Empty;
densityMode = NormalizeDensityMode(densityMode);
selectedCell = null;
return;
}
if (tableDetail.Groups.All(group => !string.Equals(group.Key, selectedGroupKey, StringComparison.OrdinalIgnoreCase)))
{
selectedGroupKey = string.Empty;
}
var matchingColumns = tableDetail.Columns.Where(column => string.IsNullOrWhiteSpace(selectedColumnKey) || string.Equals(column.Key, selectedColumnKey, StringComparison.OrdinalIgnoreCase)).ToList();
if (matchingColumns.Count == 0)
{
selectedColumnKey = string.Empty;
}
rollJumpValue = NormalizeRollInput(rollJumpValue);
densityMode = NormalizeDensityMode(densityMode);
if (selectedCell is not null && tableDetail.Cells.All(cell => cell.ResultId != selectedCell.ResultId))
{
selectedCell = null;
}
NormalizeSelectedCellForCurrentView();
}
private static string NormalizeMode(string? mode) =>
mode switch
{
TablesReferenceMode.NeedsCuration => TablesReferenceMode.NeedsCuration,
TablesReferenceMode.Curated => TablesReferenceMode.Curated,
_ => TablesReferenceMode.Reference
};
private static string NormalizeDensityMode(string? mode) =>
string.Equals(mode, TablesDensityMode.Dense, StringComparison.Ordinal) ? TablesDensityMode.Dense : TablesDensityMode.Comfortable;
private static string NormalizeRollInput(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var digitsOnly = new string(value.Where(char.IsDigit).ToArray());
return digitsOnly.Length == 0 ? string.Empty : digitsOnly;
}
private void NormalizeSelectedCellForCurrentView()
{
if (selectedCell is null || tableDetail is null)
{
return;
}
var cell = tableDetail.Cells.FirstOrDefault(item => item.ResultId == selectedCell.ResultId);
if (cell is null || !MatchesCurrentView(cell))
{
selectedCell = null;
}
}
private bool MatchesCurrentView(CriticalTableCellDetail cell)
{
if (!string.IsNullOrWhiteSpace(selectedGroupKey) && !string.Equals(cell.GroupKey, selectedGroupKey, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(selectedColumnKey) && !string.Equals(cell.ColumnKey, selectedColumnKey, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return referenceMode switch
{
TablesReferenceMode.NeedsCuration => !cell.IsCurated,
TablesReferenceMode.Curated => cell.IsCurated,
_ => true
};
}
}