From 9aea9d582ead1955134442dca1fc68d47ac9b41e Mon Sep 17 00:00:00 2001 From: Marcus Markiewicz <43656407+supermem613@users.noreply.github.com> Date: Mon, 21 Mar 2022 18:27:31 -0400 Subject: [PATCH] Improve Search Experience * Removing un-documented tokenization of search to plain word search (Fixes issues #78); * Change "Searching..." to be at the top and obvious (Fixes issue #79); * Remove confusing empty message while searching; * Increase search max items to 250; * Add actual error found during query to UX; --- .../Windows/Controls/Data/ListViewModel.cs | 10 +- Source/TeamMate/Pages/SearchPage.xaml | 30 ++-- Source/TeamMate/Services/SearchService.cs | 14 +- Source/TeamMate/Utilities/ChaosScenarios.cs | 2 +- Source/TeamMate/Utilities/SearchExpression.cs | 131 ++---------------- .../Utilities/SearchTextHighlighter.cs | 5 +- .../ViewModels/SearchPageViewModel.cs | 12 +- 7 files changed, 52 insertions(+), 152 deletions(-) diff --git a/Source/Foundation/Windows/Controls/Data/ListViewModel.cs b/Source/Foundation/Windows/Controls/Data/ListViewModel.cs index be461e0..bf08292 100644 --- a/Source/Foundation/Windows/Controls/Data/ListViewModel.cs +++ b/Source/Foundation/Windows/Controls/Data/ListViewModel.cs @@ -379,12 +379,20 @@ public void ToggleSort() /// /// Invalidates the no items text. /// - private void InvalidateNoItemsText() + public void InvalidateNoItemsText() { string text = (SearchFilter != null) ? EmptyFilteredCollectionText : EmptyCollectionText; NoItemsText = text; } + /// + /// Clears the no items text. + /// + public void ClearNoItemsText() + { + NoItemsText = String.Empty; + } + /// /// Invalidates the filters. /// diff --git a/Source/TeamMate/Pages/SearchPage.xaml b/Source/TeamMate/Pages/SearchPage.xaml index 216727e..9e094df 100644 --- a/Source/TeamMate/Pages/SearchPage.xaml +++ b/Source/TeamMate/Pages/SearchPage.xaml @@ -25,34 +25,34 @@ - + + - - - - + GetLocalQueries() return this.WindowService.MainWindow.ViewModel.HomePage.TileCollection.Tiles.Select(t => t.Query).ToArray(); } - public async Task VstsSearch(SearchExpression searchExpression, CancellationToken cancellationToken) + public async Task AdoSearch(SearchExpression searchExpression, CancellationToken cancellationToken) { var pc = this.SessionService.Session.ProjectContext; @@ -75,14 +75,14 @@ public async Task VstsSearch(SearchExpression searchExpression, C ProjectName = pc.ProjectName, Wiql = searchExpression.ToVstsWiql(), RequiredFields = pc.RequiredWorkItemFieldNames, - MaxItemsToFetch = MaxItemsFromVsts + MaxItemsToFetch = MaxItemsFromAdo }; await ChaosMonkey.ChaosAsync(ChaosScenarios.VstsSearch); var result = await pc.WorkItemTrackingClient.QueryAsync(query); var workItems = result.WorkItems.Select(wi => CreateWorkItemViewModel(wi)); - var searchResults = workItems.Select(wi => new SearchResult(wi, SearchResultSource.Vsts)).ToArray(); + var searchResults = workItems.Select(wi => new SearchResult(wi, SearchResultSource.Ado)).ToArray(); return new SearchResults(searchResults, result.QueryResult.WorkItems.Count()); } @@ -132,7 +132,7 @@ public class SearchResultSource : IComparable private QueryViewModelBase source; private string sourceName; - public static readonly SearchResultSource Vsts = new SearchResultSource("VSTS"); + public static readonly SearchResultSource Ado = new SearchResultSource("ADO"); public SearchResultSource(QueryViewModelBase source) { @@ -146,7 +146,7 @@ private SearchResultSource(string sourceName) public bool IsLocal { - get { return this != Vsts; } + get { return this != Ado; } } public int CompareTo(object obj) @@ -164,7 +164,7 @@ public int CompareTo(object obj) private int GetRanking() { - if (this == Vsts) + if (this == Ado) { return 1; } diff --git a/Source/TeamMate/Utilities/ChaosScenarios.cs b/Source/TeamMate/Utilities/ChaosScenarios.cs index e618b52..70056d8 100644 --- a/Source/TeamMate/Utilities/ChaosScenarios.cs +++ b/Source/TeamMate/Utilities/ChaosScenarios.cs @@ -10,7 +10,7 @@ public static class ChaosScenarios public static readonly ChaosScenario DownloadAttachment = new ChaosScenario("DownloadAttachment"); public static readonly ChaosScenario SaveWorkItem = new ChaosScenario("SaveWorkItem"); public static readonly ChaosScenario LocalSearch = new ChaosScenario("LocalSearch"); - public static readonly ChaosScenario VstsSearch = new ChaosScenario("VstsSearch"); + public static readonly ChaosScenario VstsSearch = new ChaosScenario("AdoSearch"); public static readonly ChaosScenario GetLinkedChangesetInfo = new ChaosScenario("GetLinkedChangesetInfo"); public static readonly ChaosScenario GetLinkedWorkItemsInfo = new ChaosScenario("GetLinkedWorkItemsInfo"); public static readonly ChaosScenario FileUpload = new ChaosScenario("FileUpload"); diff --git a/Source/TeamMate/Utilities/SearchExpression.cs b/Source/TeamMate/Utilities/SearchExpression.cs index ed7dfda..0534b70 100644 --- a/Source/TeamMate/Utilities/SearchExpression.cs +++ b/Source/TeamMate/Utilities/SearchExpression.cs @@ -11,13 +11,9 @@ namespace Microsoft.Tools.TeamMate.Utilities { public class SearchExpression { - private static readonly Regex SearchRegex = new Regex(@"(?\w+):""(?[^\""]*)""|(?\w+):(?\w*)|(?\w+)", RegexOptions.Compiled); - private const string KeyGroup = "key"; - private const string ValueGroup = "value"; - private SearchExpression() { - this.Tokens = new List(); + this.Tokens = new string[0]; } public static SearchExpression Parse(string text) @@ -29,30 +25,13 @@ public static SearchExpression Parse(string text) if (!String.IsNullOrWhiteSpace(text)) { - var matches = SearchRegex.Matches(text); - foreach (Match item in matches) - { - var valueGroup = item.Groups[ValueGroup]; - - if (valueGroup != null) - { - string value = valueGroup.Value; - if (!String.IsNullOrWhiteSpace(value)) - { - var keyGroup = item.Groups[KeyGroup]; - string key = (keyGroup != null) ? keyGroup.Value : null; - - var token = (!String.IsNullOrWhiteSpace(key)) ? new SearchExpressionToken(key, value) : new SearchExpressionToken(value); - expression.Tokens.Add(token); - } - } - } + expression.Tokens = text.Split(' '); } return expression; } - public IList Tokens { get; private set; } + public string[] Tokens { get; private set; } public string Text { get; private set; } public bool IsEmpty { get { return !Tokens.Any(); } } @@ -93,25 +72,11 @@ public bool Matches(PullRequestRowViewModel item) private Predicate BuildCodeReviewPredicate() { - List plainWords = new List(); List> predicates = new List>(); - foreach (var token in Tokens) - { - string value = token.Value; - if (IsKey(token.Key, "c")) - { - predicates.Add((r) => Matches(r.Reference.CreatedBy.DisplayName, value)); - } - else - { - // If we don't recognize the key token, treat the value as a plain word for matching - plainWords.Add(value); - } - } - if (plainWords.Any()) + if (this.Tokens.Any()) { - var predicate = TextMatcher.MatchAllWordStartsMultiText(plainWords); + var predicate = TextMatcher.MatchAllWordStartsMultiText(this.Tokens); var matcher = new MultiWordMatcher(predicate); predicates.Add((wi) => wi.Matches(matcher)); } @@ -133,37 +98,11 @@ public bool Matches(WorkItemRowViewModel item) private Predicate BuildWorkItemPredicate() { // IMPORTANT: Keep in sync with ToVstsWiql - List plainWords = new List(); List> predicates = new List>(); - foreach (var token in Tokens) - { - string value = token.Value; - if (IsKey(token.Key, "a")) - { - predicates.Add((wi) => Matches(wi.AssignedTo, value)); - } - else if (IsKey(token.Key, "s")) - { - predicates.Add((wi) => Matches(wi.State, value)); - } - else if (IsKey(token.Key, "t")) - { - predicates.Add((wi) => Matches(wi.Type, value)); - } - else if (IsKey(token.Key, "c")) - { - predicates.Add((wi) => Matches(wi.CreatedBy, value)); - } - else - { - // If we don't recognize the key token, treat the value as a plain word for matching - plainWords.Add(value); - } - } - if (plainWords.Any()) + if (this.Tokens.Any()) { - var predicate = TextMatcher.MatchAllWordStartsMultiText(plainWords); + var predicate = TextMatcher.MatchAllWordStartsMultiText(this.Tokens); var matcher = new MultiWordMatcher(predicate); predicates.Add((wi) => wi.Matches(matcher)); } @@ -202,65 +141,13 @@ public string ToVstsWiql() WorkItemQueryBuilder builder = new WorkItemQueryBuilder(); builder.Condition = FieldConditionInfo.CurrentProjectCondition; - List plainWords = new List(); - List> predicates = new List>(); - foreach (var token in Tokens) + if (this.Tokens.Any()) { - string value = token.Value; - if (IsKey(token.Key, "a")) - { - builder.Condition = builder.Condition.And(new FieldConditionInfo(WorkItemConstants.CoreFields.AssignedTo, Operators.Contains, value)); - } - else if (IsKey(token.Key, "s")) - { - builder.Condition = builder.Condition.And(new FieldConditionInfo(WorkItemConstants.CoreFields.State, Operators.Contains, value)); - } - else if (IsKey(token.Key, "t")) - { - builder.Condition = builder.Condition.And(new FieldConditionInfo(WorkItemConstants.CoreFields.WorkItemType, Operators.Contains, value)); - } - else if (IsKey(token.Key, "c")) - { - builder.Condition = builder.Condition.And(new FieldConditionInfo(WorkItemConstants.CoreFields.CreatedBy, Operators.Contains, value)); - } - else - { - // If we don't recognize the key token, treat the value as a plain word for matching - plainWords.Add(value); - } - } - - if (plainWords.Any()) - { - builder.Condition = builder.Condition.And(WorkItemQueryFactory.CreateWordSearchClause(plainWords)); + builder.Condition = builder.Condition.And(WorkItemQueryFactory.CreateWordSearchClause(this.Tokens)); } builder.AddOrderBy(WorkItemConstants.CoreFields.ChangedDate, false); return builder.ToString(); } } - - public class SearchExpressionToken - { - public SearchExpressionToken(string value) - { - this.Value = value; - } - - public SearchExpressionToken(string key, string value) - { - this.Key = key; - this.Value = value; - } - - public string Key { get; private set; } - public string Value { get; private set; } - public bool HasKey { get { return Key != null; } } - - public override string ToString() - { - string escapedValue = (Value.Contains(' ')) ? '\"' + Value + '\"' : Value; - return (Key != null) ? String.Format("{0}:{1}", Key, escapedValue) : escapedValue; - } - } } diff --git a/Source/TeamMate/Utilities/SearchTextHighlighter.cs b/Source/TeamMate/Utilities/SearchTextHighlighter.cs index 6933c6b..5fd6e1e 100644 --- a/Source/TeamMate/Utilities/SearchTextHighlighter.cs +++ b/Source/TeamMate/Utilities/SearchTextHighlighter.cs @@ -39,10 +39,9 @@ static SearchTextHighlighter() public static void Highlight(ListView listView, SearchExpression searchExpression) { - var words = searchExpression.Tokens.Select(t => t.Value).ToArray(); - if (words.Any()) + if (searchExpression.Tokens.Any()) { - Regex regex = TextMatcher.MatchAnyWordStartRegex(words); + Regex regex = TextMatcher.MatchAnyWordStartRegex(searchExpression.Tokens); var listBoxItems = GetHighlightableListBoxItems(listView); foreach (var listBoxItem in listBoxItems) diff --git a/Source/TeamMate/ViewModels/SearchPageViewModel.cs b/Source/TeamMate/ViewModels/SearchPageViewModel.cs index 3744993..ddb9eae 100644 --- a/Source/TeamMate/ViewModels/SearchPageViewModel.cs +++ b/Source/TeamMate/ViewModels/SearchPageViewModel.cs @@ -217,7 +217,7 @@ private static ListViewModel CreateListViewModel(ICollectionView collectionView) listViewModel.Filters.Add(new ListViewFilter("All")); listViewModel.Filters.Add(new ListViewFilter("Work Items", (o) => ((SearchResult)o).Item is WorkItemRowViewModel)); - listViewModel.Filters.Add(new ListViewFilter("Code PullRequests", (o) => ((SearchResult)o).Item is PullRequestRowViewModel)); + listViewModel.Filters.Add(new ListViewFilter("Pull Requests", (o) => ((SearchResult)o).Item is PullRequestRowViewModel)); listViewModel.Filters.Add(new ListViewFilter("Local Only", (o) => ((SearchResult)o).Source.IsLocal)); return listViewModel; @@ -297,6 +297,9 @@ private async void InvalidateSearch() return; } + this.listViewModel.IsFilterByVisible = false; + this.listViewModel.ClearNoItemsText(); + SearchStarted?.Invoke(this, EventArgs.Empty); TaskContext taskContext = new TaskContext(); @@ -309,7 +312,7 @@ private async void InvalidateSearch() var localSearchTask = searchService.LocalSearch(this.SearchExpression, CancellationToken.None); searchTasks.Add(localSearchTask); - var vstsSearchTask = searchService.VstsSearch(this.SearchExpression, CancellationToken.None); + var vstsSearchTask = searchService.AdoSearch(this.SearchExpression, CancellationToken.None); searchTasks.Add(vstsSearchTask); taskContext.Status = "Searching..."; @@ -330,7 +333,7 @@ private async void InvalidateSearch() { if (!taskContext.IsFailed) { - taskContext.Fail("An error occurred while performing the search", ex); + taskContext.Fail("An error occurred while performing the search: " + ex.InnerException.Message, ex); } Log.Warn(ex); @@ -338,6 +341,9 @@ private async void InvalidateSearch() } } + this.listViewModel.IsFilterByVisible = true; + this.listViewModel.InvalidateNoItemsText(); + SearchCompleted?.Invoke(this, EventArgs.Empty); }