Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions src/Capacitor.App/ViewModels/WorkContextViewModel.Projections.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ int SessionCount {
set {
if (_sessionCount == value) return;
_sessionCount = value;
this.RaisePropertyChanged(nameof(SessionCountText));
this.RaisePropertyChanged(nameof(WhoCountText));
}
}

Expand All @@ -72,11 +72,20 @@ int SessionCount {
public bool HasBlockers => _blockedBy.Count > 0;
public bool HasIssue => Issue is not null;
public bool HasContributors => _contributors.Count > 0;
public string SessionCountText => _sessionCount switch {
0 => "",
1 => "1 session",
var n => $"{n} sessions",
};
/// People first, since that is what the section lists; the session count follows because one
/// person can hold several. With nobody listed the session count stands alone.
public string WhoCountText {
get {
var sessions = _sessionCount switch {
0 => "",
1 => "1 session",
var n => $"{n} sessions",
};
if (_contributors.Count == 0) return sessions;
var people = _contributors.Count == 1 ? "1 person" : $"{_contributors.Count} people";
return sessions.Length == 0 ? people : $"{people} · {sessions}";
}
}

string _requester = "You";
public string Requester { get => _requester; private set => this.RaiseAndSetIfChanged(ref _requester, value); }
Expand Down Expand Up @@ -156,6 +165,7 @@ void RaiseCardCounts() {
this.RaisePropertyChanged(nameof(HasParts));
this.RaisePropertyChanged(nameof(HasBlockers));
this.RaisePropertyChanged(nameof(HasContributors));
this.RaisePropertyChanged(nameof(WhoCountText));
}

void ApplyReady(WorkContextRead read) {
Expand Down Expand Up @@ -194,7 +204,9 @@ void ApplyReady(WorkContextRead read) {

void ApplyItem(WorkItemDto item, IReadOnlyList<SessionWorkItemAssignmentDto> assignments) {
Key = FirstNonBlank(item.Key?.ShortKey);
Title = FirstNonBlank(item.EnrichedTitle, item.Title) ?? "";
// An item with no tracker or generated title arrives with its key as the title.
var title = FirstNonBlank(item.EnrichedTitle, item.Title) ?? "";
Title = string.Equals(title, Key, StringComparison.OrdinalIgnoreCase) ? "" : title;
Overview = item.IsOverviewMechanical ? null : FirstNonBlank(item.Overview);
ApplyState(item.State?.Kind);

Expand Down Expand Up @@ -234,7 +246,7 @@ void ApplyIssue(WorkItemLinkDto? link) {
Issue = null;
return;
}
var title = FirstNonBlank(link.Title) ?? $"Issue {link.ShortKey}";
var title = FirstNonBlank(link.Title) ?? "";
if (Issue is { } current && current.Key == link.ShortKey && current.Title == title && current.Url == link.Url) return;
Issue = new WorkContextLinkViewModel("ISSUE", link.ShortKey, title, link.Url, _opener);
}
Expand Down Expand Up @@ -279,7 +291,7 @@ static void Replace<T, TKey>(AvaloniaList<T> target, List<T> incoming, Func<T, T
}

WorkContextLinkViewModel Link(int number, string? title, string? url) =>
new("PULL REQUEST", $"#{number}", title ?? $"Pull request #{number}", url, _opener);
new("PULL REQUEST", $"#{number}", title ?? "", url, _opener);

static (string Provider, string Host) RepositoryIdentity(SessionSummaryDto summary, string repoHash) {
var link = summary.PullRequests.FirstOrDefault(pr => pr.RepoHash == repoHash && PullRequestWire.SafeLink(pr.Url) is not null);
Expand Down
25 changes: 19 additions & 6 deletions src/Capacitor.App/Views/WorkContextView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,22 @@
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Transparent stretch wrapper for cards / nested chrome — not a hit-target of its own. -->
<!-- Stretch wrapper around a card: the card is the chrome, so the wrapper paints nothing in
any state — Fluent's hover fill lands on the presenter at the button's own radius and
would show behind the card's corners. -->
<Style Selector="Button.toggle">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.toggle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
Button.toggle:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<!-- Section rows (Who's on it / Session): real padding so hover and click aren't tight to glyphs. -->
<Style Selector="Button.sectionHeader">
<Setter Property="Background" Value="Transparent" />
Expand Down Expand Up @@ -111,6 +119,9 @@
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="13" />
</Style>
<Style Selector="Button.toggle:pointerover Border.card">
<Setter Property="BorderBrush" Value="{StaticResource KcapFaintBrush}" />
</Style>
<Style Selector="Path.chevron">
<Setter Property="Stroke" Value="{StaticResource KcapMutedBrush}" />
<Setter Property="StrokeThickness" Value="1.8" />
Expand All @@ -125,8 +136,9 @@
<StackPanel Spacing="6">
<TextBlock Text="{Binding Eyebrow}" Classes="cardEyebrow" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Key}" FontSize="11.5" FontWeight="Bold" Foreground="{StaticResource KcapSuccessBrush}" VerticalAlignment="Center" />
<TextBlock Text="{Binding Title}" FontSize="11.5" Foreground="{StaticResource KcapTextBrush}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock x:Name="LinkKey" Text="{Binding Key}" FontSize="11.5" FontWeight="Bold" Foreground="{StaticResource KcapSuccessBrush}" VerticalAlignment="Center" />
<TextBlock x:Name="LinkTitle" Text="{Binding Title}" FontSize="11.5" Foreground="{StaticResource KcapTextBrush}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
IsVisible="{Binding Title, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</StackPanel>
</Border>
Expand Down Expand Up @@ -175,7 +187,8 @@
Foreground="{StaticResource KcapSuccessBrush}" Margin="0,8,0,0"
IsVisible="{Binding Key, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock x:Name="WorkContextTitle" Text="{Binding Title}" FontSize="12.5" LineHeight="18"
Foreground="{StaticResource KcapTextBrush}" TextWrapping="Wrap" Margin="0,4,0,0" />
Foreground="{StaticResource KcapTextBrush}" TextWrapping="Wrap" Margin="0,4,0,0"
IsVisible="{Binding Title, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock x:Name="OverviewText" Text="{Binding Overview}" FontSize="11" LineHeight="16"
Foreground="{StaticResource KcapMutedBrush}" TextWrapping="Wrap" Margin="0,6,0,0"
IsVisible="{Binding Overview, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
Expand Down Expand Up @@ -259,9 +272,9 @@
<Path Classes="chevron" Data="M3,4.5 L6,7.5 L9,4.5" IsVisible="{Binding PeopleExpanded}" />
<Path Classes="chevron" Data="M4.5,3 L7.5,6 L4.5,9" IsVisible="{Binding !PeopleExpanded}" />
</Panel>
<TextBlock x:Name="SessionCountText" DockPanel.Dock="Right" Text="{Binding SessionCountText}" FontSize="10.5"
<TextBlock x:Name="WhoCountText" DockPanel.Dock="Right" Text="{Binding WhoCountText}" FontSize="10.5"
Foreground="{StaticResource KcapMutedBrush}" VerticalAlignment="Center" Margin="0,0,10,0"
IsVisible="{Binding SessionCountText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
IsVisible="{Binding WhoCountText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Text="WHO'S ON IT" Classes="eyebrow" />
</DockPanel>
</Button>
Expand Down
55 changes: 47 additions & 8 deletions test/Capacitor.App.Tests.Unit/WorkContextViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,27 @@ await RunOnUiAsync(async () => {
});
}

/// The server sends the key as the title when an item has no tracker or generated title, so
/// the card would otherwise print the key twice.
[Test]
[NotInParallel("AvaloniaSession")]
public async Task A_key_titled_item_with_no_other_title_shows_the_key_once() {
await RunOnUiAsync(async () => {
var h = new Harness();
h.Source.Enqueue(
ReadyWith(Row("w1", "WK-2198"), Item(title: "WK-2198", enriched: null)),
ReadyWith(Row("w1", "WK-2198 — Desktop shell"), Item(title: "WK-2198", enriched: "Desktop shell")));

await h.PushAsync(Dto());
await Assert.That(h.Vm.Key).IsEqualTo("WK-2198");
await Assert.That(h.Vm.Title).IsEqualTo("");

await h.TickAsync();
await Assert.That(h.Vm.Title).IsEqualTo("Desktop shell");
await h.Vm.TeardownAsync();
});
}

[Test]
[NotInParallel("AvaloniaSession")]
public async Task Without_an_item_a_new_primary_shows_the_assignment_label_whole_with_no_key() {
Expand Down Expand Up @@ -686,7 +707,8 @@ await RunOnUiAsync(async () => {
await Assert.That(h.Opener.Opened).IsEquivalentTo(new[] { "https://github.com/kurrent-io/kcap-cli/issues/777" });

await h.TickAsync();
await Assert.That(h.Vm.Issue!.Title).IsEqualTo("Issue WK-2521");
await Assert.That(h.Vm.Issue!.Key).IsEqualTo("WK-2521");
await Assert.That(h.Vm.Issue.Title).IsEqualTo("");
await Assert.That(h.Vm.Issue.CanOpen).IsFalse();

await h.TickAsync();
Expand All @@ -696,29 +718,42 @@ await RunOnUiAsync(async () => {
});
}

/// The section lists people, so its count names people first; the session count stays beside
/// it because one person can hold several sessions. Without a listed contributor the requester
/// row stands in and the session count alone is shown.
[Test]
[NotInParallel("AvaloniaSession")]
public async Task Contributors_and_the_session_count_come_from_the_item_and_the_requester_row_is_the_fallback() {
public async Task The_who_count_names_people_before_sessions_and_the_requester_row_is_the_fallback() {
await RunOnUiAsync(async () => {
var h = new Harness();
var now = h.Time.GetUtcNow();
var crowded = Item() with {
Contributors = [Person("u1", " Ada Lovelace ", now.AddHours(-2)), Person("github:7", null, now.AddDays(-3)), Person("u3", "👩 Grace")],
SessionCount = 3,
SessionCount = 4,
};
h.Source.Enqueue(ReadyWith(Row("w1", "t"), crowded), ReadyWith(Row("w1", "t"), Item() with { SessionCount = 1 }));
h.Source.Enqueue(
ReadyWith(Row("w1", "t"), crowded),
ReadyWith(Row("w1", "t"), Item() with { Contributors = [Person("u1", "Ada")], SessionCount = 2 }),
ReadyWith(Row("w1", "t"), Item() with { Contributors = [Person("u1", "Ada")], SessionCount = 1 }),
ReadyWith(Row("w1", "t"), Item() with { SessionCount = 1 }));
await h.PushAsync(Dto());

await Assert.That(h.Vm.HasContributors).IsTrue();
await Assert.That(h.Vm.Contributors.Select(c => c.Name)).IsEquivalentTo(new[] { "Ada Lovelace", "github:7", "👩 Grace" }, TUnit.Assertions.Enums.CollectionOrdering.Matching);
await Assert.That(h.Vm.Contributors.Select(c => c.Initial)).IsEquivalentTo(new[] { "A", "G", "👩" }, TUnit.Assertions.Enums.CollectionOrdering.Matching);
await Assert.That(h.Vm.Contributors.Select(c => c.LastActivityText)).IsEquivalentTo(new[] { "2h ago", "3d ago", "" }, TUnit.Assertions.Enums.CollectionOrdering.Matching);
await Assert.That(h.Vm.SessionCountText).IsEqualTo("3 sessions");
await Assert.That(h.Vm.WhoCountText).IsEqualTo("3 people · 4 sessions");

await h.TickAsync();
await Assert.That(h.Vm.WhoCountText).IsEqualTo("1 person · 2 sessions");

await h.TickAsync();
await Assert.That(h.Vm.WhoCountText).IsEqualTo("1 person · 1 session");

await h.TickAsync();
await Assert.That(h.Vm.HasContributors).IsFalse();
await Assert.That(h.Vm.Contributors).IsEmpty();
await Assert.That(h.Vm.SessionCountText).IsEqualTo("1 session");
await Assert.That(h.Vm.WhoCountText).IsEqualTo("1 session");
await Assert.That(h.Vm.Requester).IsEqualTo("You");
await h.Vm.TeardownAsync();
});
Expand Down Expand Up @@ -754,7 +789,8 @@ await RunOnUiAsync(async () => {
};
var otherRepo = dup with { PullRequests = [Pr("kurrent-io", "kcap-server", 42, "https://github.com/kurrent-io/kcap-server/pull/42", "Server")] };
var noIdentity = dup with { RepoOwner = null, RepoName = null, PullRequests = [Pr("x", "y", 42, null, "Elsewhere")] };
h.Source.Enqueue(ReadyWith(null, summary: dup), ReadyWith(null, summary: otherRepo), ReadyWith(null, summary: noIdentity));
var untitled = dup with { PrTitle = null, PullRequests = [] };
h.Source.Enqueue(ReadyWith(null, summary: dup), ReadyWith(null, summary: otherRepo), ReadyWith(null, summary: noIdentity), ReadyWith(null, summary: untitled));

await h.PushAsync(Dto());
await Assert.That(h.Vm.Links.Select(l => l.Title)).IsEquivalentTo(new[] { "Listed" });
Expand All @@ -766,6 +802,9 @@ await RunOnUiAsync(async () => {

await h.TickAsync();
await Assert.That(h.Vm.Links.Select(l => l.Title)).IsEquivalentTo(new[] { "Elsewhere" });

await h.TickAsync();
await Assert.That(h.Vm.Links.Select(l => (l.Key, l.Title))).IsEquivalentTo(new[] { ("#42", "") });
await h.Vm.TeardownAsync();
});
}
Expand Down Expand Up @@ -894,7 +933,7 @@ await RunOnUiAsync(async () => {
await Assert.That(h.Vm.Links).IsEmpty();
await Assert.That(h.Vm.Issue).IsNull();
await Assert.That(h.Vm.Contributors).IsEmpty();
await Assert.That(h.Vm.SessionCountText).IsEqualTo("");
await Assert.That(h.Vm.WhoCountText).IsEqualTo("");
await Assert.That(h.Vm.Repository).IsEqualTo("myproj");
await Assert.That(h.Vm.Requester).IsEqualTo("You");
await h.Vm.TeardownAsync();
Expand Down
Loading