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>
This commit is contained in:
@@ -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)
|
||||
- This is currently limited to *Jellyfin*
|
||||
- `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
|
||||
- Fix startup on systems unsupported by NvEncSharp
|
||||
|
||||
23
ErsatzTV.Tests/ErsatzTV.Tests.csproj
Normal file
23
ErsatzTV.Tests/ErsatzTV.Tests.csproj
Normal file
@@ -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
Normal file
35
ErsatzTV.Tests/Pages/MultiSelectBaseTests.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Infrastructure.Sql
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Core.Nullable", "ErsatzTV.Core.Nullable\ErsatzTV.Core.Nullable.csproj", "{557D88A6-C982-4FFD-8FD2-6446CB07D093}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.Tests\ErsatzTV.Tests.csproj", "{56F56E76-CEF4-4639-B7BB-03FD201BB019}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Debug No Sync|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86} = {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}
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -38,6 +38,12 @@ public class MultiSelectBase<T> : FragmentNavigationBase
|
||||
protected string SelectionLabel() =>
|
||||
$"{SelectedItems.Count} {(SelectedItems.Count == 1 ? "Item" : "Items")} Selected";
|
||||
|
||||
protected void SelectAllPageItems(IEnumerable<MediaCardViewModel> cards)
|
||||
{
|
||||
ResetSelectionWithCards(SelectedItems, cards);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void ClearSelection()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
|
||||
@@ -92,25 +92,37 @@
|
||||
<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>
|
||||
<MudButton Variant="@Variant.Filled"
|
||||
Color="@Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.DeleteForever"
|
||||
OnClick="@(_ => EmptyTrash())">
|
||||
Empty Trash
|
||||
</MudButton>
|
||||
</div>
|
||||
<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"
|
||||
StartIcon="@Icons.Material.Filled.DeleteForever"
|
||||
OnClick="@(_ => EmptyTrash())">
|
||||
Empty Trash
|
||||
</MudButton>
|
||||
</div>
|
||||
<div style="align-items: center; display: flex; width: 100%" class="d-md-none">
|
||||
<div class="flex-grow-1"></div>
|
||||
<div>
|
||||
<MudButton Variant="@Variant.Filled"
|
||||
Color="@Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.DeleteForever"
|
||||
OnClick="@(_ => EmptyTrash())">
|
||||
Empty Trash
|
||||
</MudButton>
|
||||
</div>
|
||||
<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"
|
||||
StartIcon="@Icons.Material.Filled.DeleteForever"
|
||||
OnClick="@(_ => EmptyTrash())">
|
||||
Empty Trash
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -509,7 +521,20 @@
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
3
ErsatzTV/Properties/AssemblyInfo.cs
Normal file
3
ErsatzTV/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ErsatzTV.Tests")]
|
||||
@@ -44,7 +44,7 @@
|
||||
<div style="flex: 1">
|
||||
<MudText Class="d-none d-md-flex">@Query</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
@@ -59,7 +59,28 @@
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</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>
|
||||
@@ -99,6 +120,12 @@
|
||||
[Parameter]
|
||||
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)
|
||||
{
|
||||
int length = maximum.ToString(CultureInfo.InvariantCulture).Length;
|
||||
|
||||
Reference in New Issue
Block a user