Implement queue-first curation workflow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
@using RolemasterDb.App.Frontend.Curation
|
||||
|
||||
<div class="critical-editor-card curation-queue-bar">
|
||||
<div class="curation-queue-bar-header">
|
||||
<div class="curation-queue-heading">
|
||||
<h1 class="panel-title">Curation</h1>
|
||||
<span class="muted">Queue-first repair workspace</span>
|
||||
</div>
|
||||
|
||||
<div class="curation-queue-links">
|
||||
@if (!string.IsNullOrWhiteSpace(TablesHref))
|
||||
{
|
||||
<a class="btn btn-secondary" href="@TablesHref">Open tables</a>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(DiagnosticsHref))
|
||||
{
|
||||
<a class="btn btn-secondary" href="@DiagnosticsHref">Open diagnostics</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SegmentedTabs
|
||||
Items="ScopeItems"
|
||||
SelectedValue="SelectedScope"
|
||||
SelectedValueChanged="SelectedScopeChanged"
|
||||
AriaLabel="Curation queue scope"
|
||||
CssClass="curation-scope-tabs"/>
|
||||
|
||||
@if (string.Equals(SelectedScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
|
||||
{
|
||||
<div class="field-shell curation-table-select">
|
||||
<label for="curation-table-select">Table scope</label>
|
||||
<select
|
||||
id="curation-table-select"
|
||||
class="input-shell"
|
||||
value="@SelectedTableSlug"
|
||||
@onchange="HandleTableChanged"
|
||||
disabled="@IsBusy">
|
||||
@foreach (var table in Tables)
|
||||
{
|
||||
<option value="@table.Key">@table.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (CurrentItem is not null)
|
||||
{
|
||||
<div class="curation-queue-summary">
|
||||
<StatusChip Tone="warning">Needs curation</StatusChip>
|
||||
<strong>@CurrentItem.TableName</strong>
|
||||
<span>Roll band <strong>@CurrentItem.RollBand</strong></span>
|
||||
<span>Severity <strong>@CurrentItem.ColumnLabel</strong></span>
|
||||
@if (!string.IsNullOrWhiteSpace(CurrentItem.GroupLabel))
|
||||
{
|
||||
<span>Variant <strong>@CurrentItem.GroupLabel</strong></span>
|
||||
}
|
||||
<span>Result ID <strong>@CurrentItem.ResultId</strong></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SegmentedTabItem> ScopeItems { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string SelectedScope { get; set; } = CurationQueueScopes.AllTables;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback<string> SelectedScopeChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CriticalTableReference> Tables { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string SelectedTableSlug { get; set; } = string.Empty;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback<string> 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);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="critical-editor-card curation-empty-state">
|
||||
<div>
|
||||
<h2 class="panel-title">@Title</h2>
|
||||
<p class="muted">@Message</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ActionHref) && !string.IsNullOrWhiteSpace(ActionLabel))
|
||||
{
|
||||
<a class="btn btn-secondary" href="@ActionHref">@ActionLabel</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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; }
|
||||
|
||||
}
|
||||
248
src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor
Normal file
248
src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor
Normal file
@@ -0,0 +1,248 @@
|
||||
@using System.Collections.Generic
|
||||
@using System.Linq
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="curation-workspace-shell">
|
||||
<p class="muted" role="status">@LoadingMessage</p>
|
||||
</div>
|
||||
}
|
||||
else if (Model is null)
|
||||
{
|
||||
<div class="curation-workspace-shell">
|
||||
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
|
||||
{
|
||||
<p class="error-text critical-editor-error" role="alert">@GetVisibleErrorMessage()</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">@EmptyMessage</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="curation-workspace-shell">
|
||||
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
|
||||
{
|
||||
<p class="error-text critical-editor-error" role="alert">@GetVisibleErrorMessage()</p>
|
||||
}
|
||||
|
||||
<div class="critical-curation-grid">
|
||||
<div class="critical-editor-card critical-curation-preview-card @(IsQuickParseMode ? "is-quick-parse" : null)">
|
||||
@if (IsQuickParseMode)
|
||||
{
|
||||
<CriticalCellQuickParseEditor
|
||||
@ref="quickParseEditor"
|
||||
Model="Model"
|
||||
IsDisabled="@(IsSaving || IsReparsing)"
|
||||
TextAreaCssClass="input-shell critical-editor-textarea critical-curation-quick-parse-textarea"
|
||||
OnReparse="OnReparse"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button
|
||||
type="button"
|
||||
class="critical-curation-preview-button"
|
||||
@onclick="OnEnterQuickParse"
|
||||
disabled="@(IsSaving || IsReparsing)">
|
||||
<CompactCriticalCell
|
||||
Description="@Model.DescriptionText"
|
||||
Effects="@CriticalCellPresentation.BuildPreviewEffects(Model)"
|
||||
Branches="@CriticalCellPresentation.BuildPreviewBranches(Model)"/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="critical-editor-card critical-curation-source-card">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl))
|
||||
{
|
||||
<img
|
||||
class="critical-curation-source-image"
|
||||
src="@Model.SourceImageUrl"
|
||||
alt="@CriticalCellPresentation.BuildSourceImageAltText(Model)"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="critical-curation-source-empty">
|
||||
<p class="muted">No source image is available for this cell yet.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!IsQuickParseMode)
|
||||
{
|
||||
@if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries)
|
||||
{
|
||||
<div class="critical-curation-legend">
|
||||
@foreach (var entry in usedLegendEntries)
|
||||
{
|
||||
<div class="critical-curation-legend-item" title="@entry.Tooltip">
|
||||
<span class="legend-symbol">@entry.Symbol</span>
|
||||
<strong>@entry.Label</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="curation-workspace-actions">
|
||||
@if (IsQuickParseMode)
|
||||
{
|
||||
<button type="button" class="btn btn-link" @onclick="OnCancelQuickParse" disabled="@(IsSaving || IsReparsing)">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ritual"
|
||||
@onclick="HandleReparseClickAsync"
|
||||
@onclick:stopPropagation="true"
|
||||
@onclick:preventDefault="true"
|
||||
disabled="@(IsSaving || IsReparsing)">
|
||||
@(IsReparsing ? ReparseBusyLabel : ReparseActionLabel)
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (ShowSecondaryAction)
|
||||
{
|
||||
<button type="button" class="@SecondaryActionCssClass" @onclick="OnSecondaryAction" disabled="@(IsSaving || IsReparsing)">
|
||||
@SecondaryActionLabel
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (ShowPrimaryAction)
|
||||
{
|
||||
<button type="button" class="@PrimaryActionCssClass" @onclick="OnPrimaryAction" disabled="@(IsSaving || IsReparsing)">
|
||||
@(IsSaving ? PrimaryBusyLabel : PrimaryActionLabel)
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@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<CriticalTableLegendEntry>? 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<CriticalTableLegendEntry> GetUsedLegendEntries(CriticalCellEditorModel model, IReadOnlyList<CriticalTableLegendEntry>? 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;
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Curation</PageTitle>
|
||||
|
||||
<section class="panel">
|
||||
<h1 class="panel-title">Curation</h1>
|
||||
<p class="panel-copy">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.</p>
|
||||
<section class="panel curation-page">
|
||||
@if (referenceData is null)
|
||||
{
|
||||
<div class="critical-editor-card nested">
|
||||
<span class="muted" role="status">Loading curation queue...</span>
|
||||
</div>
|
||||
}
|
||||
else if (!hasInitializedContext)
|
||||
{
|
||||
<div class="critical-editor-card nested">
|
||||
<span class="muted" role="status">Restoring curation context...</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CurationQueueBar
|
||||
ScopeItems="BuildScopeItems()"
|
||||
SelectedScope="selectedQueueScope"
|
||||
SelectedScopeChanged="HandleQueueScopeChangedAsync"
|
||||
Tables="referenceData.CriticalTables"
|
||||
SelectedTableSlug="selectedTableSlug"
|
||||
SelectedTableSlugChanged="HandleSelectedTableChangedAsync"
|
||||
CurrentItem="currentQueueItem"
|
||||
IsBusy="IsBusy"
|
||||
TablesHref="@BuildTablesUri()"
|
||||
DiagnosticsHref="@BuildDiagnosticsUri()"/>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(pageError))
|
||||
{
|
||||
<p class="error-text critical-editor-error" role="alert">@pageError</p>
|
||||
}
|
||||
else if (currentQueueItem is null)
|
||||
{
|
||||
<CurationQueueEmptyState
|
||||
Title="@BuildEmptyStateTitle()"
|
||||
Message="@BuildEmptyStateMessage()"
|
||||
ActionHref="@BuildTablesUri()"
|
||||
ActionLabel="Open tables"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="critical-editor-card curation-workspace-frame">
|
||||
<CurationWorkspace
|
||||
Model="curationModel"
|
||||
IsLoading="isCurationLoading"
|
||||
IsSaving="isCurationSaving"
|
||||
IsReparsing="isCurationReparsing"
|
||||
IsQuickParseMode="isCurationQuickParseMode"
|
||||
ErrorMessage="@curationError"
|
||||
QuickParseErrorMessage="@curationQuickParseError"
|
||||
LegendEntries="@(currentTableDetail?.Legend ?? Array.Empty<CriticalTableLegendEntry>())"
|
||||
PrimaryActionLabel="Mark curated and continue"
|
||||
SecondaryActionLabel="Open full editor"
|
||||
OnPrimaryAction="MarkCellCuratedAsync"
|
||||
OnSecondaryAction="OpenCellEditorAsync"
|
||||
OnEnterQuickParse="EnterCurationQuickParseMode"
|
||||
OnCancelQuickParse="CancelCurationQuickParseMode"
|
||||
OnReparse="ReparseCurationCellAsync"/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</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 = "curation";
|
||||
|
||||
private readonly Dictionary<string, CriticalTableDetail?> 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<CriticalTableDetail?> 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<string> 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<string> OrderCandidatesForRestore(IReadOnlyList<string> candidates, string? preferredSlug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(preferredSlug))
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var ordered = new List<string>(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<SegmentedTabItem> 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."
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (isCurationOpen)
|
||||
{
|
||||
<CriticalCellCurationDialog
|
||||
@key="curationModel"
|
||||
Model="curationModel"
|
||||
IsLoading="isCurationLoading"
|
||||
IsSaving="isCurationSaving"
|
||||
IsReparsing="isCurationReparsing"
|
||||
IsQuickParseMode="isCurationQuickParseMode"
|
||||
ErrorMessage="@curationError"
|
||||
QuickParseErrorMessage="@curationQuickParseError"
|
||||
LegendEntries="@(tableDetail?.Legend ?? Array.Empty<CriticalTableLegendEntry>())"
|
||||
OnClose="CloseCellCurationAsync"
|
||||
OnMarkCurated="MarkCellCuratedAsync"
|
||||
OnEdit="OpenEditorFromCurationAsync"
|
||||
OnEnterQuickParse="EnterCurationQuickParseMode"
|
||||
OnCancelQuickParse="CancelCurationQuickParseMode"
|
||||
OnReparse="ReparseCurationCellAsync"/>
|
||||
}
|
||||
|
||||
@if (isEditorOpen)
|
||||
{
|
||||
<CriticalCellEditorDialog
|
||||
@@ -141,15 +122,6 @@
|
||||
private int? editingResultId;
|
||||
private CriticalCellEditorModel? editorModel;
|
||||
private CriticalCellEditorModel? editorComparisonBaselineModel;
|
||||
private bool isCurationOpen;
|
||||
private bool isCurationLoading;
|
||||
private bool isCurationSaving;
|
||||
private bool isCurationReparsing;
|
||||
private bool isCurationQuickParseMode;
|
||||
private string? curationError;
|
||||
private string? curationQuickParseError;
|
||||
private int? curatingResultId;
|
||||
private CriticalCellEditorModel? curationModel;
|
||||
private string referenceMode = TablesReferenceMode.Reference;
|
||||
private string selectedGroupKey = string.Empty;
|
||||
private string selectedColumnKey = string.Empty;
|
||||
@@ -282,228 +254,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenCellCurationAsync(int resultId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedTableSlug))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isCurationSaving = false;
|
||||
isCurationReparsing = false;
|
||||
isCurationQuickParseMode = false;
|
||||
isCurationOpen = true;
|
||||
await LoadCurationCellAsync(resultId, "The selected cell could not be loaded for curation.");
|
||||
}
|
||||
|
||||
private async Task CloseCellCurationAsync()
|
||||
{
|
||||
isCurationOpen = false;
|
||||
isCurationLoading = false;
|
||||
isCurationSaving = false;
|
||||
isCurationReparsing = false;
|
||||
isCurationQuickParseMode = false;
|
||||
curationError = null;
|
||||
curationQuickParseError = null;
|
||||
curatingResultId = null;
|
||||
curationModel = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task MarkCellCuratedAsync()
|
||||
{
|
||||
if (curationModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || curatingResultId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isCurationSaving = true;
|
||||
curationError = null;
|
||||
|
||||
try
|
||||
{
|
||||
curationModel.IsCurated = true;
|
||||
var response = await LookupService.UpdateCriticalCellAsync(selectedTableSlug, curatingResultId.Value, curationModel.ToRequest());
|
||||
if (response is null)
|
||||
{
|
||||
curationError = "The selected cell could not be marked curated.";
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadTableDetailAsync();
|
||||
|
||||
var nextResultId = FindNextUncuratedResultId(curatingResultId.Value);
|
||||
if (nextResultId is null)
|
||||
{
|
||||
await CloseCellCurationAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadCurationCellAsync(nextResultId.Value, "The next cell could not be loaded for curation.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
curationError = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCurationSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditorFromCurationAsync()
|
||||
{
|
||||
if (curatingResultId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resultId = curatingResultId.Value;
|
||||
await CloseCellCurationAsync();
|
||||
await OpenCellEditorAsync(resultId);
|
||||
}
|
||||
|
||||
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 LoadCurationCellAsync(int resultId, string loadFailureMessage)
|
||||
{
|
||||
curationError = null;
|
||||
curationQuickParseError = null;
|
||||
curationModel = null;
|
||||
curatingResultId = resultId;
|
||||
isCurationLoading = true;
|
||||
isCurationQuickParseMode = false;
|
||||
isCurationReparsing = false;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, resultId);
|
||||
if (response is null)
|
||||
{
|
||||
curationError = loadFailureMessage;
|
||||
curationModel = null;
|
||||
return;
|
||||
}
|
||||
|
||||
curationModel = CriticalCellEditorModel.FromResponse(response);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
curationError = exception.Message;
|
||||
curationModel = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCurationLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReparseCurationCellAsync()
|
||||
{
|
||||
if (curationModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || curatingResultId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isCurationReparsing = true;
|
||||
curationQuickParseError = null;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await ReparseCriticalCellAsync(curationModel, curatingResultId.Value);
|
||||
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 int? FindNextUncuratedResultId(int currentResultId)
|
||||
{
|
||||
if (tableDetail?.Cells is null || tableDetail.Cells.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var orderedCells = tableDetail.Cells.OrderBy(cell => 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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -32,120 +30,30 @@
|
||||
<button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button>
|
||||
</header>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="critical-editor-body">
|
||||
<p class="muted">Loading curation preview...</p>
|
||||
</div>
|
||||
}
|
||||
else if (Model is null)
|
||||
{
|
||||
<div class="critical-editor-body">
|
||||
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
|
||||
{
|
||||
<p class="error-text critical-editor-error">@GetVisibleErrorMessage()</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">No curation preview is available.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="critical-editor-body critical-curation-body @(IsQuickParseMode ? "is-quick-parse" : null)">
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<p class="error-text critical-editor-error">@ErrorMessage</p>
|
||||
}
|
||||
|
||||
<div class="critical-curation-grid">
|
||||
<div class="critical-editor-card critical-curation-preview-card @(IsQuickParseMode ? "is-quick-parse" : null)">
|
||||
@if (IsQuickParseMode)
|
||||
{
|
||||
<CriticalCellQuickParseEditor
|
||||
@ref="quickParseEditor"
|
||||
<CurationWorkspace
|
||||
Model="Model"
|
||||
IsDisabled="@(IsSaving || IsReparsing)"
|
||||
TextAreaCssClass="input-shell critical-editor-textarea critical-curation-quick-parse-textarea"
|
||||
IsLoading="IsLoading"
|
||||
IsSaving="IsSaving"
|
||||
IsReparsing="IsReparsing"
|
||||
IsQuickParseMode="IsQuickParseMode"
|
||||
ErrorMessage="@ErrorMessage"
|
||||
QuickParseErrorMessage="@QuickParseErrorMessage"
|
||||
LegendEntries="LegendEntries"
|
||||
PrimaryActionLabel="Mark as Curated"
|
||||
SecondaryActionLabel="Edit"
|
||||
SecondaryActionCssClass="btn btn-link"
|
||||
OnPrimaryAction="OnMarkCurated"
|
||||
OnSecondaryAction="OnEdit"
|
||||
OnEnterQuickParse="OnEnterQuickParse"
|
||||
OnCancelQuickParse="OnCancelQuickParse"
|
||||
OnReparse="OnReparse"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button
|
||||
type="button"
|
||||
class="critical-curation-preview-button"
|
||||
@onclick="OnEnterQuickParse"
|
||||
disabled="@(IsSaving || IsReparsing)">
|
||||
<CompactCriticalCell
|
||||
Description="@Model.DescriptionText"
|
||||
Effects="@CriticalCellPresentation.BuildPreviewEffects(Model)"
|
||||
Branches="@CriticalCellPresentation.BuildPreviewBranches(Model)" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="critical-editor-card critical-curation-source-card">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl))
|
||||
{
|
||||
<img
|
||||
class="critical-curation-source-image"
|
||||
src="@Model.SourceImageUrl"
|
||||
alt="@CriticalCellPresentation.BuildSourceImageAltText(Model)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="critical-curation-source-empty">
|
||||
<p class="muted">No source image is available for this cell yet.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!IsQuickParseMode)
|
||||
{
|
||||
@if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries)
|
||||
{
|
||||
<div class="critical-curation-legend">
|
||||
@foreach (var entry in usedLegendEntries)
|
||||
{
|
||||
<div class="critical-curation-legend-item" title="@entry.Tooltip">
|
||||
<span class="legend-symbol">@entry.Symbol</span>
|
||||
<strong>@entry.Label</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="critical-editor-footer">
|
||||
@if (IsQuickParseMode)
|
||||
{
|
||||
<button type="button" class="btn btn-link" @onclick="OnCancelQuickParse" disabled="@(IsSaving || IsReparsing)">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ritual"
|
||||
@onclick="HandleReparseClickAsync"
|
||||
@onclick:stopPropagation="true"
|
||||
@onclick:preventDefault="true"
|
||||
disabled="@(IsSaving || IsReparsing)">
|
||||
@(IsReparsing ? "Parsing..." : "Parse")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-link" @onclick="OnEdit" disabled="@(IsSaving || IsReparsing)">Edit</button>
|
||||
<button type="button" class="btn-ritual" @onclick="OnMarkCurated" disabled="@(IsSaving || IsReparsing)">
|
||||
@(IsSaving ? "Saving..." : "Mark as Curated")
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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<IJSObjectReference>(
|
||||
"import",
|
||||
"./Components/Shared/CriticalCellEditorDialog.razor.js");
|
||||
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("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<CriticalTableLegendEntry> GetUsedLegendEntries(
|
||||
CriticalCellEditorModel model,
|
||||
IReadOnlyList<CriticalTableLegendEntry>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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<string, string?> parameters, string key, string? value)
|
||||
@@ -52,9 +45,7 @@ public sealed class TableContextUrlSerializer
|
||||
}
|
||||
|
||||
private static string? ReadValue(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> 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<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
@@ -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<CriticalTableCellDetail> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
Reference in New Issue
Block a user