feat(cms): extract all-releases grid as embeddable ALL-tab component (9.8.B)

CmsAllReleasesGrid self-loads the cross-medium release list so 8.A can host it as the ALL tab with no VM plumbing; TrackList's Albums mode renders it now. Preserves sort/delete/expand/edit and the 8.D Type chip.
This commit is contained in:
daniel-c-harvey
2026-06-13 21:26:43 -04:00
parent 2991d9ec5d
commit e78a61c3b1
4 changed files with 77 additions and 30 deletions
@@ -125,14 +125,15 @@ else
private List<AlbumRow> _rows = new(); private List<AlbumRow> _rows = new();
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet // 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 // resurrecting a row we removed locally on delete: while the parent holds the same Releases
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album // instance (e.g. a mid-operation re-render under IsDeleting, before any refresh hands us a new
// back. We only re-project when the parent hands us a genuinely new list. // 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; private IReadOnlyList<ReleaseDto>? _projectedReleases;
// Re-project rows only when the parent supplies a genuinely new release list (reference change). // 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 // 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() protected override void OnParametersSet()
{ {
if (!ReferenceEquals(_projectedReleases, Releases)) 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 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 // 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) private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
{ {
var confirmed = await DialogService.ShowMessageBox( 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) else if (VM.Mode == BrowseMode.Albums)
{ {
<CmsAlbumBrowser Releases="VM.Albums" @* The all-releases grid is now a self-contained component (Phase 9 §8.B): it owns its own load
IsLoading="VM.AlbumsLoading" and refresh, so the host renders it with no parameters. The 8.A tab strip hosts this same
OnReleasesChanged="OnAlbumsChanged" /> 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) else if (VM.Mode == BrowseMode.Archive)
{ {
@@ -76,8 +78,8 @@
@code { @code {
private CmsTrackGrid? _grid; private CmsTrackGrid? _grid;
// The album browser owns its own row state and removes a deleted release locally. Invalidate the // The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
// VM cache so genres and album counts reflect the deletion on next mode switch. // the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
private void OnAlbumsChanged() private void OnAlbumsChanged()
{ {
VM.Invalidate(); VM.Invalidate();
@@ -28,34 +28,23 @@ public class CmsTrackBrowserViewModel
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks; 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. // Genre mode.
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>(); public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
public bool GenresLoading { get; private set; } public bool GenresLoading { get; private set; }
public string? ExpandedGenre { get; private set; } public string? ExpandedGenre { get; private set; }
/// <summary> /// <summary>
/// Switch the active mode, lazily loading the album or genre dataset on first entry. Collapses /// Switch the active mode, lazily loading the genre dataset on first entry into Genre mode and
/// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there. /// 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> /// </summary>
public async Task SwitchModeAsync(BrowseMode mode) public async Task SwitchModeAsync(BrowseMode mode)
{ {
Mode = mode; Mode = mode;
ExpandedGenre = null; // collapse on mode switch ExpandedGenre = null; // collapse on mode switch
if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading) if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
{
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)
{ {
GenresLoading = true; GenresLoading = true;
var result = await _trackService.GetGenreSummariesAsync(); var result = await _trackService.GetGenreSummariesAsync();
@@ -73,13 +62,13 @@ public class CmsTrackBrowserViewModel
} }
/// <summary> /// <summary>
/// Drop the cached album and genre datasets so the next <see cref="SwitchModeAsync"/> into /// Drop the cached genre dataset so the next <see cref="SwitchModeAsync"/> into Genre mode
/// either mode re-fetches from the API. Call after a track or release mutation (edit, delete) /// re-fetches from the API. Call after a track or release mutation (edit, delete) since the genre
/// since both datasets are derived from the catalogue and go stale on any such change. /// 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> /// </summary>
public void Invalidate() public void Invalidate()
{ {
Albums = Array.Empty<ReleaseDto>();
Genres = Array.Empty<GenreSummaryDto>(); Genres = Array.Empty<GenreSummaryDto>();
} }
} }