feat(ui): collapse parent task rows by default with granular row sync
This commit is contained in:
@@ -88,6 +88,10 @@
|
|||||||
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
||||||
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.ChevronRight / Icon.ChevronDown — filled expand/collapse chevrons (PathIcon fills) -->
|
||||||
|
<StreamGeometry x:Key="Icon.ChevronRight">M9 5 L16 12 L9 19 L6.8 16.8 L11.6 12 L6.8 7.2 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="Icon.ChevronDown">M5 9 L12 16 L19 9 L16.8 6.8 L12 11.6 L7.2 6.8 Z</StreamGeometry>
|
||||||
|
|
||||||
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
|
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
|
||||||
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
|
||||||
|
|
||||||
|
|||||||
@@ -301,28 +301,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
internal void Regroup()
|
internal void Regroup()
|
||||||
{
|
{
|
||||||
OverdueItems.Clear();
|
// Collapse parents that have children by default, so subtasks stay tucked away until
|
||||||
OpenItems.Clear();
|
// the user expands the row (an explicit toggle is saved and wins over this default).
|
||||||
CompletedItems.Clear();
|
|
||||||
|
|
||||||
// Auto-collapse planning parents whose every child is Done (unless the user
|
|
||||||
// has explicitly toggled the row — saved state wins).
|
|
||||||
var childrenByParent = Items
|
var childrenByParent = Items
|
||||||
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
|
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
|
||||||
.GroupBy(r => r.ParentTaskId!)
|
.GroupBy(r => r.ParentTaskId!)
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild
|
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild && !r.Done))
|
||||||
&& r.PlanningPhase == PlanningPhase.Finalized
|
|
||||||
&& !r.Done))
|
|
||||||
{
|
{
|
||||||
if (_expandedState.ContainsKey(parent.Id)) continue;
|
if (_expandedState.ContainsKey(parent.Id)) continue;
|
||||||
if (childrenByParent.TryGetValue(parent.Id, out var kids)
|
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
|
||||||
&& kids.Count > 0
|
|
||||||
&& kids.All(c => c.Status == TaskStatus.Done))
|
|
||||||
{
|
|
||||||
parent.IsExpanded = false;
|
parent.IsExpanded = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Restore IsExpanded from saved state
|
// Restore IsExpanded from saved state
|
||||||
foreach (var r in Items)
|
foreach (var r in Items)
|
||||||
@@ -362,19 +352,29 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
var overdue = new List<TaskRowViewModel>();
|
||||||
|
var open = new List<TaskRowViewModel>();
|
||||||
|
var completed = new List<TaskRowViewModel>();
|
||||||
foreach (var r in flat)
|
foreach (var r in flat)
|
||||||
{
|
{
|
||||||
var underOpenPlanningParent = r.IsChild &&
|
var underOpenPlanningParent = r.IsChild &&
|
||||||
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
|
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
|
||||||
|
|
||||||
if (r.Done && !underOpenPlanningParent)
|
if (r.Done && !underOpenPlanningParent)
|
||||||
CompletedItems.Add(r);
|
completed.Add(r);
|
||||||
else if (r.ScheduledFor is { } d && d.Date < today)
|
else if (r.ScheduledFor is { } d && d.Date < today)
|
||||||
OverdueItems.Add(r);
|
overdue.Add(r);
|
||||||
else
|
else
|
||||||
OpenItems.Add(r);
|
open.Add(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile the bound collections in place (granular Insert/Move/Remove) rather than
|
||||||
|
// Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl
|
||||||
|
// keeps every unchanged container instead of tearing the whole list down on a Reset.
|
||||||
|
SyncCollection(OverdueItems, overdue);
|
||||||
|
SyncCollection(OpenItems, open);
|
||||||
|
SyncCollection(CompletedItems, completed);
|
||||||
|
|
||||||
HasOverdue = OverdueItems.Count > 0;
|
HasOverdue = OverdueItems.Count > 0;
|
||||||
HasOpen = OpenItems.Count > 0;
|
HasOpen = OpenItems.Count > 0;
|
||||||
HasCompleted = CompletedItems.Count > 0;
|
HasCompleted = CompletedItems.Count > 0;
|
||||||
@@ -500,6 +500,28 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
coll.Move(srcIdx, finalIdx);
|
coll.Move(srcIdx, finalIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile a bound collection toward a target order using granular Remove/Move/Insert,
|
||||||
|
// so unchanged rows keep their containers (no Reset-driven full re-render).
|
||||||
|
private static void SyncCollection(
|
||||||
|
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> dst,
|
||||||
|
List<TaskRowViewModel> target)
|
||||||
|
{
|
||||||
|
var keep = new HashSet<TaskRowViewModel>(target);
|
||||||
|
for (int i = dst.Count - 1; i >= 0; i--)
|
||||||
|
if (!keep.Contains(dst[i]))
|
||||||
|
dst.RemoveAt(i);
|
||||||
|
|
||||||
|
for (int i = 0; i < target.Count; i++)
|
||||||
|
{
|
||||||
|
var item = target[i];
|
||||||
|
if (i < dst.Count && ReferenceEquals(dst[i], item)) continue;
|
||||||
|
|
||||||
|
var cur = dst.IndexOf(item);
|
||||||
|
if (cur >= 0) dst.Move(cur, i);
|
||||||
|
else dst.Insert(i, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
||||||
{
|
{
|
||||||
if (OverdueItems.Contains(row)) return OverdueItems;
|
if (OverdueItems.Contains(row)) return OverdueItems;
|
||||||
|
|||||||
@@ -87,10 +87,10 @@
|
|||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
ToolTip.Tip="{loc:Tr tasks.toggleSubtasksTip}">
|
ToolTip.Tip="{loc:Tr tasks.toggleSubtasksTip}">
|
||||||
<Panel>
|
<Panel>
|
||||||
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsExpanded}"
|
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.ChevronDown}"
|
||||||
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
Foreground="{DynamicResource TextDimBrush}" IsVisible="{Binding IsExpanded}"/>
|
||||||
<TextBlock Classes="meta" Text="▸" IsVisible="{Binding !IsExpanded}"
|
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.ChevronRight}"
|
||||||
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
Foreground="{DynamicResource TextDimBrush}" IsVisible="{Binding !IsExpanded}"/>
|
||||||
</Panel>
|
</Panel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Animation;
|
|
||||||
using Avalonia.Animation.Easings;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Styling;
|
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
@@ -123,24 +118,4 @@ public partial class TaskRowView : UserControl
|
|||||||
ScheduleAnchor.Flyout?.Hide();
|
ScheduleAnchor.Flyout?.Hide();
|
||||||
_pendingScheduleRow = null;
|
_pendingScheduleRow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
base.OnAttachedToVisualTree(e);
|
|
||||||
RenderTransform = new TranslateTransform(0, 8);
|
|
||||||
Opacity = 0;
|
|
||||||
var anim = new Avalonia.Animation.Animation
|
|
||||||
{
|
|
||||||
Duration = TimeSpan.FromMilliseconds(300),
|
|
||||||
Easing = new CubicEaseOut(),
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0d) } },
|
|
||||||
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1d) } },
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await anim.RunAsync(this);
|
|
||||||
Opacity = 1;
|
|
||||||
RenderTransform = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ public class TasksIslandRegroupTests : IDisposable
|
|||||||
var vm = BuildViewModel();
|
var vm = BuildViewModel();
|
||||||
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||||
|
|
||||||
|
// Parents with children collapse by default; expand to surface the nested child.
|
||||||
|
vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1"));
|
||||||
|
|
||||||
// Child with Done status under an open Planning parent should NOT go to CompletedItems
|
// Child with Done status under an open Planning parent should NOT go to CompletedItems
|
||||||
Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1");
|
Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1");
|
||||||
// Child should appear nested (IsChild == true) in OpenItems
|
// Child should appear nested (IsChild == true) in OpenItems
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ public class TasksIslandViewModelPlanningTests
|
|||||||
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
|
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
|
public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands()
|
||||||
{
|
{
|
||||||
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Active);
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||||
var child1 = MakeRow("c1", TaskStatus.Idle, "p1");
|
var child1 = MakeRow("c1", TaskStatus.Idle, "p1");
|
||||||
@@ -173,18 +173,17 @@ public class TasksIslandViewModelPlanningTests
|
|||||||
|
|
||||||
var (vm, _) = VmFactory.Create([parent, child1, child2]);
|
var (vm, _) = VmFactory.Create([parent, child1, child2]);
|
||||||
|
|
||||||
// Initially expanded — children visible in OpenItems
|
// Collapsed by default — children hidden, parent still present
|
||||||
Assert.Contains(child1, vm.OpenItems);
|
|
||||||
Assert.Contains(child2, vm.OpenItems);
|
|
||||||
|
|
||||||
// Collapse the parent
|
|
||||||
vm.ToggleExpandCommand.Execute(parent);
|
|
||||||
|
|
||||||
// Children should no longer appear
|
|
||||||
Assert.DoesNotContain(child1, vm.OpenItems);
|
Assert.DoesNotContain(child1, vm.OpenItems);
|
||||||
Assert.DoesNotContain(child2, vm.OpenItems);
|
Assert.DoesNotContain(child2, vm.OpenItems);
|
||||||
// Parent still present
|
|
||||||
Assert.Contains(parent, vm.OpenItems);
|
Assert.Contains(parent, vm.OpenItems);
|
||||||
|
|
||||||
|
// Expand the parent
|
||||||
|
vm.ToggleExpandCommand.Execute(parent);
|
||||||
|
|
||||||
|
// Children now visible
|
||||||
|
Assert.Contains(child1, vm.OpenItems);
|
||||||
|
Assert.Contains(child2, vm.OpenItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -210,20 +209,23 @@ public class TasksIslandViewModelPlanningTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToggleExpand_ExpandsCollapsedParentAgain()
|
public void ToggleExpand_TogglesParentExpansion()
|
||||||
{
|
{
|
||||||
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||||
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
||||||
|
|
||||||
var (vm, _) = VmFactory.Create([parent, child]);
|
var (vm, _) = VmFactory.Create([parent, child]);
|
||||||
|
|
||||||
// Collapse
|
// Collapsed by default
|
||||||
vm.ToggleExpandCommand.Execute(parent);
|
|
||||||
Assert.DoesNotContain(child, vm.OpenItems);
|
Assert.DoesNotContain(child, vm.OpenItems);
|
||||||
|
|
||||||
// Re-expand
|
// Expand
|
||||||
vm.ToggleExpandCommand.Execute(parent);
|
vm.ToggleExpandCommand.Execute(parent);
|
||||||
Assert.Contains(child, vm.OpenItems);
|
Assert.Contains(child, vm.OpenItems);
|
||||||
|
|
||||||
|
// Collapse again
|
||||||
|
vm.ToggleExpandCommand.Execute(parent);
|
||||||
|
Assert.DoesNotContain(child, vm.OpenItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user