feat(lists): allow nested sidebar folders via drag and drop

Folders can now contain other folders, to arbitrary depth, via a
self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as
lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a
folder onto another folder's header middle moves it in, the outer
quarters or any top-level/in-folder drop insert it beside a target.
Dropping a folder into itself or one of its own subfolders is silently
ignored. Deleting a folder releases its lists and subfolders back to
the top level, unchanged from the existing list behavior.

ListFolderNavItemViewModel.Lists became Children (object, mixing lists
and subfolders, mirroring TopLevel) so the existing implicit, type-matched
DataTemplates render nested folders recursively without new XAML, and each
nesting level gets its indent for free from the folder template's own
margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts
now walk the tree instead of assuming one level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-09-28 13:31:26 +02:00
co-authored by Claude Sonnet 5
parent 352aaa525b
commit 0dac047c46
16 changed files with 1610 additions and 88 deletions
@@ -88,12 +88,62 @@ public class ListsIslandFolderTests : IDisposable
await vm.LoadAsync();
var folder = Assert.Single(vm.Folders);
Assert.Equal("Inside", Assert.Single(folder.Lists).Name);
Assert.Equal("Inside", Assert.Single(folder.Children.OfType<ListNavItemViewModel>()).Name);
Assert.Equal("Outside", Assert.Single(vm.RootLists).Name);
// UserLists stays the flat superset — counts and row refreshes work over it.
Assert.Equal(2, vm.UserLists.Count);
}
[Fact]
public async Task LoadAsync_builds_an_arbitrarily_deep_folder_tree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "mid", Name = "Mid", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 2, ParentFolderId = "mid", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "deep", Name = "Deep", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = Assert.Single(vm.Folders, f => f.ParentFolderId is null);
Assert.Equal("top", top.Id);
var mid = Assert.Single(top.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("mid", mid.Id);
var leaf = Assert.Single(mid.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("leaf", leaf.Id);
Assert.Equal("Deep", Assert.Single(leaf.Children.OfType<ListNavItemViewModel>()).Name);
// The flat recursive projection sees all three levels.
Assert.Equal(3, vm.Folders.Count);
}
[Fact]
public async Task Folder_count_sums_recursively_through_nested_subfolders()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "a", Name = "A", SortOrder = 2, FolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "b", Name = "B", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
await SeedTaskAsync("a", 1);
await SeedTaskAsync("b", 2);
await SeedTaskAsync("b", 3);
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
// 1 task directly in "a" (under top) + 2 tasks in "b" (under the nested "leaf").
Assert.Equal(3, top.Count);
Assert.Equal(2, vm.Folders.Single(f => f.Id == "leaf").Count);
}
[Fact]
public async Task Folder_count_is_the_sum_of_its_lists()
{
@@ -121,7 +171,7 @@ public class ListsIslandFolderTests : IDisposable
await vm.MoveToFolderAsync(mover, vm.Folders[0]);
Assert.Empty(vm.RootLists);
Assert.Equal(2, vm.Folders[0].Lists.Count);
Assert.Equal(2, vm.Folders[0].Children.Count);
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
@@ -135,11 +185,11 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.MoveToFolderAsync(row, null);
Assert.Empty(vm.Folders[0].Lists);
Assert.Empty(vm.Folders[0].Children);
Assert.Same(row, Assert.Single(vm.RootLists));
Assert.Null(row.FolderId);
@@ -156,12 +206,12 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var mover = vm.RootLists.Single();
var inside = vm.Folders[0].Lists.Single();
var inside = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.ReorderAsync(mover, inside, placeBelow: false);
Assert.Empty(vm.RootLists);
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Children.OfType<ListNavItemViewModel>().Select(l => l.Name));
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
@@ -221,20 +271,93 @@ public class ListsIslandFolderTests : IDisposable
}
[Fact]
public async Task DropAsync_never_nests_a_folder_in_a_folder()
public async Task DropAsync_nests_a_folder_beside_a_list_inside_another_folder()
{
// Folders now nest anywhere (drag-and-drop only, arbitrary depth) — dropping f2 next to a
// list that lives inside f1 moves f2 into f1's container alongside it.
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];
var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders.Single(f => f.Id == "f2");
var inside = f1.Children.OfType<ListNavItemViewModel>().Single();
await vm.DropAsync(f2, inside, placeBelow: true);
Assert.Equal(2, vm.TopLevel.Count);
Assert.Single(vm.Folders[0].Lists);
Assert.Equal("f1", f2.ParentFolderId);
Assert.Equal(new object[] { inside, f2 }, f1.Children);
Assert.Single(vm.TopLevel);
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task DropAsync_ignores_dropping_a_folder_beside_a_target_in_its_own_subtree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
// Would require nesting "top" inside its own descendant "child" — silently ignored.
await vm.DropAsync(top, child, placeBelow: true);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
Assert.Single(vm.TopLevel);
}
[Fact]
public async Task MoveToFolderAsync_nests_a_folder_into_another_folder_and_persists_it()
{
await SeedAsync("f1", folderSort: 0);
await SeedAsync("f2", folderSort: 1);
var vm = NewVm();
await vm.LoadAsync();
var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders.Single(f => f.Id == "f2");
await vm.MoveToFolderAsync(f2, f1);
Assert.Equal("f1", f2.ParentFolderId);
Assert.Single(vm.TopLevel);
Assert.Same(f2, Assert.Single(f1.Children));
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task MoveToFolderAsync_ignores_moving_a_folder_into_itself_or_a_descendant()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
await vm.MoveToFolderAsync(top, top);
await vm.MoveToFolderAsync(top, child);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
}
[Fact]
@@ -244,13 +367,14 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var a = vm.Folders[0].Lists[0];
var b = vm.Folders[0].Lists[1];
var children = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
var (a, b) = (children[0], children[1]);
await vm.ReorderAsync(b, a, placeBelow: false);
Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId));
var reordered = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
Assert.Equal(["B", "A"], reordered.Select(l => l.Name));
Assert.All(reordered, l => Assert.Equal("f1", l.FolderId));
}
[Fact]
@@ -308,7 +432,7 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.RemoveFromFolderCommand.ExecuteAsync(row);