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
@@ -144,4 +144,49 @@ public sealed class ListFolderTests : IDisposable
Assert.Equal(["One", "Two"], folders.Select(f => f.Name));
Assert.Equal([0, 1], folders.Select(f => f.SortOrder));
}
[Fact]
public async Task SetFolderParentAsync_nests_a_folder_and_moves_it_back_out()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
}
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", "parent");
await using (var ctx = Open())
Assert.Equal("parent", (await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", null);
await using (var ctx = Open())
Assert.Null((await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
}
[Fact]
public async Task Deleting_a_folder_keeps_its_subfolder_and_moves_it_to_the_top_level()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
await repo.SetFolderParentAsync("child", "parent");
}
await using (var ctx = Open())
await new ListRepository(ctx).DeleteFolderAsync("parent");
await using (var ctx = Open())
{
var child = await ctx.ListFolders.AsNoTracking().SingleAsync();
Assert.Equal("child", child.Id);
Assert.Null(child.ParentFolderId);
}
}
}