Add persisted location review pagination
This commit is contained in:
parent
7990cb244e
commit
c82430da04
@ -1059,9 +1059,18 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
|
|||||||
foreach (var viewName in heavyViews)
|
foreach (var viewName in heavyViews)
|
||||||
{
|
{
|
||||||
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
|
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
|
||||||
Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal) || view.Contains("const pageSize = 24;", StringComparison.Ordinal), $"{viewName} should cap visible candidates to a small page.");
|
if (string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||||
|
Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), $"{viewName} should use persisted bounded candidate pages.");
|
||||||
|
Assert(view.Contains("var locationCandidates = Model.LocationReview.Candidates.ToList();", StringComparison.Ordinal), $"{viewName} should render only the persisted candidate page.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal) || view.Contains("const pageSize = 24;", StringComparison.Ordinal), $"{viewName} should cap visible candidates to a small page.");
|
||||||
|
}
|
||||||
Assert(!view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should not expose an unbounded View All path.");
|
Assert(!view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should not expose an unbounded View All path.");
|
||||||
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions.");
|
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal) || view.Contains("PendingCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var assets = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml"));
|
var assets = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml"));
|
||||||
@ -2700,14 +2709,28 @@ static void LocationReviewUxSupportsLargeCandidateSets()
|
|||||||
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
|
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
|
||||||
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
|
||||||
|
var persisted = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
|
||||||
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
|
||||||
|
|
||||||
Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal), "Location review should render a bounded candidate batch.");
|
Assert(view.Contains("var locationCandidates = Model.LocationReview.Candidates.ToList();", StringComparison.Ordinal), "Location review should render only the persisted candidate page.");
|
||||||
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), "Location review should disclose remaining decisions outside the current batch.");
|
Assert(viewModels.Contains("PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize)", StringComparison.Ordinal), "52 pending candidates should page as 25 / 25 / 2.");
|
||||||
|
Assert(viewModels.Contains("ShowingStart", StringComparison.Ordinal) && viewModels.Contains("ShowingEnd", StringComparison.Ordinal), "Location review should show the visible pending range.");
|
||||||
Assert(view.Contains("story-review-page-status", StringComparison.Ordinal), "Location review should show bounded review counts.");
|
Assert(view.Contains("story-review-page-status", StringComparison.Ordinal), "Location review should show bounded review counts.");
|
||||||
Assert(!view.Contains("data-location-page-next", StringComparison.Ordinal), "Location review should not hide a full candidate DOM behind client-side paging.");
|
Assert(view.Contains("Page @Model.LocationReview.PageNumber", StringComparison.Ordinal), "Location review should show Page X of Y.");
|
||||||
|
Assert(view.Contains("Showing @Model.LocationReview.ShowingStart", StringComparison.Ordinal), "Location review should show N-M of T pending.");
|
||||||
|
Assert(view.Contains("name=\"intent\" value=\"previous\"", StringComparison.Ordinal)
|
||||||
|
&& view.Contains("name=\"intent\" value=\"next\"", StringComparison.Ordinal), "Previous and Next should submit the current page so decisions persist before navigation.");
|
||||||
|
Assert(view.Contains("name=\"PageNumber\"", StringComparison.Ordinal), "Location review should post the current page number.");
|
||||||
Assert(view.Contains("EvidenceSummaries", StringComparison.Ordinal), "Location review should expose compact aggregated evidence.");
|
Assert(view.Contains("EvidenceSummaries", StringComparison.Ordinal), "Location review should expose compact aggregated evidence.");
|
||||||
Assert(!view.Contains("data-location-bulk=\"create-all\"", StringComparison.Ordinal), "Location review should not offer bulk create-all.");
|
Assert(!view.Contains("data-location-bulk=\"create-all\"", StringComparison.Ordinal), "Location review should not offer bulk create-all.");
|
||||||
Assert(!view.Contains("data-location-bulk=\"ignore-all\"", StringComparison.Ordinal), "Location review should not offer bulk ignore-all.");
|
Assert(!view.Contains("data-location-bulk=\"ignore-all\"", StringComparison.Ordinal), "Location review should not offer bulk ignore-all.");
|
||||||
|
Assert(viewModels.Contains("public int PageNumber { get; set; } = 1;", StringComparison.Ordinal), "Location review posts should preserve the page being saved.");
|
||||||
|
Assert(persisted.Contains("pageNumber = Math.Clamp(pageNumber, 1, maxPage)", StringComparison.Ordinal), "Persisted candidate pages should clamp after pending totals change.");
|
||||||
|
Assert(controller.Contains("ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form, string intent = \"save\")", StringComparison.Ordinal), "Location review POST should accept a save/navigation intent.");
|
||||||
|
Assert(controller.Contains("pendingCount <= 0", StringComparison.Ordinal)
|
||||||
|
&& controller.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Location review should return to Review Centre only after module-wide pending reaches zero.");
|
||||||
|
Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage })", StringComparison.Ordinal), "Location review should stay in-module while pending candidates remain.");
|
||||||
|
Assert(controller.Contains("var targetPage = string.Equals(intent, \"previous\"", StringComparison.Ordinal), "Previous navigation should move to the prior persisted page after saving.");
|
||||||
Assert(viewModels.Contains("IReadOnlyList<string> EvidenceSummaries", StringComparison.Ordinal), "Location candidates should carry aggregated evidence summaries.");
|
Assert(viewModels.Contains("IReadOnlyList<string> EvidenceSummaries", StringComparison.Ordinal), "Location candidates should carry aggregated evidence summaries.");
|
||||||
Assert(service.Contains("candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID", StringComparison.Ordinal), "Location evidence should be deduplicated per scene/observation.");
|
Assert(service.Contains("candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID", StringComparison.Ordinal), "Location evidence should be deduplicated per scene/observation.");
|
||||||
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching.");
|
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching.");
|
||||||
|
|||||||
@ -546,7 +546,7 @@ public sealed class OnboardingController(
|
|||||||
|
|
||||||
[HttpPost("story-intelligence/locations")]
|
[HttpPost("story-intelligence/locations")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form)
|
public async Task<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form, string intent = "save")
|
||||||
{
|
{
|
||||||
var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form);
|
var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form);
|
||||||
if (progress is null)
|
if (progress is null)
|
||||||
@ -555,9 +555,26 @@ public sealed class OnboardingController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
||||||
return result.Success
|
if (!result.Success)
|
||||||
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
|
{
|
||||||
: RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID });
|
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber) });
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount = progress.LocationReview.SummaryNeedsAttentionCount ?? progress.LocationReview.PendingCandidateCount;
|
||||||
|
if (pendingCount <= 0)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID });
|
||||||
|
}
|
||||||
|
|
||||||
|
const int pageSize = 25;
|
||||||
|
var pageCount = Math.Max(1, (int)Math.Ceiling(pendingCount / (double)pageSize));
|
||||||
|
var currentPage = Math.Max(1, form.PageNumber);
|
||||||
|
var targetPage = string.Equals(intent, "previous", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? currentPage - 1
|
||||||
|
: currentPage;
|
||||||
|
targetPage = Math.Clamp(targetPage, 1, pageCount);
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("story-intelligence/assets")]
|
[HttpPost("story-intelligence/assets")]
|
||||||
|
|||||||
@ -553,6 +553,10 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
|||||||
return new CandidatePage<TCandidate>([], pageNumber, pageSize, 0, 0, false);
|
return new CandidatePage<TCandidate>([], pageNumber, pageSize, 0, 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var maxPage = generation.PendingCount <= 0
|
||||||
|
? 1
|
||||||
|
: (int)Math.Ceiling(generation.PendingCount / (double)pageSize);
|
||||||
|
pageNumber = Math.Clamp(pageNumber, 1, maxPage);
|
||||||
var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize);
|
var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize);
|
||||||
var items = rows.Select(row => JsonSerializer.Deserialize<TCandidate>(row.CandidatePayloadJson, JsonOptions))
|
var items = rows.Select(row => JsonSerializer.Deserialize<TCandidate>(row.CandidatePayloadJson, JsonOptions))
|
||||||
.Where(item => item is not null)
|
.Where(item => item is not null)
|
||||||
|
|||||||
@ -451,8 +451,11 @@ public sealed class StoryIntelligenceLocationReviewViewModel
|
|||||||
public int PageSize { get; init; } = 25;
|
public int PageSize { get; init; } = 25;
|
||||||
public int TotalCandidateCount { get; init; }
|
public int TotalCandidateCount { get; init; }
|
||||||
public int PendingCandidateCount { get; init; }
|
public int PendingCandidateCount { get; init; }
|
||||||
|
public int PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize);
|
||||||
|
public int ShowingStart => PendingCandidateCount == 0 || Candidates.Count == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1;
|
||||||
|
public int ShowingEnd => ShowingStart == 0 ? 0 : Math.Min(PendingCandidateCount, ShowingStart + Candidates.Count - 1);
|
||||||
public bool HasPreviousPage => PageNumber > 1;
|
public bool HasPreviousPage => PageNumber > 1;
|
||||||
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
|
public bool HasNextPage => PageNumber < PageCount;
|
||||||
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
|
||||||
public IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel> SameLocationOptions { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel> SameLocationOptions { get; init; } = [];
|
||||||
public IReadOnlyList<StoryIntelligenceLocationTargetOptionViewModel> LocationTargetOptions { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceLocationTargetOptionViewModel> LocationTargetOptions { get; init; } = [];
|
||||||
@ -516,6 +519,7 @@ public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
|||||||
public sealed class StoryIntelligenceLocationImportForm
|
public sealed class StoryIntelligenceLocationImportForm
|
||||||
{
|
{
|
||||||
public Guid BatchID { get; set; }
|
public Guid BatchID { get; set; }
|
||||||
|
public int PageNumber { get; set; } = 1;
|
||||||
public List<StoryIntelligenceLocationImportChoiceForm> Locations { get; set; } = [];
|
public List<StoryIntelligenceLocationImportChoiceForm> Locations { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
@model StoryIntelligenceProgressViewModel
|
@model StoryIntelligenceProgressViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Review locations";
|
ViewData["Title"] = "Review locations";
|
||||||
const int previewLimit = 25;
|
var locationCandidates = Model.LocationReview.Candidates.ToList();
|
||||||
var locationCandidates = Model.LocationReview.Candidates.Take(previewLimit).ToList();
|
|
||||||
var hiddenCandidateCount = Math.Max(0, Model.LocationReview.PendingCandidateCount - locationCandidates.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="story-location-title">
|
<section class="onboarding-shell" aria-labelledby="story-location-title">
|
||||||
@ -40,14 +38,15 @@
|
|||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Use exact matches</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Use exact matches</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
||||||
@if (hiddenCandidateCount > 0)
|
<span class="story-review-page-status">
|
||||||
{
|
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
|
||||||
<span class="story-review-page-status">Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending</span>
|
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
|
||||||
}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
|
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
|
||||||
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||||||
|
<input type="hidden" name="PageNumber" value="@Model.LocationReview.PageNumber" />
|
||||||
<div class="story-character-card-grid">
|
<div class="story-character-card-grid">
|
||||||
@for (var i = 0; i < locationCandidates.Count; i++)
|
@for (var i = 0; i < locationCandidates.Count; i++)
|
||||||
{
|
{
|
||||||
@ -197,20 +196,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
@if (hiddenCandidateCount > 0)
|
<span class="text-muted">
|
||||||
{
|
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
|
||||||
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
|
||||||
}
|
</span>
|
||||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
|
||||||
@if (Model.LocationReview.HasPreviousPage)
|
@if (Model.LocationReview.HasPreviousPage)
|
||||||
{
|
{
|
||||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber - 1)">Previous</a>
|
<button class="btn btn-outline-secondary" type="submit" name="intent" value="previous" data-busy-label="Saving page...">Previous</button>
|
||||||
}
|
}
|
||||||
@if (Model.LocationReview.HasNextPage)
|
@if (Model.LocationReview.HasNextPage)
|
||||||
{
|
{
|
||||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber + 1)">Next</a>
|
<button class="btn btn-outline-secondary" type="submit" name="intent" value="next" data-busy-label="Saving page...">Next</button>
|
||||||
}
|
}
|
||||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
|
<button class="btn btn-primary" type="submit" name="intent" value="save" data-busy-label="Saving locations...">Save location decisions</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user