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);
}