feat(ui): My Day actions, orphan-aware grouping, menu restructure
Pending UI work: - My Day add/remove context actions on task rows (parent removal cascades to children) - orphan-aware grouping: a child whose parent isn't in view renders as a top-level row, not an indented draft - shell menu restructure (Worker / Repositories submenus); 'Finalize plan' action, drop 'Queue subtasks sequentially' - notes editor refinements - subtask-row hover tweak (Surface3, no transition) - bump Avalonia 12.0.0 -> 12.0.4
This commit is contained in:
@@ -109,4 +109,83 @@ public class TaskRowViewModelPlanningTests
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
Assert.False(vm.CanQueuePlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivePlanningParent_CannotSendToQueue()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||
vm.HasPlanningChildren = true;
|
||||
Assert.False(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizedParentWithChildren_CanSendToQueue()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
vm.HasPlanningChildren = true;
|
||||
Assert.True(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivePlanning_CanFinalizePlanning()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||
Assert.True(vm.CanFinalizePlanning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizedPlanning_CannotFinalizePlanning()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
Assert.False(vm.CanFinalizePlanning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainIdle_CannotFinalizePlanning()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle);
|
||||
Assert.False(vm.CanFinalizePlanning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChildWithParentInView_RendersAsChild()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
||||
Assert.True(vm.ParentInView); // default
|
||||
Assert.True(vm.ShowAsChild);
|
||||
Assert.True(vm.IsDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrphanedChild_RendersFlat_WithNoDraftOrPlannedBadge()
|
||||
{
|
||||
// Parent absent from the view (e.g. removed from My Day, or daily-prep placed a lone
|
||||
// child there): the row stays a child by data but must read as a normal top-level task.
|
||||
var draftOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
|
||||
draftOrphan.ParentInView = false;
|
||||
Assert.True(draftOrphan.IsChild);
|
||||
Assert.False(draftOrphan.ShowAsChild);
|
||||
Assert.False(draftOrphan.IsDraft);
|
||||
|
||||
var plannedOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
|
||||
plannedOrphan.ParentFinalized = true;
|
||||
plannedOrphan.ParentInView = false;
|
||||
Assert.False(plannedOrphan.ShowAsChild);
|
||||
Assert.False(plannedOrphan.IsPlanned);
|
||||
Assert.False(plannedOrphan.IsDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAddToMyDay_TrueOnlyWhenNotInMyDayAndNotDone()
|
||||
{
|
||||
var row = MakeRow(TaskStatus.Idle);
|
||||
Assert.True(row.CanAddToMyDay); // idle, not yet in My Day
|
||||
|
||||
row.IsMyDay = true;
|
||||
Assert.False(row.CanAddToMyDay); // already in My Day
|
||||
|
||||
row.IsMyDay = false;
|
||||
row.Done = true;
|
||||
Assert.False(row.CanAddToMyDay); // done tasks don't belong in today's focus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
@@ -223,4 +224,105 @@ public class TasksIslandViewModelPlanningTests
|
||||
vm.ToggleExpandCommand.Execute(parent);
|
||||
Assert.Contains(child, vm.OpenItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regroup_ChildWithoutParentInView_ReadsAsTopLevelOrphan()
|
||||
{
|
||||
// Parent not in the view: the child must be flagged orphan so it renders flat, and it
|
||||
// surfaces as a normal top-level row rather than an indented Draft.
|
||||
var orphan = MakeRow("c1", TaskStatus.Idle, parentId: "missing-parent");
|
||||
|
||||
var (vm, _) = VmFactory.Create([orphan]);
|
||||
|
||||
Assert.False(orphan.ParentInView);
|
||||
Assert.False(orphan.ShowAsChild);
|
||||
Assert.False(orphan.IsDraft);
|
||||
Assert.Contains(orphan, vm.OpenItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regroup_ChildWithParentPresent_KeepsParentInView()
|
||||
{
|
||||
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
||||
|
||||
var (_, _) = VmFactory.Create([parent, child]);
|
||||
|
||||
Assert.True(child.ParentInView);
|
||||
Assert.True(child.ShowAsChild);
|
||||
}
|
||||
}
|
||||
|
||||
// ── My Day add / remove (real DB) ─────────────────────────────────────────────
|
||||
|
||||
public sealed class TasksIslandViewModelMyDayTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task AddToMyDay_SetsIsMyDay()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString("N");
|
||||
var taskId = Guid.NewGuid().ToString("N");
|
||||
await using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle, IsMyDay = false,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
|
||||
var row = new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle, IsMyDay = false };
|
||||
|
||||
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.AddToMyDayCommand).ExecuteAsync(row);
|
||||
|
||||
await using var verify = _db.CreateContext();
|
||||
var loaded = await verify.Tasks.FirstAsync(t => t.Id == taskId);
|
||||
Assert.True(loaded.IsMyDay);
|
||||
Assert.True(row.IsMyDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromMyDay_OnParent_CascadesToEveryChild()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString("N");
|
||||
var parentId = Guid.NewGuid().ToString("N");
|
||||
var child1 = Guid.NewGuid().ToString("N");
|
||||
var child2 = Guid.NewGuid().ToString("N");
|
||||
|
||||
await using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = parentId, ListId = listId, Title = "P", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, IsMyDay = true,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = child1, ListId = listId, Title = "C1", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = child2, ListId = listId, Title = "C2", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
|
||||
var parentRow = new TaskRowViewModel { Id = parentId, Status = TaskStatus.WaitingForChildren, IsMyDay = true };
|
||||
|
||||
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.RemoveFromMyDayCommand).ExecuteAsync(parentRow);
|
||||
|
||||
await using var verify = _db.CreateContext();
|
||||
Assert.False(await verify.Tasks.AnyAsync(t => t.IsMyDay),
|
||||
"removing the parent from My Day must clear IsMyDay on the parent and all its children");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user