Merge p9-w8-8b-all-tab-grid into dev (8.B: embeddable ALL-tab all-releases grid)

This commit is contained in:
daniel-c-harvey
2026-06-13 21:30:26 -04:00
4 changed files with 77 additions and 30 deletions
@@ -125,14 +125,15 @@ else
private List<AlbumRow> _rows = new();
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet
// resurrecting a row we removed locally on delete: VM.Albums is cached for the circuit and is
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album
// back. We only re-project when the parent hands us a genuinely new list.
// resurrecting a row we removed locally on delete: while the parent holds the same Releases
// instance (e.g. a mid-operation re-render under IsDeleting, before any refresh hands us a new
// list), a blind rebuild every render would bring the deleted row back. We only re-project when
// the parent hands us a genuinely new list.
private IReadOnlyList<ReleaseDto>? _projectedReleases;
// Re-project rows only when the parent supplies a genuinely new release list (reference change).
// Local edits to _rows (a removed row after delete) must survive re-renders triggered by the
// same cached VM.Albums instance.
// same Releases instance.
protected override void OnParametersSet()
{
if (!ReferenceEquals(_projectedReleases, Releases))
@@ -245,7 +246,7 @@ else
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
// parent so the cached VM.Albums stays in sync with what is shown.
// parent so its release list stays in sync with what is shown.
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
{
var confirmed = await DialogService.ShowMessageBox(
@@ -0,0 +1,55 @@
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar
@* The ALL-tab content (Phase 9 §8.B): the cross-medium all-releases grid (CUTS, SESSIONS, MIXES
together) with per-row edit, delete, expand-tracks, and the 8.D Type chip. Self-contained — owns its
own data load so a host (TrackList today, the 8.A tab strip later) renders it with no parameters and
no VM plumbing. Re-loads on first render and re-fetches after a row mutation so the list stays in
sync with the catalogue. *@
<CmsAlbumBrowser Releases="_releases"
IsLoading="_loading"
OnReleasesChanged="OnGridReleasesChanged" />
@code {
// Fires after a row mutation (delete) so a host can invalidate sibling caches derived from the same
// catalogue — e.g. TrackList's genre cache. The grid refreshes its own list regardless; this is a
// notification, not the data source. Optional: an embed that has no sibling state leaves it unset.
[Parameter] public EventCallback OnReleasesChanged { get; set; }
private IReadOnlyList<ReleaseDto> _releases = Array.Empty<ReleaseDto>();
private bool _loading = true;
protected override Task OnInitializedAsync() => ReloadAsync();
private async Task OnGridReleasesChanged()
{
await ReloadAsync();
await OnReleasesChanged.InvokeAsync();
}
// Single load path: the initial fetch and the post-mutation refresh both run through here. After a
// delete CmsAlbumBrowser has already dropped the row from its own projection, so this re-fetch
// reconciles the authoritative list (track counts, orphaned-release cleanup) without a stale cache.
private async Task ReloadAsync()
{
_loading = true;
StateHasChanged();
var result = await CmsTrackService.GetReleasesAsync();
if (result.Success && result.Value is not null)
{
_releases = result.Value;
}
else
{
_releases = Array.Empty<ReleaseDto>();
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load releases: {error}", Severity.Error);
}
_loading = false;
StateHasChanged();
}
}
@@ -54,9 +54,11 @@
}
else if (VM.Mode == BrowseMode.Albums)
{
<CmsAlbumBrowser Releases="VM.Albums"
IsLoading="VM.AlbumsLoading"
OnReleasesChanged="OnAlbumsChanged" />
@* The all-releases grid is now a self-contained component (Phase 9 §8.B): it owns its own load
and refresh, so the host renders it with no parameters. The 8.A tab strip hosts this same
component as its ALL tab. Genre mode still uses the VM cache below; only album loading moved
into the component, so VM.Albums / VM.AlbumsLoading are no longer read here. *@
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
}
else if (VM.Mode == BrowseMode.Archive)
{
@@ -76,8 +78,8 @@
@code {
private CmsTrackGrid? _grid;
// The album browser owns its own row state and removes a deleted release locally. Invalidate the
// VM cache so genres and album counts reflect the deletion on next mode switch.
// The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
// the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
private void OnAlbumsChanged()
{
VM.Invalidate();
@@ -28,34 +28,23 @@ public class CmsTrackBrowserViewModel
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
// Album mode.
public IReadOnlyList<ReleaseDto> Albums { get; private set; } = Array.Empty<ReleaseDto>();
public bool AlbumsLoading { get; private set; }
// Genre mode.
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
public bool GenresLoading { get; private set; }
public string? ExpandedGenre { get; private set; }
/// <summary>
/// Switch the active mode, lazily loading the album or genre dataset on first entry. Collapses
/// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there.
/// Switch the active mode, lazily loading the genre dataset on first entry into Genre mode and
/// collapsing any expanded genre row. Track mode and the all-releases grid (Albums mode) each own
/// their own data — the grid loads itself (see <c>CmsAllReleasesGrid</c>) — so no fetch happens for
/// either here.
/// </summary>
public async Task SwitchModeAsync(BrowseMode mode)
{
Mode = mode;
ExpandedGenre = null; // collapse on mode switch
if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading)
{
AlbumsLoading = true;
var result = await _trackService.GetReleasesAsync();
Albums = result.Success && result.Value is not null
? result.Value
: Array.Empty<ReleaseDto>();
AlbumsLoading = false;
}
else if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
{
GenresLoading = true;
var result = await _trackService.GetGenreSummariesAsync();
@@ -73,13 +62,13 @@ public class CmsTrackBrowserViewModel
}
/// <summary>
/// Drop the cached album and genre datasets so the next <see cref="SwitchModeAsync"/> into
/// either mode re-fetches from the API. Call after a track or release mutation (edit, delete)
/// since both datasets are derived from the catalogue and go stale on any such change.
/// Drop the cached genre dataset so the next <see cref="SwitchModeAsync"/> into Genre mode
/// re-fetches from the API. Call after a track or release mutation (edit, delete) since the genre
/// summaries are derived from the catalogue and go stale on any such change. The all-releases grid
/// owns and refreshes its own data, so it needs no invalidation here.
/// </summary>
public void Invalidate()
{
Albums = Array.Empty<ReleaseDto>();
Genres = Array.Empty<GenreSummaryDto>();
}
}