Skip to content

Commit 670eff2

Browse files
SLVS-2522 Add context menu for hotspot (#6424)
1 parent a1ac1b9 commit 670eff2

File tree

8 files changed

+242
-17
lines changed

8 files changed

+242
-17
lines changed

src/IssueViz.Security.UnitTests/ReportView/Hotspots/HotspotViewModelTests.cs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,20 @@ namespace SonarLint.VisualStudio.IssueVisualization.Security.UnitTests.ReportVie
2828
[TestClass]
2929
public class HotspotViewModelTests
3030
{
31+
private readonly LocalHotspot hotspot = CreateMockedHotspot("csharp:101",
32+
Guid.NewGuid(),
33+
1,
34+
66,
35+
"remove todo comment",
36+
"myClass.cs");
37+
private HotspotViewModel testSubject;
38+
39+
[TestInitialize]
40+
public void TestInitialize() => testSubject = new HotspotViewModel(hotspot);
41+
3142
[TestMethod]
3243
public void Ctor_InitializesPropertiesAsExpected()
3344
{
34-
var hotspot = CreateMockedHotspot("csharp:101",
35-
Guid.NewGuid(),
36-
1,
37-
66,
38-
"remove todo comment",
39-
"myClass.cs");
40-
41-
var testSubject = new HotspotViewModel(hotspot);
42-
4345
testSubject.LocalHotspot.Should().Be(hotspot);
4446
testSubject.RuleInfo.RuleKey.Should().Be(hotspot.Visualization.RuleId);
4547
testSubject.RuleInfo.IssueId.Should().Be(hotspot.Visualization.IssueId);
@@ -50,6 +52,22 @@ public void Ctor_InitializesPropertiesAsExpected()
5052
testSubject.Issue.Should().Be(hotspot.Visualization);
5153
}
5254

55+
[TestMethod]
56+
public void ExistsOnServer_LocalHotspot_ReturnsFalse()
57+
{
58+
hotspot.Visualization.Issue.IssueServerKey.Returns((string)null);
59+
60+
testSubject.ExistsOnServer.Should().BeFalse();
61+
}
62+
63+
[TestMethod]
64+
public void ExistsOnServer_ServerHotspot_ReturnsTrue()
65+
{
66+
hotspot.Visualization.Issue.IssueServerKey.Returns(Guid.NewGuid().ToString());
67+
68+
testSubject.ExistsOnServer.Should().BeTrue();
69+
}
70+
5371
private static LocalHotspot CreateMockedHotspot(
5472
string ruleId,
5573
Guid issueId,

src/IssueViz.Security.UnitTests/ReportView/Hotspots/HotspotsReportViewModelTest.cs

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@
1818
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
*/
2020

21+
using System.Windows;
22+
using SonarLint.VisualStudio.Core;
2123
using SonarLint.VisualStudio.Core.Analysis;
24+
using SonarLint.VisualStudio.Integration.TestInfrastructure;
2225
using SonarLint.VisualStudio.IssueVisualization.Models;
2326
using SonarLint.VisualStudio.IssueVisualization.Security.Hotspots;
27+
using SonarLint.VisualStudio.IssueVisualization.Security.Hotspots.ReviewHotspot;
2428
using SonarLint.VisualStudio.IssueVisualization.Security.IssuesStore;
2529
using SonarLint.VisualStudio.IssueVisualization.Security.ReportView;
2630
using SonarLint.VisualStudio.IssueVisualization.Security.ReportView.Hotspots;
@@ -31,20 +35,28 @@ namespace SonarLint.VisualStudio.IssueVisualization.Security.UnitTests.ReportVie
3135
[TestClass]
3236
public class HotspotsReportViewModelTest
3337
{
38+
private readonly LocalHotspot serverHotspot = CreateMockedHotspot("myFile.cs", "serverKey");
3439
private ILocalHotspotsStore localHotspotsStore;
40+
private IMessageBox messageBox;
41+
private IReviewHotspotsService reviewHotspotsService;
3542
private HotspotsReportViewModel testSubject;
3643

3744
[TestInitialize]
3845
public void TestInitialize()
3946
{
4047
localHotspotsStore = Substitute.For<ILocalHotspotsStore>();
41-
testSubject = new HotspotsReportViewModel(localHotspotsStore);
48+
reviewHotspotsService = Substitute.For<IReviewHotspotsService>();
49+
messageBox = Substitute.For<IMessageBox>();
50+
testSubject = new HotspotsReportViewModel(localHotspotsStore, reviewHotspotsService, messageBox);
4251
}
4352

4453
[TestMethod]
4554
public void MefCtor_CheckIsExported() =>
4655
MefTestHelpers.CheckTypeCanBeImported<HotspotsReportViewModel, IHotspotsReportViewModel>(
47-
MefTestHelpers.CreateExport<ILocalHotspotsStore>());
56+
MefTestHelpers.CreateExport<ILocalHotspotsStore>(),
57+
MefTestHelpers.CreateExport<IReviewHotspotsService>(),
58+
MefTestHelpers.CreateExport<IMessageBox>()
59+
);
4860

4961
[TestMethod]
5062
public void MefCtor_CheckIsSingleton() => MefTestHelpers.CheckIsSingletonMefComponent<HotspotsReportViewModel>();
@@ -114,12 +126,93 @@ public void HotspotsChanged_RaisedOnStoreIssuesChanged()
114126
raised.Should().BeTrue();
115127
}
116128

117-
private static LocalHotspot CreateMockedHotspot(string filePath)
129+
[TestMethod]
130+
public async Task ShowHotspotInBrowserAsync_CallsHandler()
131+
{
132+
var hotspot = CreateMockedHotspot("myFile.cs");
133+
134+
await testSubject.ShowHotspotInBrowserAsync(hotspot);
135+
136+
reviewHotspotsService.Received(1).OpenHotspotAsync(hotspot.Visualization.Issue.IssueServerKey).IgnoreAwaitForAssert();
137+
}
138+
139+
[TestMethod]
140+
public async Task GetAllowedStatusesAsync_ChangeStatusPermitted_ReturnsListOfAllowedStatuses()
141+
{
142+
var allowedStatuses = new List<HotspotStatus> { HotspotStatus.Fixed, HotspotStatus.ToReview };
143+
MockChangeStatusPermitted(serverHotspot.Visualization.Issue.IssueServerKey, allowedStatuses);
144+
145+
var result = await testSubject.GetAllowedStatusesAsync(new HotspotViewModel(serverHotspot));
146+
147+
result.Should().BeEquivalentTo(allowedStatuses);
148+
messageBox.DidNotReceive().Show(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<MessageBoxButton>(), Arg.Any<MessageBoxImage>());
149+
}
150+
151+
[TestMethod]
152+
public async Task GetAllowedStatusesAsync_ChangeStatusNotPermitted_ShowsMessageBoxAndReturnsNull()
153+
{
154+
var reason = "Not permitted";
155+
MockChangeStatusNotPermitted(serverHotspot.Visualization.Issue.IssueServerKey, reason);
156+
157+
var result = await testSubject.GetAllowedStatusesAsync(new HotspotViewModel(serverHotspot));
158+
159+
result.Should().BeNull();
160+
messageBox.Received(1).Show(Arg.Is<string>(x => x == string.Format(Resources.ReviewHotspotWindow_CheckReviewPermittedFailureMessage, reason)),
161+
Arg.Is<string>(x => x == Resources.ReviewHotspotWindow_FailureTitle), MessageBoxButton.OK, MessageBoxImage.Error);
162+
}
163+
164+
[TestMethod]
165+
public async Task GetAllowedStatusesAsync_NoStatusSelected_ShowsMessageBoxAndReturnsNull()
166+
{
167+
var result = await testSubject.GetAllowedStatusesAsync(null);
168+
169+
result.Should().BeNull();
170+
messageBox.Received(1).Show(
171+
Arg.Is<string>(x => x == string.Format(Resources.ReviewHotspotWindow_CheckReviewPermittedFailureMessage, Resources.ReviewHotspotWindow_NoStatusSelectedFailureMessage)),
172+
Arg.Is<string>(x => x == Resources.ReviewHotspotWindow_FailureTitle), MessageBoxButton.OK, MessageBoxImage.Error);
173+
}
174+
175+
[TestMethod]
176+
[DataRow(HotspotStatus.Fixed)]
177+
[DataRow(HotspotStatus.ToReview)]
178+
[DataRow(HotspotStatus.Acknowledged)]
179+
[DataRow(HotspotStatus.Safe)]
180+
public async Task ChangeHotspotStatusAsync_Succeeds_ReturnsTrue(HotspotStatus newStatus)
181+
{
182+
var hotspotViewModel = new HotspotViewModel(serverHotspot);
183+
MockReviewHotspot(serverHotspot.Visualization.Issue.IssueServerKey, newStatus, true);
184+
185+
var result = await testSubject.ChangeHotspotStatusAsync(hotspotViewModel, newStatus);
186+
187+
result.Should().BeTrue();
188+
reviewHotspotsService.Received(1).ReviewHotspotAsync(serverHotspot.Visualization.Issue.IssueServerKey, newStatus).IgnoreAwaitForAssert();
189+
messageBox.DidNotReceive().Show(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<MessageBoxButton>(), Arg.Any<MessageBoxImage>());
190+
}
191+
192+
[TestMethod]
193+
[DataRow(HotspotStatus.Fixed)]
194+
[DataRow(HotspotStatus.ToReview)]
195+
[DataRow(HotspotStatus.Acknowledged)]
196+
[DataRow(HotspotStatus.Safe)]
197+
public async Task ChangeHotspotStatusAsync_Fails_ShowsMessageBox(HotspotStatus newStatus)
198+
{
199+
var hotspotViewModel = new HotspotViewModel(serverHotspot);
200+
MockReviewHotspot(serverHotspot.Visualization.Issue.IssueServerKey, newStatus, false);
201+
202+
var result = await testSubject.ChangeHotspotStatusAsync(hotspotViewModel, newStatus);
203+
204+
result.Should().BeFalse();
205+
messageBox.Received(1).Show(Arg.Is<string>(x => x == Resources.ReviewHotspotWindow_ReviewFailureMessage), Arg.Is<string>(x => x == Resources.ReviewHotspotWindow_FailureTitle),
206+
MessageBoxButton.OK, MessageBoxImage.Error);
207+
}
208+
209+
private static LocalHotspot CreateMockedHotspot(string filePath, string hotspotKey = null)
118210
{
119211
var analysisIssueVisualization = Substitute.For<IAnalysisIssueVisualization>();
120212
var analysisIssueBase = Substitute.For<IAnalysisIssueBase>();
121213
analysisIssueBase.PrimaryLocation.FilePath.Returns(filePath);
122214
analysisIssueVisualization.Issue.Returns(analysisIssueBase);
215+
analysisIssueVisualization.Issue.IssueServerKey.Returns(hotspotKey);
123216

124217
return new LocalHotspot(analysisIssueVisualization, default, default);
125218
}
@@ -136,4 +229,12 @@ private static void VerifyExpectedHotspotGroupViewModel(GroupFileViewModel group
136229
groupFileVm.FilteredIssues.Should().ContainSingle(vm => ((HotspotViewModel)vm).LocalHotspot == expectedHotspot);
137230
}
138231
}
232+
233+
private void MockChangeStatusPermitted(string hotspotKey, List<HotspotStatus> allowedStatuses) =>
234+
reviewHotspotsService.CheckReviewHotspotPermittedAsync(hotspotKey).Returns(new ReviewHotspotPermittedArgs(allowedStatuses));
235+
236+
private void MockChangeStatusNotPermitted(string hotspotKey, string reason) =>
237+
reviewHotspotsService.CheckReviewHotspotPermittedAsync(hotspotKey).Returns(new ReviewHotspotNotPermittedArgs(reason));
238+
239+
private void MockReviewHotspot(string hotspotKey, HotspotStatus newStatus, bool succeeded) => reviewHotspotsService.ReviewHotspotAsync(hotspotKey, newStatus).Returns(succeeded);
139240
}

