Skip to content

Commit

Permalink
Merge pull request #2 from TimVinkemeier/feature/minimal-ux
Browse files Browse the repository at this point in the history
Minor UX improvements, settings validation
  • Loading branch information
TimVinkemeier committed Sep 6, 2020
2 parents 2bbc6df + 947f67c commit 44040d8
Show file tree
Hide file tree
Showing 11 changed files with 380 additions and 193 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public interface IWorkItemService

Task<IReadOnlyList<WorkItemType>> GetWorkItemTypesAsync();

Task TestSettingsAsync(string baseUrl, string projectName, string accessToken);

Task UpdateWorkItemAsync(int workItemId, JsonPatchDocument patchDocument, CancellationToken cancellationToken);
}
}
43 changes: 20 additions & 23 deletions TimVinkemeier.AzureDevOpsToolkit.Core/Services/WorkItemService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,20 @@ public WorkItemService(ISettingsService settingsService)

public async Task<WorkItem> GetWorkItemAsync(int id)
{
var baseUrl = await _settingsService.GetSettingAsync<string>(Setting.OrganisationBaseUrl).ConfigureAwait(false);
var accessToken = await _settingsService.GetSettingAsync<string>(Setting.AzureDevOpsToken).ConfigureAwait(false);
var credentials = new VssBasicCredential(string.Empty, accessToken);
var uri = new Uri(baseUrl);
var client = new WorkItemTrackingHttpClient(uri, credentials);
var client = await CreateClientAsync().ConfigureAwait(false);
return await client.GetWorkItemAsync(id).ConfigureAwait(false);
}

public async Task<IReadOnlyList<WorkItemField>> GetWorkItemFieldsAsync()
{
var baseUrl = await _settingsService.GetSettingAsync<string>(Setting.OrganisationBaseUrl).ConfigureAwait(false);
var accessToken = await _settingsService.GetSettingAsync<string>(Setting.AzureDevOpsToken).ConfigureAwait(false);
var projectName = await _settingsService.GetSettingAsync<string>(Setting.ProjectName).ConfigureAwait(false);
var credentials = new VssBasicCredential(string.Empty, accessToken);
var uri = new Uri(baseUrl);
var client = new WorkItemTrackingHttpClient(uri, credentials);
var client = await CreateClientAsync().ConfigureAwait(false);
return await client.GetFieldsAsync(projectName).ConfigureAwait(false);
}

