Browse Source

Add select all controls to media lists (#2738)

* Add select all controls to media lists

* Refine select-all helper and add coverage

* Adjust select-all button alignment

* Tighten select-all helper semantics

* Allow tests to access internal members

* Rename select-all helper and avoid shift tracking

* Simplify select-all reset helper

* Keep pager centered and move select-all right

* Add missing div

* create test project for main app; move and rename new tests

* remove core => main app reference

* cleanup unused imports

* Fix button behavior when the screen is small

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
pull/2742/merge
Jon Crall 1 day ago committed by GitHub
parent
commit
daff1c6533
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 23
      ErsatzTV.Tests/ErsatzTV.Tests.csproj
  3. 35
      ErsatzTV.Tests/Pages/MultiSelectBaseTests.cs
  4. 9
      ErsatzTV.sln
  5. 2
      ErsatzTV/Pages/ArtistList.razor
  6. 2
      ErsatzTV/Pages/EpisodeList.razor
  7. 2
      ErsatzTV/Pages/ImageList.razor
  8. 2
      ErsatzTV/Pages/MovieList.razor
  9. 14
      ErsatzTV/Pages/MultiSelectBase.cs
  10. 2
      ErsatzTV/Pages/MusicVideoList.razor
  11. 2
      ErsatzTV/Pages/OtherVideoList.razor
  12. 2
      ErsatzTV/Pages/RemoteStreamList.razor
  13. 2
      ErsatzTV/Pages/SongList.razor
  14. 2
      ErsatzTV/Pages/TelevisionShowList.razor
  15. 35
      ErsatzTV/Pages/Trash.razor
  16. 3
      ErsatzTV/Properties/AssemblyInfo.cs
  17. 31
      ErsatzTV/Shared/MediaCardPager.razor

1
CHANGELOG.md

@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level) - `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level)
- This is currently limited to *Jellyfin* - This is currently limited to *Jellyfin*
- `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10 - `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10
- Add `Select All` button to media pages by @Erotemic
### Fixed ### Fixed
- Fix startup on systems unsupported by NvEncSharp - Fix startup on systems unsupported by NvEncSharp

23
ErsatzTV.Tests/ErsatzTV.Tests.csproj

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV\ErsatzTV.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
</ItemGroup>
</Project>

35
ErsatzTV.Tests/Pages/MultiSelectBaseTests.cs

@ -0,0 +1,35 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Pages;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Pages;
[TestFixture]
public class MultiSelectBaseTests
{
[Test]
public void Should_replace_existing_selection_and_return_last_card()
{
var existingCard = new MediaCardViewModel(1, "Existing", "Sub", "Existing", "", Core.Domain.MediaItemState.Normal, false);
var selected = new HashSet<MediaCardViewModel> { existingCard };
var first = new MediaCardViewModel(2, "First", "Sub", "First", "", Core.Domain.MediaItemState.Normal, false);
var second = new MediaCardViewModel(3, "Second", "Sub", "Second", "", Core.Domain.MediaItemState.Normal, false);
MultiSelectBase<Search>.ResetSelectionWithCards(selected, [first, second]);
selected.ShouldBe([first, second], ignoreOrder: true);
}
[Test]
public void Should_clear_selection_when_no_cards()
{
var existingCard = new MediaCardViewModel(1, "Existing", "Sub", "Existing", "", Core.Domain.MediaItemState.Normal, false);
var selected = new HashSet<MediaCardViewModel> { existingCard };
MultiSelectBase<Search>.ResetSelectionWithCards(selected, []);
selected.ShouldBeEmpty();
}
}

9
ErsatzTV.sln

@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Infrastructure.Sql
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Core.Nullable", "ErsatzTV.Core.Nullable\ErsatzTV.Core.Nullable.csproj", "{557D88A6-C982-4FFD-8FD2-6446CB07D093}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Core.Nullable", "ErsatzTV.Core.Nullable\ErsatzTV.Core.Nullable.csproj", "{557D88A6-C982-4FFD-8FD2-6446CB07D093}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.Tests\ErsatzTV.Tests.csproj", "{56F56E76-CEF4-4639-B7BB-03FD201BB019}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -113,11 +115,18 @@ Global
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|Any CPU.Build.0 = Release|Any CPU {557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|Any CPU.Build.0 = Release|Any CPU
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU {557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU {557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|Any CPU.Build.0 = Debug|Any CPU
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|Any CPU.ActiveCfg = Release|Any CPU
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|Any CPU.Build.0 = Release|Any CPU
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86} = {325E6DA0-52B3-4431-98A2-72C36F403704} {CE7F1ACD-F286-4761-A7BC-A541A1E25C86} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{1C892530-CF92-4F43-8C64-BCEEF958D726} = {325E6DA0-52B3-4431-98A2-72C36F403704} {1C892530-CF92-4F43-8C64-BCEEF958D726} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992} = {325E6DA0-52B3-4431-98A2-72C36F403704} {591FB3F4-4DD8-441B-B7C8-F2A42BF69992} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{2EF80455-953D-4696-831D-E8CBCA82B0EF} = {325E6DA0-52B3-4431-98A2-72C36F403704} {2EF80455-953D-4696-831D-E8CBCA82B0EF} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{56F56E76-CEF4-4639-B7BB-03FD201BB019} = {325E6DA0-52B3-4431-98A2-72C36F403704}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

2
ErsatzTV/Pages/ArtistList.razor

@ -18,6 +18,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

2
ErsatzTV/Pages/EpisodeList.razor

@ -18,6 +18,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

2
ErsatzTV/Pages/ImageList.razor

@ -18,6 +18,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

2
ErsatzTV/Pages/MovieList.razor

@ -20,6 +20,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

14
ErsatzTV/Pages/MultiSelectBase.cs

@ -38,6 +38,12 @@ public class MultiSelectBase<T> : FragmentNavigationBase
protected string SelectionLabel() => protected string SelectionLabel() =>
$"{SelectedItems.Count} {(SelectedItems.Count == 1 ? "Item" : "Items")} Selected"; $"{SelectedItems.Count} {(SelectedItems.Count == 1 ? "Item" : "Items")} Selected";
protected void SelectAllPageItems(IEnumerable<MediaCardViewModel> cards)
{
ResetSelectionWithCards(SelectedItems, cards);
StateHasChanged();
}
protected void ClearSelection() protected void ClearSelection()
{ {
SelectedItems.Clear(); SelectedItems.Clear();
@ -239,4 +245,12 @@ public class MultiSelectBase<T> : FragmentNavigationBase
}); });
} }
} }
internal static void ResetSelectionWithCards(
ISet<MediaCardViewModel> selectedItems,
IEnumerable<MediaCardViewModel> cards)
{
selectedItems.Clear();
selectedItems.UnionWith(cards);
}
} }

2
ErsatzTV/Pages/MusicVideoList.razor

@ -18,6 +18,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

2
ErsatzTV/Pages/OtherVideoList.razor

@ -18,6 +18,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

2
ErsatzTV/Pages/RemoteStreamList.razor

@ -18,6 +18,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

2
ErsatzTV/Pages/SongList.razor

@ -18,6 +18,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

2
ErsatzTV/Pages/TelevisionShowList.razor

@ -20,6 +20,8 @@
AddSelectionToCollection="@AddSelectionToCollection" AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist" AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection" ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode" IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/> SelectionLabel="@SelectionLabel"/>
</MudPaper> </MudPaper>

35
ErsatzTV/Pages/Trash.razor

@ -92,26 +92,38 @@
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")" Style="margin-bottom: auto; margin-top: auto">@_remoteStreams.Count Remote Streams</MudLink> <MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")" Style="margin-bottom: auto; margin-top: auto">@_remoteStreams.Count Remote Streams</MudLink>
} }
<div class="flex-grow-1 d-none d-md-flex"></div> <div class="flex-grow-1 d-none d-md-flex"></div>
<div>
<MudButton Variant="@Variant.Filled" <MudButton Variant="@Variant.Filled"
Color="@Color.Primary"
StartIcon="@Icons.Material.Filled.SelectAll"
Disabled="@(!IsNotEmpty)"
OnClick="@(_ => SelectAllPageItems(AllCardsOnPage()))">
Select All
</MudButton>
<MudButton Class="ml-3"
Variant="@Variant.Filled"
Color="@Color.Error" Color="@Color.Error"
StartIcon="@Icons.Material.Filled.DeleteForever" StartIcon="@Icons.Material.Filled.DeleteForever"
OnClick="@(_ => EmptyTrash())"> OnClick="@(_ => EmptyTrash())">
Empty Trash Empty Trash
</MudButton> </MudButton>
</div> </div>
</div>
<div style="align-items: center; display: flex; width: 100%" class="d-md-none"> <div style="align-items: center; display: flex; width: 100%" class="d-md-none">
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<div>
<MudButton Variant="@Variant.Filled" <MudButton Variant="@Variant.Filled"
Color="@Color.Primary"
StartIcon="@Icons.Material.Filled.SelectAll"
Disabled="@(!IsNotEmpty)"
OnClick="@(_ => SelectAllPageItems(AllCardsOnPage()))">
Select All
</MudButton>
<MudButton Class="ml-2"
Variant="@Variant.Filled"
Color="@Color.Error" Color="@Color.Error"
StartIcon="@Icons.Material.Filled.DeleteForever" StartIcon="@Icons.Material.Filled.DeleteForever"
OnClick="@(_ => EmptyTrash())"> OnClick="@(_ => EmptyTrash())">
Empty Trash Empty Trash
</MudButton> </MudButton>
</div> </div>
</div>
} }
else else
{ {
@ -509,7 +521,20 @@
} }
private bool IsNotEmpty => private bool IsNotEmpty =>
_movies?.Cards.Count > 0 || _shows?.Cards.Count > 0 || _seasons?.Cards.Count > 0 || _episodes?.Cards.Count > 0 || _musicVideos?.Cards.Count > 0 || _otherVideos?.Cards.Count > 0 || _songs?.Cards.Count > 0 || _artists?.Cards.Count > 0 || _images?.Cards.Count > 0; _movies?.Cards.Count > 0 || _shows?.Cards.Count > 0 || _seasons?.Cards.Count > 0 || _episodes?.Cards.Count > 0 || _musicVideos?.Cards.Count > 0 || _otherVideos?.Cards.Count > 0 || _songs?.Cards.Count > 0 || _artists?.Cards.Count > 0 || _images?.Cards.Count > 0 || _remoteStreams?.Cards.Count > 0;
private IEnumerable<MediaCardViewModel> AllCardsOnPage() =>
Enumerable.Empty<MediaCardViewModel>()
.Concat(_movies.Cards)
.Concat(_shows.Cards)
.Concat(_seasons.Cards)
.Concat(_episodes.Cards)
.Concat(_artists.Cards)
.Concat(_musicVideos.Cards)
.Concat(_otherVideos.Cards)
.Concat(_songs.Cards)
.Concat(_images.Cards)
.Concat(_remoteStreams.Cards);
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e) private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{ {

3
ErsatzTV/Properties/AssemblyInfo.cs

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ErsatzTV.Tests")]

31
ErsatzTV/Shared/MediaCardPager.razor

@ -44,7 +44,7 @@
<div style="flex: 1"> <div style="flex: 1">
<MudText Class="d-none d-md-flex">@Query</MudText> <MudText Class="d-none d-md-flex">@Query</MudText>
</div> </div>
<div> <div style="display: flex; justify-content: center;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;"> <MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft" <MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage" OnClick="@PrevPage"
@ -59,7 +59,28 @@
</MudIconButton> </MudIconButton>
</MudPaper> </MudPaper>
</div> </div>
<div style="flex: 1"></div> <div style="flex: 1; display: flex; justify-content: flex-end;">
@if (SelectAllOnPage is not null)
{
<div class="d-none d-md-flex" style="margin-left:auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.SelectAll"
Disabled="@(CanSelectAllOnPage is null || !CanSelectAllOnPage())"
OnClick="@(_ => SelectAllOnPage?.Invoke())">
Select All
</MudButton>
</div>
<div class="d-md-none" style="margin-left:auto; display:flex; align-items:center;">
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.SelectAll"
Label="Select All"
Disabled="@(CanSelectAllOnPage is null || !CanSelectAllOnPage())"
OnClick="@(_ => SelectAllOnPage?.Invoke())" />
</MudMenu>
</div>
}
</div>
</div> </div>
} }
</div> </div>
@ -99,6 +120,12 @@
[Parameter] [Parameter]
public Action ClearSelection { get; set; } public Action ClearSelection { get; set; }
[Parameter]
public Func<bool> CanSelectAllOnPage { get; set; }
[Parameter]
public Action SelectAllOnPage { get; set; }
private static MarkupString PaddedString(int value, int maximum) private static MarkupString PaddedString(int value, int maximum)
{ {
int length = maximum.ToString(CultureInfo.InvariantCulture).Length; int length = maximum.ToString(CultureInfo.InvariantCulture).Length;

Loading…
Cancel
Save