From deab6bc884b9eef9eddb210e79ca74a7df35290a Mon Sep 17 00:00:00 2001 From: Tim Vinkemeier Date: Sun, 6 Sep 2020 17:25:27 +0200 Subject: [PATCH] Minor UX improvements, settings validation --- .../Services/IWorkItemService.cs | 2 + .../Services/WorkItemService.cs | 43 ++- .../ViewModels/ContentViewBaseViewModel.cs | 13 +- .../SearchAndReplaceViewModel.cs | 24 +- .../ViewModels/Settings/SettingsViewModel.cs | 41 ++- .../Converters/StringToVisibilityConverter.cs | 25 ++ .../MainWindow.xaml | 2 + .../Views/MainMenuView.xaml | 54 +-- .../Views/SearchAndReplaceView.xaml | 319 ++++++++++-------- .../Views/SearchAndReplaceView.xaml.cs | 10 +- .../Views/SettingsView.xaml | 40 ++- 11 files changed, 380 insertions(+), 193 deletions(-) create mode 100644 TimVinkemeier.AzureDevOpsToolkit/Converters/StringToVisibilityConverter.cs diff --git a/TimVinkemeier.AzureDevOpsToolkit.Core/Services/IWorkItemService.cs b/TimVinkemeier.AzureDevOpsToolkit.Core/Services/IWorkItemService.cs index 0709421..07276bf 100644 --- a/TimVinkemeier.AzureDevOpsToolkit.Core/Services/IWorkItemService.cs +++ b/TimVinkemeier.AzureDevOpsToolkit.Core/Services/IWorkItemService.cs @@ -17,6 +17,8 @@ public interface IWorkItemService Task> GetWorkItemTypesAsync(); + Task TestSettingsAsync(string baseUrl, string projectName, string accessToken); + Task UpdateWorkItemAsync(int workItemId, JsonPatchDocument patchDocument, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/TimVinkemeier.AzureDevOpsToolkit.Core/Services/WorkItemService.cs b/TimVinkemeier.AzureDevOpsToolkit.Core/Services/WorkItemService.cs index 35f60b8..a6a1ad5 100644 --- a/TimVinkemeier.AzureDevOpsToolkit.Core/Services/WorkItemService.cs +++ b/TimVinkemeier.AzureDevOpsToolkit.Core/Services/WorkItemService.cs @@ -22,32 +22,20 @@ public WorkItemService(ISettingsService settingsService) public async Task GetWorkItemAsync(int id) { - var baseUrl = await _settingsService.GetSettingAsync(Setting.OrganisationBaseUrl).ConfigureAwait(false); - var accessToken = await _settingsService.GetSettingAsync(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> GetWorkItemFieldsAsync() { - var baseUrl = await _settingsService.GetSettingAsync(Setting.OrganisationBaseUrl).ConfigureAwait(false); - var accessToken = await _settingsService.GetSettingAsync(Setting.AzureDevOpsToken).ConfigureAwait(false); var projectName = await _settingsService.GetSettingAsync(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> GetWorkItemsForWiqlQueryAsync(string query) { - var baseUrl = await _settingsService.GetSettingAsync(Setting.OrganisationBaseUrl).ConfigureAwait(false); - var accessToken = await _settingsService.GetSettingAsync(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 @@ -65,22 +53,22 @@ public async Task> GetWorkItemsForWiqlQueryAsync(string public async Task> GetWorkItemTypesAsync() { - var baseUrl = await _settingsService.GetSettingAsync(Setting.OrganisationBaseUrl).ConfigureAwait(false); - var accessToken = await _settingsService.GetSettingAsync(Setting.AzureDevOpsToken).ConfigureAwait(false); var projectName = await _settingsService.GetSettingAsync(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(Setting.OrganisationBaseUrl).ConfigureAwait(false); - var accessToken = await _settingsService.GetSettingAsync(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) { @@ -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 CreateClientAsync() + { + var baseUrl = await _settingsService.GetSettingAsync(Setting.OrganisationBaseUrl).ConfigureAwait(false); + var accessToken = await _settingsService.GetSettingAsync(Setting.AzureDevOpsToken).ConfigureAwait(false); + var credentials = new VssBasicCredential(string.Empty, accessToken); + var uri = new Uri(baseUrl); + return new WorkItemTrackingHttpClient(uri, credentials); + } } } \ No newline at end of file diff --git a/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/ContentViewBaseViewModel.cs b/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/ContentViewBaseViewModel.cs index ca94f29..3ac771b 100644 --- a/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/ContentViewBaseViewModel.cs +++ b/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/ContentViewBaseViewModel.cs @@ -1,4 +1,5 @@ -using MvvmCross.Commands; +using System.Threading.Tasks; + using MvvmCross.Logging; using MvvmCross.Navigation; using MvvmCross.ViewModels; @@ -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()); + BusyTask = MvxNotifyTask.Create(Task.CompletedTask); } - public IMvxAsyncCommand BackToRootCommand { get; } + public MvxNotifyTask BusyTask + { + get => _busyTask; + set => SetProperty(ref _busyTask, value); + } } } \ No newline at end of file diff --git a/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/SearchAndReplace/SearchAndReplaceViewModel.cs b/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/SearchAndReplace/SearchAndReplaceViewModel.cs index 23d6652..fd3d80e 100644 --- a/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/SearchAndReplace/SearchAndReplaceViewModel.cs +++ b/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/SearchAndReplace/SearchAndReplaceViewModel.cs @@ -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; @@ -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; @@ -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(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) @@ -86,6 +89,8 @@ public bool IsRunningSearch } } + public IMvxAsyncCommand OpenWorkItemCommand { get; } + public IMvxAsyncCommand ReplaceCommand { get; } public string ReplaceText @@ -184,6 +189,17 @@ private void OnSelectionChanged() RaisePropertyChanged(nameof(AreAllResultsSelected)); } + private async Task OpenWorkItemAsync(int workItemId) + { + var baseUrl = await _settingsService.GetSettingAsync(Setting.OrganisationBaseUrl).ConfigureAwait(false); + var projectName = await _settingsService.GetSettingAsync(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); diff --git a/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/Settings/SettingsViewModel.cs b/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/Settings/SettingsViewModel.cs index c733dd0..1c32431 100644 --- a/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/Settings/SettingsViewModel.cs +++ b/TimVinkemeier.AzureDevOpsToolkit.Core/ViewModels/Settings/SettingsViewModel.cs @@ -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; @@ -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 @@ -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; @@ -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(); } diff --git a/TimVinkemeier.AzureDevOpsToolkit/Converters/StringToVisibilityConverter.cs b/TimVinkemeier.AzureDevOpsToolkit/Converters/StringToVisibilityConverter.cs new file mode 100644 index 0000000..0ca94ba --- /dev/null +++ b/TimVinkemeier.AzureDevOpsToolkit/Converters/StringToVisibilityConverter.cs @@ -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; + } +} \ No newline at end of file diff --git a/TimVinkemeier.AzureDevOpsToolkit/MainWindow.xaml b/TimVinkemeier.AzureDevOpsToolkit/MainWindow.xaml index 321a624..e6babce 100644 --- a/TimVinkemeier.AzureDevOpsToolkit/MainWindow.xaml +++ b/TimVinkemeier.AzureDevOpsToolkit/MainWindow.xaml @@ -9,6 +9,8 @@ Title="Toolkit for Azure DevOps" Width="800" Height="450" + MinWidth="800" + MinHeight="450" mc:Ignorable="d"> \ No newline at end of file diff --git a/TimVinkemeier.AzureDevOpsToolkit/Views/MainMenuView.xaml b/TimVinkemeier.AzureDevOpsToolkit/Views/MainMenuView.xaml index 3e15586..665b282 100644 --- a/TimVinkemeier.AzureDevOpsToolkit/Views/MainMenuView.xaml +++ b/TimVinkemeier.AzureDevOpsToolkit/Views/MainMenuView.xaml @@ -12,6 +12,30 @@ d:DesignHeight="450" d:DesignWidth="800" mc:Ignorable="d"> + + + + + @@ -21,27 +45,13 @@ - - - -