src/IssueViz.Security/ReportView/Hotspots/HotspotViewModel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ namespace SonarLint.VisualStudio.IssueVisualization.Security.ReportView.Hotspots
2727
internal class HotspotViewModel : ViewModelBase, IAnalysisIssueViewModel
2828
{
2929
public LocalHotspot LocalHotspot { get; }
30+
public bool ExistsOnServer => LocalHotspot.Visualization.Issue.IssueServerKey != null;
3031

3132
public HotspotViewModel(LocalHotspot localHotspot)
3233
{

src/IssueViz.Security/ReportView/Hotspots/HotspotsReportViewModel.cs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020

2121
using System.Collections.ObjectModel;
2222
using System.ComponentModel.Composition;
23+
using System.Windows;
24+
using SonarLint.VisualStudio.Core;
25+
using SonarLint.VisualStudio.Core.Analysis;
2326
using SonarLint.VisualStudio.IssueVisualization.Security.Hotspots;
27+
using SonarLint.VisualStudio.IssueVisualization.Security.Hotspots.ReviewHotspot;
2428
using SonarLint.VisualStudio.IssueVisualization.Security.IssuesStore;
2529

2630
namespace SonarLint.VisualStudio.IssueVisualization.Security.ReportView.Hotspots;
@@ -29,6 +33,12 @@ internal interface IHotspotsReportViewModel : IDisposable
2933
{
3034
ObservableCollection<IGroupViewModel> GetHotspotsGroupViewModels();
3135

36+
Task ShowHotspotInBrowserAsync(LocalHotspot localHotspot);
37+
38+
Task<IEnumerable<HotspotStatus>> GetAllowedStatusesAsync(HotspotViewModel selectedHotspotViewModel);
39+
40+
Task<bool> ChangeHotspotStatusAsync(HotspotViewModel selectedHotspotViewModel, HotspotStatus newStatus);
41+
3242
event EventHandler HotspotsChanged;
3343
}
3444

@@ -37,11 +47,15 @@ internal interface IHotspotsReportViewModel : IDisposable
3747
internal sealed class HotspotsReportViewModel : IHotspotsReportViewModel
3848
{
3949
private readonly ILocalHotspotsStore hotspotsStore;
50+
private readonly IReviewHotspotsService reviewHotspotsService;
51+
private readonly IMessageBox messageBox;
4052

4153
[ImportingConstructor]
42-
public HotspotsReportViewModel(ILocalHotspotsStore hotspotsStore)
54+
public HotspotsReportViewModel(ILocalHotspotsStore hotspotsStore, IReviewHotspotsService reviewHotspotsService, IMessageBox messageBox)
4355
{
4456
this.hotspotsStore = hotspotsStore;
57+
this.reviewHotspotsService = reviewHotspotsService;
58+
this.messageBox = messageBox;
4559
hotspotsStore.IssuesChanged += HotspotsStore_IssuesChanged;
4660
}
4761

@@ -55,6 +69,35 @@ public ObservableCollection<IGroupViewModel> GetHotspotsGroupViewModels()
5569
return GetGroupViewModel(hotspots);
5670
}
5771

72+
public async Task ShowHotspotInBrowserAsync(LocalHotspot localHotspot) => await reviewHotspotsService.OpenHotspotAsync(localHotspot.Visualization.Issue.IssueServerKey);
73+
74+
public async Task<IEnumerable<HotspotStatus>> GetAllowedStatusesAsync(HotspotViewModel selectedHotspotViewModel)
75+
{
76+
var response = selectedHotspotViewModel == null
77+
? new ReviewHotspotNotPermittedArgs(Resources.ReviewHotspotWindow_NoStatusSelectedFailureMessage)
78+
: await reviewHotspotsService.CheckReviewHotspotPermittedAsync(selectedHotspotViewModel.LocalHotspot.Visualization.Issue.IssueServerKey);
79+
switch (response)
80+
{
81+
case ReviewHotspotPermittedArgs reviewHotspotPermittedArgs:
82+
return reviewHotspotPermittedArgs.AllowedStatuses;
83+
case ReviewHotspotNotPermittedArgs reviewHotspotNotPermittedArgs:
84+
messageBox.Show(string.Format(Resources.ReviewHotspotWindow_CheckReviewPermittedFailureMessage, reviewHotspotNotPermittedArgs.Reason), Resources.ReviewHotspotWindow_FailureTitle,
85+
MessageBoxButton.OK, MessageBoxImage.Error);
86+
break;
87+
}
88+
return null;
89+
}
90+
91+
public async Task<bool> ChangeHotspotStatusAsync(HotspotViewModel selectedHotspotViewModel, HotspotStatus newStatus)
92+
{
93+
var wasChanged = await reviewHotspotsService.ReviewHotspotAsync(selectedHotspotViewModel.LocalHotspot.Visualization.Issue.IssueServerKey, newStatus);
94+
if (!wasChanged)
95+
{
96+
messageBox.Show(Resources.ReviewHotspotWindow_ReviewFailureMessage, Resources.ReviewHotspotWindow_FailureTitle, MessageBoxButton.OK, MessageBoxImage.Error);
97+
}
98+
return wasChanged;
99+
}
100+
58101
private static ObservableCollection<IGroupViewModel> GetGroupViewModel(IEnumerable<IIssueViewModel> issueViewModels)
59102
{
60103
var issuesByFileGrouping = issueViewModels.GroupBy(vm => vm.FilePath);

src/IssueViz.Security/ReportView/ReportViewControl.xaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,16 @@
191191
<MenuItem Click="ViewDependencyRiskInBrowser_OnClick" Style="{StaticResource ViewIssueInBrowserMenuItemStyle}" />
192192
</ContextMenu>
193193

194+
<ContextMenu x:Key="HotspotContextMenu"
195+
Visibility="{Binding Path=ExistsOnServer, Converter={StaticResource TrueToVisibleConverter}}">
196+
<MenuItem Header="{x:Static res:Resources.ChangeStatusMenuItem}"
197+
Click="ChangeHotspotStatusMenuItem_OnClick"
198+
Style="{StaticResource ChangeStatusMenuItemStyle}"/>
199+
<MenuItem Click="ViewHotspotInBrowser_OnClick"
200+
Loaded="ShowHotspotInBrowserMenuItem_OnLoaded"
201+
Style="{StaticResource ViewIssueInBrowserMenuItemStyle}" />
202+
</ContextMenu>
203+
194204
<Style x:Key="ResolutionFilterBorderStyle" TargetType="Button">
195205
<Setter Property="Margin" Value="5, 3"/>
196206
<Setter Property="Padding" Value="2"/>
@@ -362,7 +372,10 @@
362372

363373
<!--Hotspots styling-->
364374
<DataTemplate DataType="{x:Type hotspots:HotspotViewModel}">
365-
<Grid Margin="5,2" MouseRightButtonDown="TreeViewItem_OnMouseRightButtonDown" ToolTip="{x:Static res:Resources.HotspotsControl_NavigationTooltip}">
375+
<Grid Margin="5,2"
376+
ContextMenu="{StaticResource HotspotContextMenu}"
377+
MouseRightButtonDown="TreeViewItem_OnMouseRightButtonDown"
378+
ToolTip="{x:Static res:Resources.HotspotsControl_NavigationTooltip}">
366379
<Grid.ColumnDefinitions>
367380
<ColumnDefinition Width="Auto" />
368381
<ColumnDefinition Width="Auto" />

src/IssueViz.Security/ReportView/ReportViewControl.xaml.cs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626
using System.Windows.Navigation;
2727
using SonarLint.VisualStudio.ConnectedMode.UI;
2828
using SonarLint.VisualStudio.Core;
29+
using SonarLint.VisualStudio.Core.Analysis;
2930
using SonarLint.VisualStudio.Core.Binding;
3031
using SonarLint.VisualStudio.Core.Telemetry;
3132
using SonarLint.VisualStudio.IssueVisualization.Editor;
3233
using SonarLint.VisualStudio.IssueVisualization.IssueVisualizationControl.ViewModels.Commands;
3334
using SonarLint.VisualStudio.IssueVisualization.Security.DependencyRisks;
35+
using SonarLint.VisualStudio.IssueVisualization.Security.Hotspots.HotspotsList.ViewModels;
3436
using SonarLint.VisualStudio.IssueVisualization.Security.ReportView.Hotspots;
3537
using SonarLint.VisualStudio.IssueVisualization.Security.ReviewStatus;
38+
using HotspotViewModel = SonarLint.VisualStudio.IssueVisualization.Security.ReportView.Hotspots.HotspotViewModel;
3639

3740
namespace SonarLint.VisualStudio.IssueVisualization.Security.ReportView;
3841

@@ -113,15 +116,20 @@ private void ViewDependencyRiskInBrowser_OnClick(object sender, RoutedEventArgs
113116
DependencyRisksReportViewModel.ShowDependencyRiskInBrowser(selectedDependencyRiskViewModel.DependencyRisk);
114117
}
115118

116-
private void DependencyRiskContextMenu_OnLoaded(object sender, RoutedEventArgs e)
119+
private void DependencyRiskContextMenu_OnLoaded(object sender, RoutedEventArgs e) => SetDataContextToReportViewModel<ContextMenu>(sender);
120+
121+
private void SetDataContextToReportViewModel<T>(object sender) where T : FrameworkElement
117122
{
118-
if (sender is ContextMenu contextMenu)
123+
if (sender is T contextMenu)
119124
{
120-
// setting the DataContext directly on the context menu does not work for the TreeViewItem
125+
// workaround that allows setting the DataContext to the ReportViewModel, which is not accessible from the TreeViewItem context menu
126+
// due to the fact that a context menu is a popup and is not part of the visual tree
121127
contextMenu.DataContext = ReportViewModel;
122128
}
123129
}
124130

131+
private void ShowHotspotInBrowserMenuItem_OnLoaded(object sender, RoutedEventArgs e) => SetDataContextToReportViewModel<MenuItem>(sender);
132+
125133
private void TreeViewItem_OnMouseRightButtonDown(object sender, MouseButtonEventArgs e)
126134
{
127135
if (sender is FrameworkElement element && FindParentOfType<TreeViewItem>(element) is { } treeViewItem)
@@ -180,4 +188,33 @@ private static void ExecuteCommandIfValid(ICommand command, object parameter)
180188
command.Execute(parameter);
181189
}
182190
}
191+
192+
private async void ViewHotspotInBrowser_OnClick(object sender, RoutedEventArgs e)
193+
{
194+
if (ReportViewModel.SelectedItem is HotspotViewModel hotspotViewModel)
195+
{
196+
await HotspotsReportViewModel.ShowHotspotInBrowserAsync(hotspotViewModel.LocalHotspot);
197+
}
198+
}
199+
200+
private async void ChangeHotspotStatusMenuItem_OnClick(object sender, RoutedEventArgs e)
201+
{
202+
if (sender is not MenuItem { DataContext: HotspotViewModel hotspotViewModel } ||
203+
await HotspotsReportViewModel.GetAllowedStatusesAsync(hotspotViewModel) is not { } allowedStatuses)
204+
{
205+
return;
206+
}
207+
208+
var changeHotspotStatusViewModel = new ChangeHotspotStatusViewModel(hotspotViewModel.LocalHotspot.HotspotStatus, allowedStatuses);
209+
var dialog = new ChangeStatusWindow(changeHotspotStatusViewModel, browserService, activeSolutionBoundTracker);
210+
if (dialog.ShowDialog(Application.Current.MainWindow) is true)
211+
{
212+
var newStatus = changeHotspotStatusViewModel.SelectedStatusViewModel.GetCurrentStatus<HotspotStatus>();
213+
var wasChanged = await HotspotsReportViewModel.ChangeHotspotStatusAsync(hotspotViewModel, newStatus);
214+
if (wasChanged && newStatus is HotspotStatus.Fixed or HotspotStatus.Safe)
215+
{
216+
ReportViewModel.GroupViewModels.ToList().ForEach(vm => vm.FilteredIssues.Remove(hotspotViewModel));
217+
}
218+
}
219+
}
183220
}

0 commit comments

Comments
 (0)