diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index fea82fe..d3cdcb4 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -30,7 +30,7 @@ It is intentionally implementation-focused: - Branch: `frontend/tables-overhaul` - Last updated: `2026-04-12` -- Current focus: `Phase 4` +- Current focus: `Phase 5` - Document mode: living plan and progress log ### Progress Log @@ -76,6 +76,7 @@ It is intentionally implementation-focused: | 2026-03-21 | Post-P3 fix 1 | Completed | Added a defensive visible-column fallback in the table canvas and tightened view-state normalization so a stale severity filter cannot collapse the grid to roll bands only. | | 2026-04-11 | Post-P3 fix 2 | Completed | Simplified `/tables` by removing static prose and context controls, dropped the redundant selected-result inspector in favor of a floating action menu, and moved the canvas onto its own scroll region so sticky headers layer correctly beneath the context bar. | | 2026-04-12 | Phase 4 planning | Planned | Expanded the `Curation` phase from a route placeholder into a concrete migration plan that moves queue-first curation out of `Tables` and into a dedicated workflow surface. | +| 2026-04-12 | Phase 4 | Completed | Replaced the placeholder `/curation` route with a real queue-first workspace, added queue scope and context persistence, moved browse-to-curation handoff out of `Tables`, and preserved diagnostics and full-editor escape hatches without keeping queue work on the reference page. | ### Lessons Learned @@ -565,22 +566,22 @@ Turn `/tables` into the canonical reference surface for reading and inspecting c ### Status -`Planned` +`Completed` ### Task Progress | Task | Status | Notes | | --- | --- | --- | -| `P4.1` | Planned | Replace the current placeholder route with a real stateful `Curation` host page while keeping shell navigation and route identity stable. | -| `P4.2` | Planned | Reuse the shared table-context and selection patterns from Phase 2 and Phase 3 so queue state, deep links, and restore behavior stay consistent with `Tables`. | -| `P4.3` | Planned | Introduce one normalized queue-scope model for `All tables`, `Selected table`, and `Pinned set` instead of hard-coding the current table-only flow. | -| `P4.4` | Planned | Introduce a shared queue descriptor and next-item selection pipeline so queue ordering and scope resolution are not trapped inside `Tables.razor`. | -| `P4.5` | Planned | Build a queue-first split workspace with summary, source image, parsed preview, quick parse input, and predictable primary actions. | -| `P4.6` | Planned | Rehost the current load, reparse, mark-curated, and save-and-advance mechanics from `Tables` with minimal churn instead of rewriting them from scratch. | -| `P4.7` | Planned | Preserve full editor access as an escape hatch while making `Mark curated` and `Quick parse` the common fast path. | -| `P4.8` | Planned | Keep save-and-advance inside the same workflow lane by retaining scope, queue ordering, and current work context after save. | -| `P4.9` | Planned | Keep the action hierarchy disciplined so warning treatment is reserved for disruptive repair actions rather than normal save flow. | -| `P4.10` | Planned | Remove raw diagnostics and provenance details from the default curation lane and link out to `Tools` when deep inspection is needed. | +| `P4.1` | Completed | The placeholder route was replaced with an interactive `Curation` page host that owns queue scope, queue item, loading, quick-parse, and save-and-advance state. | +| `P4.2` | Completed | `Curation` now restores and persists context through the shared table-context infrastructure and reuses the same deep-link object identifiers as `Tables` and diagnostics. | +| `P4.3` | Completed | The new queue surface supports `All tables`, `Selected table`, and `Pinned set` scopes. | +| `P4.4` | Completed | Queue ordering and next-item resolution now live in dedicated frontend curation helpers instead of inside `Tables.razor`. | +| `P4.5` | Completed | The page now presents a stable queue-first split workspace with queue summary, source image, parsed preview, inline quick parse, and fixed primary actions. | +| `P4.6` | Completed | The existing load, reparse, mark-curated, and save-and-advance mechanics were rehosted into `Curation` with minimal backend churn. | +| `P4.7` | Completed | Full editor access remains available from the curation lane, while `Quick parse` and `Mark curated and continue` are the primary fast path. | +| `P4.8` | Completed | Save-and-advance keeps the user in the same scope and walks the queue forward without reopening context. | +| `P4.9` | Completed | Normal save flow stays on the primary accent action hierarchy and does not borrow warning treatment. | +| `P4.10` | Completed | Diagnostics remain out of the default curation lane and are linked contextually through `Tools` instead of embedded inline. | ### Current Baseline diff --git a/src/RolemasterDb.App/Components/Curation/CurationQueueBar.razor b/src/RolemasterDb.App/Components/Curation/CurationQueueBar.razor new file mode 100644 index 0000000..100960f --- /dev/null +++ b/src/RolemasterDb.App/Components/Curation/CurationQueueBar.razor @@ -0,0 +1,99 @@ +@using RolemasterDb.App.Frontend.Curation + +
+
+
+

Curation

