feat(ui): Listen und Ordner in einer gemischten Sidebar-Reihenfolge

Die oberste Sidebar-Ebene ist jetzt eine gemischte TopLevel-Collection, so
dass eine Liste auch oberhalb eines Ordners stehen kann. DropAsync deckt
alle vier Quelle/Ziel-Kombinationen ab, ReorderSidebarAsync numeriert die
gesamte sichtbare Reihenfolge ueber lists und list_folders hinweg.
This commit is contained in:
mika kuns
2026-09-10 15:40:10 +02:00
parent 1770b67713
commit bfb1c6523c
9 changed files with 472 additions and 191 deletions
@@ -48,11 +48,14 @@ public class ListsIslandFolderTests : IDisposable
public override bool IsConnected => true;
}
private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists)
private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists) =>
await SeedAsync(folderId, 0, lists);
private async Task SeedAsync(string? folderId, int folderSort, params (string Id, string Name, int Sort)[] lists)
{
await using var db = NewContext();
if (folderId is not null)
db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", SortOrder = folderSort, CreatedAt = DateTime.UtcNow });
foreach (var (id, name, sort) in lists)
db.Lists.Add(new ListEntity { Id = id, Name = name, SortOrder = sort, CreatedAt = DateTime.UtcNow, FolderId = folderId });
await db.SaveChangesAsync();
@@ -164,9 +167,74 @@ public class ListsIslandFolderTests : IDisposable
await using var db = NewContext();
var stored = await db.Lists.AsNoTracking().ToListAsync();
Assert.Equal("f1", stored.Single(l => l.Id == "mover").FolderId);
// sort_order is written as one global sequence in sidebar order: folder contents first.
Assert.Equal(0, stored.Single(l => l.Id == "mover").SortOrder);
Assert.Equal(1, stored.Single(l => l.Id == "inside").SortOrder);
// sort_order is one sequence over the whole sidebar, folders included: f1 takes 0, its
// two children 1 and 2.
Assert.Equal(0, (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).SortOrder);
Assert.Equal(1, stored.Single(l => l.Id == "mover").SortOrder);
Assert.Equal(2, stored.Single(l => l.Id == "inside").SortOrder);
}
[Fact]
public async Task DropAsync_reorders_folders_among_each_other_and_persists_it()
{
await SeedAsync("f1", folderSort: 0);
await SeedAsync("f2", folderSort: 1);
var vm = NewVm();
await vm.LoadAsync();
var (first, second) = (vm.Folders[0], vm.Folders[1]);
await vm.DropAsync(second, first, placeBelow: false);
Assert.Equal([second, first], vm.Folders);
await using var db = NewContext();
var stored = await db.ListFolders.AsNoTracking().ToListAsync();
Assert.Equal(0, stored.Single(f => f.Id == second.Id).SortOrder);
Assert.Equal(1, stored.Single(f => f.Id == first.Id).SortOrder);
// And it survives a reload — the load path merges folders and lists by sort_order.
var reloaded = NewVm();
await reloaded.LoadAsync();
Assert.Equal([second.Id, first.Id], reloaded.Folders.Select(f => f.Id));
}
[Fact]
public async Task DropAsync_puts_a_list_above_a_folder_at_the_top_level()
{
await SeedAsync("f1", ("inside", "Inside", 1));
await SeedAsync(null, ("mover", "Mover", 2));
var vm = NewVm();
await vm.LoadAsync();
var folder = vm.Folders[0];
var mover = vm.RootLists.Single();
await vm.DropAsync(mover, folder, placeBelow: false);
Assert.Equal(new object[] { mover, folder }, vm.TopLevel);
Assert.Null(mover.FolderId);
var reloaded = NewVm();
await reloaded.LoadAsync();
Assert.Equal(["user:mover", "f1"], reloaded.TopLevel.Select(i =>
i is ListNavItemViewModel l ? l.Id : ((ListFolderNavItemViewModel)i).Id));
}
[Fact]
public async Task DropAsync_never_nests_a_folder_in_a_folder()
{
await SeedAsync("f1", 0, ("inside", "Inside", 1));
await SeedAsync("f2", folderSort: 2);
var vm = NewVm();
await vm.LoadAsync();
var inside = vm.Folders[0].Lists.Single();
var f2 = vm.Folders[1];
await vm.DropAsync(f2, inside, placeBelow: true);
Assert.Equal(2, vm.TopLevel.Count);
Assert.Single(vm.Folders[0].Lists);
}
[Fact]