public async Task<IReadOnlyList<WorkItem>> GetWorkItemsForWiqlQueryAsync(string query)
{
var baseUrl = await _settingsService.GetSettingAsync<string>(Setting.OrganisationBaseUrl).ConfigureAwait(false);
var accessToken = await _settingsService.GetSettingAsync<string>(Setting.AzureDevOpsToken).ConfigureAwait(false);
var credentials = new VssBasicCredential(string.Empty, accessToken);
var uri = new Uri(baseUrl);
var client = new WorkItemTrackingHttpClient(uri, credentials);
var client = await CreateClientAsync().ConfigureAwait(false);
var wiqlQuery = new Wiql
{
Query = query
Expand All @@ -65,22 +53,22 @@ public async Task<IReadOnlyList<WorkItem>> GetWorkItemsForWiqlQueryAsync(string

public async Task<IReadOnlyList<WorkItemType>> GetWorkItemTypesAsync()
{
var baseUrl = await _settingsService.GetSettingAsync<string>(Setting.OrganisationBaseUrl).ConfigureAwait(false);
var accessToken = await _settingsService.GetSettingAsync<string>(Setting.AzureDevOpsToken).ConfigureAwait(false);
var projectName = await _settingsService.GetSettingAsync<string>(Setting.ProjectName).ConfigureAwait(false);
var credentials = new VssBasicCredential(string.Empty, accessToken);
var uri = new Uri(baseUrl);
var client = new WorkItemTrackingHttpClient(uri, credentials);
var client = await CreateClientAsync().ConfigureAwait(false);
return await client.GetWorkItemTypesAsync(projectName).ConfigureAwait(false);
}

public async Task UpdateWorkItemAsync(int workItemId, JsonPatchDocument patchDocument, CancellationToken cancellationToken)
public async Task TestSettingsAsync(string baseUrl, string projectName, string accessToken)
{
var baseUrl = await _settingsService.GetSettingAsync<string>(Setting.OrganisationBaseUrl).ConfigureAwait(false);
var accessToken = await _settingsService.GetSettingAsync<string>(Setting.AzureDevOpsToken).ConfigureAwait(false);
var credentials = new VssBasicCredential(string.Empty, accessToken);
var uri = new Uri(baseUrl);
var client = new WorkItemTrackingHttpClient(uri, credentials);
await client.GetFieldsAsync(projectName).ConfigureAwait(false);
}

public async Task UpdateWorkItemAsync(int workItemId, JsonPatchDocument patchDocument, CancellationToken cancellationToken)
{
var client = await CreateClientAsync().ConfigureAwait(false);
var workItem = await client.GetWorkItemAsync(workItemId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (cancellationToken.IsCancellationRequested)
{
Expand All @@ -89,5 +77,14 @@ public async Task UpdateWorkItemAsync(int workItemId, JsonPatchDocument patchDoc

await client.UpdateWorkItemAsync(patchDocument, (int)workItem.Id, false, false, false, null, cancellationToken).ConfigureAwait(false);
}

private async Task<WorkItemTrackingHttpClient> CreateClientAsync()
{
var baseUrl = await _settingsService.GetSettingAsync<string>(Setting.OrganisationBaseUrl).ConfigureAwait(false);
var accessToken = await _settingsService.GetSettingAsync<string>(Setting.AzureDevOpsToken).ConfigureAwait(false);
var credentials = new VssBasicCredential(string.Empty, accessToken);
var uri = new Uri(baseUrl);
return new WorkItemTrackingHttpClient(uri, credentials);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MvvmCross.Commands;
using System.Threading.Tasks;

using MvvmCross.Logging;
using MvvmCross.Navigation;
using MvvmCross.ViewModels;
Expand All @@ -7,12 +8,18 @@ namespace TimVinkemeier.AzureDevOpsToolkit.Core.ViewModels
{
public abstract class ContentViewBaseViewModel : MvxNavigationViewModel
{
private MvxNotifyTask _busyTask;

protected ContentViewBaseViewModel(IMvxLogProvider logProvider, IMvxNavigationService navigationService)
: base(logProvider, navigationService)
{
BackToRootCommand = new MvxAsyncCommand(() => NavigationService.Navigate<RootViewModel>());
BusyTask = MvxNotifyTask.Create(Task.CompletedTask);
}

public IMvxAsyncCommand BackToRootCommand { get; }
public MvxNotifyTask BusyTask
{
get => _busyTask;
set => SetProperty(ref _busyTask, value);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
Expand All @@ -22,6 +23,7 @@ namespace TimVinkemeier.AzureDevOpsToolkit.Core.ViewModels.SearchAndReplace
public class SearchAndReplaceViewModel : ContentViewBaseViewModel
{
private readonly IMessenger _messenger;
private readonly ISettingsService _settingsService;
private readonly IWorkItemService _workItemService;
private int _currentReplacementCount;
private bool _isRunningReplace;
Expand All @@ -34,21 +36,22 @@ public class SearchAndReplaceViewModel : ContentViewBaseViewModel

private int _totalReplacementCount;

public SearchAndReplaceViewModel(IMvxLogProvider logProvider, IMvxNavigationService navigationService, IWorkItemService workItemService, IMessenger messenger)
: base(logProvider, navigationService)
public SearchAndReplaceViewModel(IMvxLogProvider logProvider, IMvxNavigationService navigationService, IWorkItemService workItemService, IMessenger messenger, ISettingsService settingsService)
: base(logProvider, navigationService)
{
SearchText = "notification";
SearchCommand = new MvxAsyncCommand(
() => SearchAsync(SearchText, SearchItemTypes.Where(f => f.IsSelected).ToList(), SearchFields.Where(f => f.IsSelected).ToList()),
() => !string.IsNullOrWhiteSpace(SearchText) && !IsRunningReplace && SearchItemTypes.Count(f => f.IsSelected) > 0 && SearchFields.Count(f => f.IsSelected) > 0);
ReplaceCommand = new MvxAsyncCommand(ReplaceAsync, () => TotalReplacementCount > 0 && !string.IsNullOrWhiteSpace(ReplaceText) && !IsRunningSearch);
OpenWorkItemCommand = new MvxAsyncCommand<int>(OpenWorkItemAsync, id => id > 0);
_workItemService = workItemService;
_messenger = messenger;
_settingsService = settingsService;
}

public bool AreAllResultsSelected
{
get { return SearchResults?.All(r => r.IsSelected) ?? false; }
get { return SearchResults?.Count > 0 && (SearchResults?.All(r => r.IsSelected) ?? false); }
set
{
foreach (var result in SearchResults)
Expand Down Expand Up @@ -86,6 +89,8 @@ public bool IsRunningSearch
}
}

public IMvxAsyncCommand<int> OpenWorkItemCommand { get; }

public IMvxAsyncCommand ReplaceCommand { get; }

public string ReplaceText
Expand Down Expand Up @@ -184,6 +189,17 @@ private void OnSelectionChanged()
RaisePropertyChanged(nameof(AreAllResultsSelected));
}

private async Task OpenWorkItemAsync(int workItemId)
{
var baseUrl = await _settingsService.GetSettingAsync<string>(Setting.OrganisationBaseUrl).ConfigureAwait(false);
var projectName = await _settingsService.GetSettingAsync<string>(Setting.ProjectName).ConfigureAwait(false);
Process.Start(new ProcessStartInfo
{
UseShellExecute = true,
FileName = $"{baseUrl}/{projectName}/_workitems/edit/{workItemId}/"
});
}

private async Task PopulateSearchFieldsAsync()
{
var workItemTypes = await _workItemService.GetWorkItemTypesAsync().ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;

using MvvmCross.Commands;
using MvvmCross.Logging;
using MvvmCross.Navigation;
using MvvmCross.ViewModels;

using TimVinkemeier.AzureDevOpsToolkit.Core.Services;

Expand All @@ -11,16 +13,19 @@ namespace TimVinkemeier.AzureDevOpsToolkit.Core.ViewModels.Settings
public class SettingsViewModel : ContentViewBaseViewModel
{
private readonly ISettingsService _settingsService;
private readonly IWorkItemService _workItemService;
private string _baseUrl;
private bool _hasChanges = false;
private string _projectName;
private string _saveErrorMessage;
private string _token;

public SettingsViewModel(IMvxLogProvider logProvider, IMvxNavigationService navigationService, ISettingsService settingsService)
: base(logProvider, navigationService)
public SettingsViewModel(IMvxLogProvider logProvider, IMvxNavigationService navigationService, ISettingsService settingsService, IWorkItemService workItemService)
: base(logProvider, navigationService)
{
_settingsService = settingsService;
SaveCommand = new MvxAsyncCommand(SaveSettingsAsync, () => _hasChanges);
_workItemService = workItemService;
}

public string BaseUrl
Expand All @@ -47,6 +52,12 @@ public string ProjectName

public MvxAsyncCommand SaveCommand { get; }

public string SaveErrorMessage
{
get => _saveErrorMessage;
set => SetProperty(ref _saveErrorMessage, value);
}

public string Token
{
get => _token;
Expand All @@ -67,9 +78,27 @@ public override async Task Initialize()

private async Task SaveSettingsAsync()
{
await _settingsService.SetSettingAsync(Setting.AzureDevOpsToken, Token).ConfigureAwait(false);
await _settingsService.SetSettingAsync(Setting.OrganisationBaseUrl, BaseUrl).ConfigureAwait(false);
await _settingsService.SetSettingAsync(Setting.ProjectName, ProjectName).ConfigureAwait(false);
SaveErrorMessage = null;

try
{
BusyTask = MvxNotifyTask.Create(_workItemService.TestSettingsAsync(BaseUrl, ProjectName, Token));
await BusyTask.Task.ConfigureAwait(false);
}
catch (Exception ex)
{
SaveErrorMessage = ex.Message;
_hasChanges = false;
SaveCommand?.RaiseCanExecuteChanged();
return;
}

BusyTask = MvxNotifyTask.Create(Task.WhenAll(
_settingsService.SetSettingAsync(Setting.AzureDevOpsToken, Token),
_settingsService.SetSettingAsync(Setting.OrganisationBaseUrl, BaseUrl),
_settingsService.SetSettingAsync(Setting.ProjectName, ProjectName)));

await BusyTask.Task.ConfigureAwait(false);
_hasChanges = false;
SaveCommand.RaiseCanExecuteChanged();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Markup;

namespace TimVinkemeier.AzureDevOpsToolkit.Converters
{
public class StringToVisibilityConverter : MarkupExtension, IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var val = (string)value;
return string.IsNullOrEmpty(val) ? Visibility.Collapsed : Visibility.Visible;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new InvalidOperationException("This converter cannot convert back");
}

public override object ProvideValue(IServiceProvider serviceProvider)
=> this;
}
}
2 changes: 2 additions & 0 deletions TimVinkemeier.AzureDevOpsToolkit/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
Title="Toolkit for Azure DevOps"
Width="800"
Height="450"
MinWidth="800"
MinHeight="450"
mc:Ignorable="d">
<Grid />
</mvx:MvxWindow>
54 changes: 32 additions & 22 deletions TimVinkemeier.AzureDevOpsToolkit/Views/MainMenuView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@
d:DesignHeight="450"
d:DesignWidth="800"
mc:Ignorable="d">
<mvx:MvxWpfView.Resources>
<DataTemplate x:Key="MainMenuItemTemplate" DataType="{x:Type vms:MainMenuItemViewModel}">
<Button
Padding="12"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Command="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type mvx:MvxWpfView}}, Path=DataContext.NavigateCommand}"
CommandParameter="{Binding}"
Content="{Binding DisplayName}"
FontSize="16"
IsEnabled="{Binding IsSelected, Converter={c:BooleanInversionConverter}}">
<Button.Style>
<Style TargetType="{x:Type Button}">
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="DodgerBlue" />
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</DataTemplate>
</mvx:MvxWpfView.Resources>
<Grid Background="LightGray">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
Expand All @@ -21,27 +45,13 @@
<StackPanel Grid.Row="0" Orientation="Horizontal">
<Image Height="48" Source="/TimVinkemeier.AzureDevOpsToolkit;component/Assets/Logo.png" />
</StackPanel>
<ItemsControl Grid.Row="1" ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vms:MainMenuItemViewModel}">
<Button
Command="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type mvx:MvxWpfView}}, Path=DataContext.NavigateCommand}"
CommandParameter="{Binding}"
Content="{Binding DisplayName}"
IsEnabled="{Binding IsSelected, Converter={c:BooleanInversionConverter}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl Grid.Row="2" ItemsSource="{Binding SecondaryItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vms:MainMenuItemViewModel}">
<Button
Command="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type mvx:MvxWpfView}}, Path=DataContext.NavigateCommand}"
CommandParameter="{Binding}"
Content="{Binding DisplayName}"
IsEnabled="{Binding IsSelected, Converter={c:BooleanInversionConverter}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl
Grid.Row="1"
ItemTemplate="{StaticResource MainMenuItemTemplate}"
ItemsSource="{Binding Items}" />
<ItemsControl
Grid.Row="2"
ItemTemplate="{StaticResource MainMenuItemTemplate}"
ItemsSource="{Binding SecondaryItems}" />
</Grid>
</mvx:MvxWpfView>
Loading

0 comments on commit 44040d8

Please sign in to comment.