+ Queue-first repair workspace +
+ + +
+ + + + @if (string.Equals(SelectedScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal)) + { +
+ + +
+ } + + @if (CurrentItem is not null) + { +
+ Needs curation + @CurrentItem.TableName + Roll band @CurrentItem.RollBand + Severity @CurrentItem.ColumnLabel + @if (!string.IsNullOrWhiteSpace(CurrentItem.GroupLabel)) + { + Variant @CurrentItem.GroupLabel + } + Result ID @CurrentItem.ResultId +
+ } +
+ +@code { + + [Parameter] + public IReadOnlyList ScopeItems { get; set; } = []; + + [Parameter] + public string SelectedScope { get; set; } = CurationQueueScopes.AllTables; + + [Parameter, EditorRequired] + public EventCallback SelectedScopeChanged { get; set; } + + [Parameter] + public IReadOnlyList Tables { get; set; } = []; + + [Parameter] + public string SelectedTableSlug { get; set; } = string.Empty; + + [Parameter, EditorRequired] + public EventCallback SelectedTableSlugChanged { get; set; } + + [Parameter] + public CurationQueueItem? CurrentItem { get; set; } + + [Parameter] + public bool IsBusy { get; set; } + + [Parameter] + public string? TablesHref { get; set; } + + [Parameter] + public string? DiagnosticsHref { get; set; } + + private Task HandleTableChanged(ChangeEventArgs args) => + SelectedTableSlugChanged.InvokeAsync(args.Value?.ToString() ?? string.Empty); + +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Components/Curation/CurationQueueEmptyState.razor b/src/RolemasterDb.App/Components/Curation/CurationQueueEmptyState.razor new file mode 100644 index 0000000..1834e50 --- /dev/null +++ b/src/RolemasterDb.App/Components/Curation/CurationQueueEmptyState.razor @@ -0,0 +1,27 @@ +
+
+

@Title

+

@Message

+
+ + @if (!string.IsNullOrWhiteSpace(ActionHref) && !string.IsNullOrWhiteSpace(ActionLabel)) + { + @ActionLabel + } +
+ +@code { + + [Parameter] + public string Title { get; set; } = "Queue empty"; + + [Parameter] + public string Message { get; set; } = string.Empty; + + [Parameter] + public string? ActionHref { get; set; } + + [Parameter] + public string? ActionLabel { get; set; } + +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor b/src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor new file mode 100644 index 0000000..c54a537 --- /dev/null +++ b/src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor @@ -0,0 +1,248 @@ +@using System.Collections.Generic +@using System.Linq +@using Microsoft.AspNetCore.Components.Web + +@if (IsLoading) +{ +
+

@LoadingMessage

+
+} +else if (Model is null) +{ +
+ @if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage())) + { + + } + else + { +

@EmptyMessage

+ } +
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage())) + { + + } + +
+
+ @if (IsQuickParseMode) + { + + } + else + { + + } +
+ +
+ @if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl)) + { + @CriticalCellPresentation.BuildSourceImageAltText(Model) + } + else + { +
+

No source image is available for this cell yet.

+
+ } +
+
+ + @if (!IsQuickParseMode) + { + @if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries) + { +
+ @foreach (var entry in usedLegendEntries) + { +
+ @entry.Symbol + @entry.Label +
+ } +
+ } + } + +
+ @if (IsQuickParseMode) + { + + + } + else + { + @if (ShowSecondaryAction) + { + + } + + @if (ShowPrimaryAction) + { + + } + } +
+
+} + +@code { + + [Parameter, EditorRequired] + public CriticalCellEditorModel? Model { get; set; } + + [Parameter] + public bool IsLoading { get; set; } + + [Parameter] + public bool IsSaving { get; set; } + + [Parameter] + public bool IsReparsing { get; set; } + + [Parameter] + public bool IsQuickParseMode { get; set; } + + [Parameter] + public string? ErrorMessage { get; set; } + + [Parameter] + public string? QuickParseErrorMessage { get; set; } + + [Parameter] + public IReadOnlyList? LegendEntries { get; set; } + + [Parameter] + public string LoadingMessage { get; set; } = "Loading curation preview..."; + + [Parameter] + public string EmptyMessage { get; set; } = "No curation preview is available."; + + [Parameter] + public string PrimaryActionLabel { get; set; } = "Mark curated"; + + [Parameter] + public string PrimaryBusyLabel { get; set; } = "Saving..."; + + [Parameter] + public string PrimaryActionCssClass { get; set; } = "btn-ritual"; + + [Parameter] + public bool ShowPrimaryAction { get; set; } = true; + + [Parameter] + public string SecondaryActionLabel { get; set; } = "Open full editor"; + + [Parameter] + public string SecondaryActionCssClass { get; set; } = "btn btn-secondary"; + + [Parameter] + public bool ShowSecondaryAction { get; set; } = true; + + [Parameter] + public string ReparseActionLabel { get; set; } = "Parse"; + + [Parameter] + public string ReparseBusyLabel { get; set; } = "Parsing..."; + + [Parameter, EditorRequired] + public EventCallback OnPrimaryAction { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnSecondaryAction { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnEnterQuickParse { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnCancelQuickParse { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnReparse { get; set; } + + private CriticalCellQuickParseEditor? quickParseEditor; + private bool shouldFocusQuickParseEditor; + private bool wasQuickParseMode; + + protected override void OnParametersSet() + { + if (IsQuickParseMode && !wasQuickParseMode) + { + shouldFocusQuickParseEditor = true; + } + + wasQuickParseMode = IsQuickParseMode; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (shouldFocusQuickParseEditor && quickParseEditor is not null) + { + shouldFocusQuickParseEditor = false; + await quickParseEditor.FocusAsync(); + } + } + + private static IReadOnlyList GetUsedLegendEntries(CriticalCellEditorModel model, IReadOnlyList? legendEntries) + { + if (legendEntries is null || legendEntries.Count == 0) + { + return []; + } + + var usedEffectCodes = model.Effects.Select(effect => effect.EffectCode).Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode))).Where(effectCode => !string.IsNullOrWhiteSpace(effectCode)).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return legendEntries.Where(entry => usedEffectCodes.Contains(entry.EffectCode)).ToList(); + } + + private async Task HandleReparseClickAsync(MouseEventArgs _) + { + if (quickParseEditor is not null) + { + await quickParseEditor.ReparseAsync(); + return; + } + + await OnReparse.InvokeAsync(); + } + + private string? GetVisibleErrorMessage() => + IsQuickParseMode && !string.IsNullOrWhiteSpace(QuickParseErrorMessage) ? QuickParseErrorMessage : ErrorMessage; + +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Components/Pages/Curation.razor b/src/RolemasterDb.App/Components/Pages/Curation.razor index 479030c..1474bf1 100644 --- a/src/RolemasterDb.App/Components/Pages/Curation.razor +++ b/src/RolemasterDb.App/Components/Pages/Curation.razor @@ -1,8 +1,616 @@ @page "/curation" +@rendermode InteractiveServer +@using RolemasterDb.App.Frontend.AppState +@inject NavigationManager NavigationManager +@inject LookupService LookupService +@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState +@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState Curation -
-

Curation

-

The dedicated queue-first curation workflow lands in Phase 4. The shell route exists now so navigation can move to the target product model before the workflow rewrite begins.

+
+ @if (referenceData is null) + { +
+ Loading curation queue... +
+ } + else if (!hasInitializedContext) + { +
+ Restoring curation context... +
+ } + else + { + + + @if (!string.IsNullOrWhiteSpace(pageError)) + { + + } + else if (currentQueueItem is null) + { + + } + else + { +
+ +
+ } + }
+ +@if (isEditorOpen) +{ + +} + +@code { + private const string ContextDestination = "curation"; + + private readonly Dictionary tableDetailCache = new(StringComparer.OrdinalIgnoreCase); + private LookupReferenceData? referenceData; + private string selectedQueueScope = CurationQueueScopes.AllTables; + private string selectedTableSlug = string.Empty; + private bool hasInitializedContext; + private bool isCurationLoading; + private bool isCurationSaving; + private bool isCurationReparsing; + private bool isCurationQuickParseMode; + private string? pageError; + private string? curationError; + private string? curationQuickParseError; + private CurationQueueItem? currentQueueItem; + private CriticalTableDetail? currentTableDetail; + private CriticalCellEditorModel? curationModel; + private bool isEditorOpen; + private bool isEditorLoading; + private bool isEditorReparsing; + private bool isEditorSaving; + private string? editorLoadError; + private string? editorReparseError; + private string? editorSaveError; + private CriticalCellEditorModel? editorModel; + private CriticalCellEditorModel? editorComparisonBaselineModel; + + private bool IsBusy => + isCurationLoading || isCurationSaving || isCurationReparsing || isEditorLoading || isEditorReparsing || isEditorSaving; + + protected override async Task OnInitializedAsync() + { + referenceData = await LookupService.GetReferenceDataAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || hasInitializedContext || referenceData?.CriticalTables.Count is not > 0) + { + return; + } + + await PinnedTablesState.InitializeAsync(); + + var initialContext = await TableContextState.RestoreAsync(NavigationManager.Uri, ContextDestination, referenceData.CriticalTables, TableContextMode.Curation); + + selectedQueueScope = CurationQueueScopes.Normalize(initialContext.QueueScope); + selectedTableSlug = initialContext.TableSlug ?? string.Empty; + hasInitializedContext = true; + + await LoadQueueAsync(initialContext); + await InvokeAsync(StateHasChanged); + } + + private async Task HandleQueueScopeChangedAsync(string scope) + { + selectedQueueScope = CurationQueueScopes.Normalize(scope); + await LoadQueueAsync(); + } + + private async Task HandleSelectedTableChangedAsync(string tableSlug) + { + selectedTableSlug = tableSlug; + await LoadQueueAsync(); + } + + private async Task LoadQueueAsync(TableContextSnapshot? preferredContext = null) + { + isCurationLoading = true; + pageError = null; + + try + { + var (detail, item) = await ResolveQueueItemAsync(preferredContext); + await ApplyResolvedQueueItemAsync(detail, item, "The selected cell could not be loaded for curation."); + } + catch (Exception exception) + { + currentTableDetail = null; + currentQueueItem = null; + curationModel = null; + curationError = null; + curationQuickParseError = null; + pageError = exception.Message; + } + finally + { + isCurationLoading = false; + } + } + + private async Task ApplyResolvedQueueItemAsync(CriticalTableDetail? detail, CurationQueueItem? item, string loadFailureMessage) + { + currentTableDetail = detail; + currentQueueItem = item; + curationModel = null; + curationError = null; + curationQuickParseError = null; + isCurationQuickParseMode = false; + + if (item is not null) + { + selectedTableSlug = item.TableSlug; + var response = await LookupService.GetCriticalCellEditorAsync(item.TableSlug, item.ResultId); + if (response is null) + { + curationError = loadFailureMessage; + } + else + { + curationModel = CriticalCellEditorModel.FromResponse(response); + } + } + + await PersistAndSyncCurationContextAsync(); + } + + private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveQueueItemAsync(TableContextSnapshot? preferredContext = null) + { + var candidateSlugs = GetCandidateTableSlugs(); + if (candidateSlugs.Count == 0) + { + return (null, null); + } + + foreach (var slug in OrderCandidatesForRestore(candidateSlugs, preferredContext?.TableSlug)) + { + var detail = await GetTableDetailAsync(slug); + if (detail is null) + { + continue; + } + + if (preferredContext is not null && string.Equals(slug, preferredContext.TableSlug, StringComparison.OrdinalIgnoreCase) && CurationQueueResolver.FindCell(detail, preferredContext) is { IsCurated: false } preferredCell) + { + return (detail, CurationQueueResolver.CreateQueueItem(detail, preferredCell)); + } + + if (CurationQueueResolver.FindFirstUncurated(detail) is { } firstCell) + { + return (detail, CurationQueueResolver.CreateQueueItem(detail, firstCell)); + } + + if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal)) + { + return (detail, null); + } + } + + return (null, null); + } + + private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveNextQueueItemAsync() + { + if (currentQueueItem is null) + { + return await ResolveQueueItemAsync(); + } + + var candidateSlugs = GetCandidateTableSlugs(); + var currentTableIndex = candidateSlugs.FindIndex(slug => string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase)); + if (currentTableIndex < 0) + { + return await ResolveQueueItemAsync(); + } + + for (var index = currentTableIndex; index < candidateSlugs.Count; index++) + { + var slug = candidateSlugs[index]; + var detail = await GetTableDetailAsync(slug, forceRefresh: string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase)); + if (detail is null) + { + continue; + } + + CriticalTableCellDetail? nextCell = string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase) ? CurationQueueResolver.FindNextUncurated(detail, currentQueueItem.ResultId) : CurationQueueResolver.FindFirstUncurated(detail); + + if (nextCell is not null) + { + return (detail, CurationQueueResolver.CreateQueueItem(detail, nextCell)); + } + + if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal)) + { + return (detail, null); + } + } + + return (null, null); + } + + private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveCurrentQueueItemAsync() + { + if (currentQueueItem is null) + { + return (null, null); + } + + var detail = await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true); + if (detail is null) + { + return (null, null); + } + + var cell = detail.Cells.FirstOrDefault(item => item.ResultId == currentQueueItem.ResultId); + return cell is null ? (detail, null) : (detail, CurationQueueResolver.CreateQueueItem(detail, cell)); + } + + private async Task GetTableDetailAsync(string slug, bool forceRefresh = false) + { + if (!forceRefresh && tableDetailCache.TryGetValue(slug, out var cachedDetail)) + { + return cachedDetail; + } + + var detail = await LookupService.GetCriticalTableAsync(slug); + tableDetailCache[slug] = detail; + return detail; + } + + private List GetCandidateTableSlugs() + { + if (referenceData?.CriticalTables.Count is not > 0) + { + return []; + } + + return selectedQueueScope switch + { + CurationQueueScopes.SelectedTable when !string.IsNullOrWhiteSpace(selectedTableSlug) => [TableContextState.ResolveTableSlug(referenceData.CriticalTables, selectedTableSlug)], + CurationQueueScopes.PinnedSet => referenceData.CriticalTables.Where(table => PinnedTablesState.IsPinned(table.Key)).Select(table => table.Key).ToList(), + _ => referenceData.CriticalTables.Select(table => table.Key).ToList() + }; + } + + private static IReadOnlyList OrderCandidatesForRestore(IReadOnlyList candidates, string? preferredSlug) + { + if (string.IsNullOrWhiteSpace(preferredSlug)) + { + return candidates; + } + + var ordered = new List(candidates.Count); + foreach (var slug in candidates) + { + if (string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(slug); + break; + } + } + + foreach (var slug in candidates) + { + if (!string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(slug); + } + } + + return ordered; + } + + private Task EnterCurationQuickParseMode() + { + if (curationModel is null) + { + return Task.CompletedTask; + } + + curationQuickParseError = null; + isCurationQuickParseMode = true; + return Task.CompletedTask; + } + + private Task CancelCurationQuickParseMode() + { + if (isCurationReparsing) + { + return Task.CompletedTask; + } + + curationQuickParseError = null; + isCurationQuickParseMode = false; + return Task.CompletedTask; + } + + private async Task ReparseCurationCellAsync() + { + if (curationModel is null || currentQueueItem is null) + { + return; + } + + isCurationReparsing = true; + curationQuickParseError = null; + + try + { + var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest()); + if (response is null) + { + curationQuickParseError = "The selected cell could not be re-parsed."; + return; + } + + curationModel = CriticalCellEditorModel.FromResponse(response); + isCurationQuickParseMode = false; + await InvokeAsync(StateHasChanged); + } + catch (Exception exception) + { + curationQuickParseError = exception.Message; + } + finally + { + isCurationReparsing = false; + } + } + + private async Task MarkCellCuratedAsync() + { + if (curationModel is null || currentQueueItem is null) + { + return; + } + + isCurationSaving = true; + curationError = null; + + try + { + curationModel.IsCurated = true; + var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest()); + if (response is null) + { + curationError = "The selected cell could not be marked curated."; + return; + } + + await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true); + var (detail, item) = await ResolveNextQueueItemAsync(); + await ApplyResolvedQueueItemAsync(detail, item, "The next cell could not be loaded for curation."); + } + catch (Exception exception) + { + curationError = exception.Message; + } + finally + { + isCurationSaving = false; + } + } + + private Task OpenCellEditorAsync() + { + if (curationModel is null) + { + return Task.CompletedTask; + } + + editorLoadError = null; + editorReparseError = null; + editorSaveError = null; + editorComparisonBaselineModel = null; + editorModel = curationModel.Clone(); + isEditorLoading = false; + isEditorReparsing = false; + isEditorSaving = false; + isEditorOpen = true; + return Task.CompletedTask; + } + + private async Task CloseCellEditorAsync() + { + isEditorOpen = false; + isEditorLoading = false; + isEditorReparsing = false; + isEditorSaving = false; + editorLoadError = null; + editorReparseError = null; + editorSaveError = null; + editorModel = null; + editorComparisonBaselineModel = null; + await InvokeAsync(StateHasChanged); + } + + private async Task ReparseCellEditorAsync() + { + if (editorModel is null || currentQueueItem is null) + { + return; + } + + isEditorReparsing = true; + editorReparseError = null; + + try + { + var comparisonBaseline = editorModel.Clone(); + var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest()); + 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 async Task SaveCellEditorAsync() + { + if (editorModel is null || currentQueueItem is null) + { + return; + } + + isEditorSaving = true; + editorSaveError = null; + + try + { + var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest()); + if (response is null) + { + editorSaveError = "The selected cell could not be saved."; + return; + } + + await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true); + await CloseCellEditorAsync(); + + if (response.IsCurated) + { + var (nextDetail, nextItem) = await ResolveNextQueueItemAsync(); + await ApplyResolvedQueueItemAsync(nextDetail, nextItem, "The next cell could not be loaded for curation."); + } + else + { + var (detail, item) = await ResolveCurrentQueueItemAsync(); + await ApplyResolvedQueueItemAsync(detail, item, "The edited cell could not be reloaded for curation."); + } + } + catch (Exception exception) + { + editorSaveError = exception.Message; + } + finally + { + isEditorSaving = false; + } + } + + private async Task PersistAndSyncCurationContextAsync() + { + var snapshot = BuildCurrentCurationContext(); + await TableContextState.PersistAsync(ContextDestination, snapshot); + + var targetUri = TableContextState.BuildUri("/curation", snapshot); + if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal)) + { + return; + } + + NavigationManager.NavigateTo(targetUri, replace: true); + } + + private TableContextSnapshot BuildCurrentCurationContext() => + new(TableSlug: string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal) ? selectedTableSlug : currentQueueItem?.TableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Curation, QueueScope: selectedQueueScope); + + private string BuildTablesUri() + { + var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem?.TableSlug ?? selectedTableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Reference); + + return TableContextState.BuildUri("/tables", snapshot); + } + + private string? BuildDiagnosticsUri() + { + if (currentQueueItem is null) + { + return null; + } + + var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem.TableSlug, GroupKey: currentQueueItem.GroupKey, ColumnKey: currentQueueItem.ColumnKey, RollBand: currentQueueItem.RollBand, ResultId: currentQueueItem.ResultId, Mode: TableContextMode.Diagnostics); + + return TableContextState.BuildUri("/tools/diagnostics", snapshot); + } + + private IReadOnlyList BuildScopeItems() => + [ + new(CurationQueueScopes.AllTables, "All tables"), + new(CurationQueueScopes.SelectedTable, "Selected table"), + new(CurationQueueScopes.PinnedSet, "Pinned set", PinnedTablesState.Items.Count == 0 ? null : PinnedTablesState.Items.Count.ToString()) + ]; + + private string BuildEmptyStateTitle() => + selectedQueueScope switch + { + CurationQueueScopes.SelectedTable => "This table has no remaining queue items", + CurationQueueScopes.PinnedSet => "Pinned queue is empty", + _ => "Curation queue complete" + }; + + private string BuildEmptyStateMessage() => + selectedQueueScope switch + { + CurationQueueScopes.SelectedTable => "Choose another table scope or return to reference mode to inspect already curated entries.", + CurationQueueScopes.PinnedSet when PinnedTablesState.Items.Count == 0 => "Pin one or more tables first, then return to the pinned queue lane.", + CurationQueueScopes.PinnedSet => "Every uncurated result in the pinned set has already been reviewed.", + _ => "No uncurated results remain in the current queue scope." + }; + +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 98673ce..3a4bc78 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -2,6 +2,7 @@ @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 @@ -87,26 +88,6 @@ }
-@if (isCurationOpen) -{ - -} - @if (isEditorOpen) { GetGroupSortOrder(cell.GroupKey)).ThenBy(cell => GetColumnSortOrder(cell.ColumnKey)).ThenBy(cell => GetRollBandSortOrder(cell.RollBand)).ToList(); - - var currentIndex = orderedCells.FindIndex(cell => cell.ResultId == currentResultId); - for (var index = currentIndex + 1; index < orderedCells.Count; index++) - { - if (!orderedCells[index].IsCurated) - { - return orderedCells[index].ResultId; - } - } - - return null; - } - - private int GetGroupSortOrder(string? groupKey) - { - if (tableDetail is null || string.IsNullOrWhiteSpace(groupKey)) - { - return 0; - } - - return tableDetail.Groups.FirstOrDefault(group => string.Equals(group.Key, groupKey, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue; - } - - private int GetColumnSortOrder(string columnKey) - { - if (tableDetail is null) - { - return int.MaxValue; - } - - return tableDetail.Columns.FirstOrDefault(column => string.Equals(column.Key, columnKey, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue; - } - - private int GetRollBandSortOrder(string rollBandLabel) - { - if (tableDetail is null) - { - return int.MaxValue; - } - - return tableDetail.RollBands.FirstOrDefault(rollBand => string.Equals(rollBand.Label, rollBandLabel, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue; - } - private async Task CloseCellEditorAsync() { isEditorOpen = false; @@ -639,8 +389,18 @@ private Task OpenSelectedCellEditorAsync() => selectedCell is null ? Task.CompletedTask : OpenCellEditorAsync(selectedCell.ResultId); - private Task OpenSelectedCellCurationAsync() => - selectedCell is null ? Task.CompletedTask : OpenCellCurationAsync(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() { diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor index cc8a42f..39dc8ec 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor @@ -1,7 +1,5 @@ -@using System.Collections.Generic -@using System.Linq -@using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop +@using RolemasterDb.App.Components.Curation @using RolemasterDb.App.Features @implements IAsyncDisposable @inject IJSRuntime JSRuntime @@ -15,8 +13,8 @@ @onpointerup="HandleDialogPointerUp" @onpointercancel="HandleDialogPointerCancel" @onpointerdown:stopPropagation="true" - @onpointerup:stopPropagation="true" - @onpointercancel:stopPropagation="true"> + @onpointerup:stopPropagation="true" + @onpointercancel:stopPropagation="true">

Curate Result Card

@@ -32,120 +30,30 @@
- @if (IsLoading) - { -
-

Loading curation preview...

-
- } - else if (Model is null) - { -
- @if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage())) - { -

@GetVisibleErrorMessage()

- } - else - { -

No curation preview is available.

- } -
- } - else - { -
- @if (!string.IsNullOrWhiteSpace(ErrorMessage)) - { -

@ErrorMessage

- } - -
-
- @if (IsQuickParseMode) - { - - } - else - { - - } -
- -
- @if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl)) - { - @CriticalCellPresentation.BuildSourceImageAltText(Model) - } - else - { -
-

No source image is available for this cell yet.

-
- } -
-
- - @if (!IsQuickParseMode) - { - @if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries) - { -
- @foreach (var entry in usedLegendEntries) - { -
- @entry.Symbol - @entry.Label -
- } -
- } - } -
- -
- @if (IsQuickParseMode) - { - - - } - else - { - - - } -
- } +
+ +
@code { + [Parameter, EditorRequired] public CriticalCellEditorModel? Model { get; set; } @@ -190,25 +98,15 @@ private IJSObjectReference? jsModule; private bool isBackdropPointerDown; - private CriticalCellQuickParseEditor? quickParseEditor; - private bool shouldFocusQuickParseEditor; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { - jsModule = await JSRuntime.InvokeAsync( - "import", - "./Components/Shared/CriticalCellEditorDialog.razor.js"); + jsModule = await JSRuntime.InvokeAsync("import", "./Components/Shared/CriticalCellEditorDialog.razor.js"); await jsModule.InvokeVoidAsync("lockBackgroundScroll"); } - - if (shouldFocusQuickParseEditor && quickParseEditor is not null) - { - shouldFocusQuickParseEditor = false; - await quickParseEditor.FocusAsync(); - } } public async ValueTask DisposeAsync() @@ -265,51 +163,6 @@ } private static string BuildColumnDisplayText(CriticalCellEditorModel model) => - string.IsNullOrWhiteSpace(model.GroupLabel) - ? model.ColumnLabel - : $"{model.GroupLabel} / {model.ColumnLabel}"; + string.IsNullOrWhiteSpace(model.GroupLabel) ? model.ColumnLabel : $"{model.GroupLabel} / {model.ColumnLabel}"; - private static IReadOnlyList GetUsedLegendEntries( - CriticalCellEditorModel model, - IReadOnlyList? legendEntries) - { - if (legendEntries is null || legendEntries.Count == 0) - { - return []; - } - - var usedEffectCodes = model.Effects - .Select(effect => effect.EffectCode) - .Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode))) - .Where(effectCode => !string.IsNullOrWhiteSpace(effectCode)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - return legendEntries - .Where(entry => usedEffectCodes.Contains(entry.EffectCode)) - .ToList(); - } - - private async Task HandleReparseClickAsync(MouseEventArgs _) - { - if (quickParseEditor is not null) - { - await quickParseEditor.ReparseAsync(); - return; - } - - await OnReparse.InvokeAsync(); - } - - private string? GetVisibleErrorMessage() => - IsQuickParseMode && !string.IsNullOrWhiteSpace(QuickParseErrorMessage) - ? QuickParseErrorMessage - : ErrorMessage; - - protected override void OnParametersSet() - { - if (IsQuickParseMode && quickParseEditor is null) - { - shouldFocusQuickParseEditor = true; - } - } -} +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Components/_Imports.razor b/src/RolemasterDb.App/Components/_Imports.razor index db80264..58bb4ad 100644 --- a/src/RolemasterDb.App/Components/_Imports.razor +++ b/src/RolemasterDb.App/Components/_Imports.razor @@ -10,9 +10,11 @@ @using RolemasterDb.App.Features @using RolemasterDb.App @using RolemasterDb.App.Components +@using RolemasterDb.App.Components.Curation @using RolemasterDb.App.Components.Layout @using RolemasterDb.App.Components.Primitives @using RolemasterDb.App.Components.Shell @using RolemasterDb.App.Components.Shared @using RolemasterDb.App.Components.Tables @using RolemasterDb.App.Components.Tools +@using RolemasterDb.App.Frontend.Curation \ No newline at end of file diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs index 5bfacc3..8b605fe 100644 --- a/src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs +++ b/src/RolemasterDb.App/Frontend/AppState/TableContextSnapshot.cs @@ -1,10 +1,3 @@ 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); +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, string? QueueScope = null); \ No newline at end of file diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs index 4da830a..95d135e 100644 --- a/src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs +++ b/src/RolemasterDb.App/Frontend/AppState/TableContextUrlSerializer.cs @@ -11,20 +11,14 @@ public sealed class TableContextUrlSerializer private const string RollJumpKey = "roll"; private const string ResultIdKey = "result"; private const string ModeKey = "mode"; + private const string QueueScopeKey = "scope"; 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))); + 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)), ReadValue(query, QueueScopeKey)); } public string BuildRelativeUri(string basePath, TableContextSnapshot context) @@ -37,10 +31,9 @@ public sealed class TableContextUrlSerializer AddIfPresent(parameters, RollJumpKey, context.RollJump?.ToString()); AddIfPresent(parameters, ResultIdKey, context.ResultId?.ToString()); AddIfPresent(parameters, ModeKey, WriteMode(context.Mode)); + AddIfPresent(parameters, QueueScopeKey, context.QueueScope); - return parameters.Count == 0 - ? basePath - : QueryHelpers.AddQueryString(basePath, parameters); + return parameters.Count == 0 ? basePath : QueryHelpers.AddQueryString(basePath, parameters); } private static void AddIfPresent(IDictionary parameters, string key, string? value) @@ -52,9 +45,7 @@ public sealed class TableContextUrlSerializer } private static string? ReadValue(IReadOnlyDictionary query, string key) => - query.TryGetValue(key, out var value) - ? value.ToString() - : null; + query.TryGetValue(key, out var value) ? value.ToString() : null; private static int? ReadInt(IReadOnlyDictionary query, string key) { @@ -65,18 +56,18 @@ public sealed class TableContextUrlSerializer private static TableContextMode? ReadMode(string? value) => value?.Trim().ToLowerInvariant() switch { - "reference" => TableContextMode.Reference, - "curation" => TableContextMode.Curation, + "reference" => TableContextMode.Reference, + "curation" => TableContextMode.Curation, "diagnostics" => TableContextMode.Diagnostics, - _ => null + _ => null }; private static string? WriteMode(TableContextMode? mode) => mode switch { - TableContextMode.Reference => "reference", - TableContextMode.Curation => "curation", + TableContextMode.Reference => "reference", + TableContextMode.Curation => "curation", TableContextMode.Diagnostics => "diagnostics", - _ => null + _ => null }; -} +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Frontend/Curation/CurationQueueItem.cs b/src/RolemasterDb.App/Frontend/Curation/CurationQueueItem.cs new file mode 100644 index 0000000..f68bc61 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/Curation/CurationQueueItem.cs @@ -0,0 +1,3 @@ +namespace RolemasterDb.App.Frontend.Curation; + +public sealed record CurationQueueItem(string TableSlug, string TableName, int ResultId, string RollBand, string ColumnKey, string ColumnLabel, string? GroupKey, string? GroupLabel); \ No newline at end of file diff --git a/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs b/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs new file mode 100644 index 0000000..1767b32 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs @@ -0,0 +1,95 @@ +using RolemasterDb.App.Features; +using RolemasterDb.App.Frontend.AppState; + +namespace RolemasterDb.App.Frontend.Curation; + +public static class CurationQueueResolver +{ + public static IReadOnlyList GetOrderedCells(CriticalTableDetail detail) + { + ArgumentNullException.ThrowIfNull(detail); + + var rollOrder = detail.RollBands.Select((rollBand, index) => new + { + rollBand.Label, + index + }).ToDictionary(item => item.Label, item => item.index, StringComparer.Ordinal); + var columnOrder = detail.Columns.Select((column, index) => new + { + column.Key, + index + }).ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); + var groupOrder = detail.Groups.Select((group, index) => new + { + group.Key, + index + }).ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); + + return detail.Cells.OrderBy(cell => rollOrder.GetValueOrDefault(cell.RollBand, int.MaxValue)).ThenBy(cell => + { + if (string.IsNullOrWhiteSpace(cell.GroupKey)) + { + return -1; + } + + return groupOrder.GetValueOrDefault(cell.GroupKey, int.MaxValue); + }).ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)).ToList(); + } + + public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context) + { + ArgumentNullException.ThrowIfNull(detail); + + if (context is null) + { + return null; + } + + if (context.ResultId is { } resultId) + { + var matchedByResultId = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId); + if (matchedByResultId is not null) + { + return matchedByResultId; + } + } + + if (string.IsNullOrWhiteSpace(context.RollBand) && string.IsNullOrWhiteSpace(context.ColumnKey) && string.IsNullOrWhiteSpace(context.GroupKey)) + { + return null; + } + + return detail.Cells.FirstOrDefault(cell => string.Equals(cell.RollBand, context.RollBand, StringComparison.Ordinal) && string.Equals(cell.ColumnKey, context.ColumnKey, StringComparison.Ordinal) && string.Equals(cell.GroupKey ?? string.Empty, context.GroupKey ?? string.Empty, StringComparison.Ordinal)); + } + + public static CriticalTableCellDetail? FindFirstUncurated(CriticalTableDetail detail) => + GetOrderedCells(detail).FirstOrDefault(cell => !cell.IsCurated); + + public static CriticalTableCellDetail? FindNextUncurated(CriticalTableDetail detail, int currentResultId) + { + var orderedCells = GetOrderedCells(detail); + var currentIndex = orderedCells.Select((cell, index) => new + { + cell.ResultId, + index + }).FirstOrDefault(item => item.ResultId == currentResultId)?.index ?? -1; + + for (var index = currentIndex + 1; index < orderedCells.Count; index++) + { + if (!orderedCells[index].IsCurated) + { + return orderedCells[index]; + } + } + + return null; + } + + public static CurationQueueItem CreateQueueItem(CriticalTableDetail detail, CriticalTableCellDetail cell) + { + ArgumentNullException.ThrowIfNull(detail); + ArgumentNullException.ThrowIfNull(cell); + + return new CurationQueueItem(detail.Slug, detail.DisplayName, cell.ResultId, cell.RollBand, cell.ColumnKey, cell.ColumnLabel, cell.GroupKey, cell.GroupLabel); + } +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Frontend/Curation/CurationQueueScopes.cs b/src/RolemasterDb.App/Frontend/Curation/CurationQueueScopes.cs new file mode 100644 index 0000000..1ab2302 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/Curation/CurationQueueScopes.cs @@ -0,0 +1,16 @@ +namespace RolemasterDb.App.Frontend.Curation; + +public static class CurationQueueScopes +{ + public const string AllTables = "all"; + public const string SelectedTable = "table"; + public const string PinnedSet = "pinned"; + + public static string Normalize(string? value) => + value?.Trim().ToLowerInvariant() switch + { + SelectedTable => SelectedTable, + PinnedSet => PinnedSet, + _ => AllTables + }; +} \ No newline at end of file diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index 0e843d5..81ce902 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -2306,6 +2306,66 @@ select.input-shell { gap: 1rem; } +.curation-page { + display: grid; + gap: 1rem; +} + +.curation-queue-bar, +.curation-workspace-frame, +.curation-empty-state { + gap: 1rem; +} + +.curation-queue-bar-header, +.curation-queue-links, +.curation-queue-summary, +.curation-workspace-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; +} + +.curation-queue-bar-header { + justify-content: space-between; +} + +.curation-queue-heading { + display: grid; + gap: 0.25rem; +} + +.curation-scope-tabs { + width: fit-content; + max-width: 100%; +} + +.curation-table-select { + max-width: min(26rem, 100%); +} + +.curation-queue-summary { + color: var(--text-secondary); +} + +.curation-queue-summary strong { + color: var(--text-primary); +} + +.curation-workspace-shell { + display: grid; + gap: 1rem; +} + +.curation-workspace-actions { + justify-content: flex-end; +} + +.curation-empty-state { + justify-items: start; +} + .diagnostics-page-header { display: flex; align-items: start; @@ -2543,7 +2603,11 @@ select.input-shell { } .diagnostics-page-header, - .diagnostics-selection-summary { + .diagnostics-selection-summary, + .curation-queue-bar-header, + .curation-queue-links, + .curation-queue-summary, + .curation-workspace-actions { flex-direction: column; align-items: stretch; } diff --git a/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs b/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs new file mode 100644 index 0000000..46ebddb --- /dev/null +++ b/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs @@ -0,0 +1,64 @@ +using RolemasterDb.App.Features; +using RolemasterDb.App.Frontend.AppState; +using RolemasterDb.App.Frontend.Curation; + +namespace RolemasterDb.ImportTool.Tests; + +public sealed class CurationQueueResolverTests +{ + [Fact] + public void Ordered_cells_follow_roll_group_and_column_sort_order() + { + var detail = CreateDetail(new CriticalTableCellDetail(4, "21-25", "C", "C", "severity", "B", "Beta", false, null, [], []), new CriticalTableCellDetail(1, "01-05", "B", "B", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(3, "21-25", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(2, "21-25", "B", "B", "severity", "A", "Alpha", false, null, [], [])); + + var orderedIds = CurationQueueResolver.GetOrderedCells(detail).Select(cell => cell.ResultId).ToArray(); + + Assert.Equal([1, 3, 2, 4], orderedIds); + } + + [Fact] + public void Find_first_uncurated_returns_first_matching_cell_in_order() + { + var detail = CreateDetail(new CriticalTableCellDetail(1, "01-05", "A", "A", "severity", null, null, true, null, [], []), new CriticalTableCellDetail(2, "01-05", "B", "B", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(3, "06-10", "A", "A", "severity", null, null, false, null, [], [])); + + var nextCell = CurationQueueResolver.FindFirstUncurated(detail); + + Assert.NotNull(nextCell); + Assert.Equal(2, nextCell!.ResultId); + } + + [Fact] + public void Find_next_uncurated_advances_without_wrapping() + { + var detail = CreateDetail(new CriticalTableCellDetail(1, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(2, "01-05", "B", "B", "severity", null, null, true, null, [], []), new CriticalTableCellDetail(3, "06-10", "A", "A", "severity", null, null, false, null, [], [])); + + Assert.Equal(3, CurationQueueResolver.FindNextUncurated(detail, 1)!.ResultId); + Assert.Null(CurationQueueResolver.FindNextUncurated(detail, 3)); + } + + [Fact] + public void Find_cell_prefers_result_id_and_falls_back_to_location_context() + { + var detail = CreateDetail(new CriticalTableCellDetail(11, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], [])); + + var byResult = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(ResultId: 12)); + var byLocation = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "A", ColumnKey: "B", RollBand: "06-10")); + + Assert.Equal(12, byResult!.ResultId); + Assert.Equal(12, byLocation!.ResultId); + } + + private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) => + new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [ + new CriticalColumnReference("A", "A", "severity", 1), + new CriticalColumnReference("B", "B", "severity", 2), + new CriticalColumnReference("C", "C", "severity", 3) + ], [ + new CriticalGroupReference("A", "Alpha", 1), + new CriticalGroupReference("B", "Beta", 2) + ], [ + new CriticalRollBandReference("01-05", 1, 5, 1), + new CriticalRollBandReference("06-10", 6, 10, 2), + new CriticalRollBandReference("21-25", 21, 25, 3) + ], cells, []); +} \ No newline at end of file