From c75ea0cc622fc21839f803ac04b8b83866f49f17 Mon Sep 17 00:00:00 2001 From: Mark van Renswoude Date: Mon, 3 Jan 2022 15:00:30 +0100 Subject: [PATCH 1/4] Implemented DataAnnotation based validation for Tapeti messages Fixed class name missing when selecting from Tapeti class Added Filter to class selection dialog Fixed or removed some minor todo's --- .../Generator/IExampleGenerator.cs | 8 +- .../Rendering/MessageBodyRenderer.cs | 2 - .../Validation/IPayloadValidator.cs | 25 ++++ .../Validation}/TextPosition.cs | 2 +- .../AssemblyParser/AssemblyParser.cs | 133 ++++++++++++++---- .../AssemblyParserStrings.Designer.cs | 90 ++++++++++++ .../AssemblyParser/AssemblyParserStrings.resx | 129 +++++++++++++++++ .../NuGet/NuGetPackageManager.cs | 1 + PettingZoo.Tapeti/PettingZoo.Tapeti.csproj | 11 ++ .../TapetiClassLibraryExampleGenerator.cs | 2 +- PettingZoo.Tapeti/TypeToJObjectConverter.cs | 5 +- .../ClassSelectionStrings.Designer.cs | 18 +++ .../ClassSelection/ClassSelectionStrings.resx | 10 +- .../ClassSelection/ClassSelectionViewModel.cs | 98 ++++++++++++- .../ClassSelection/ClassSelectionWindow.xaml | 27 +++- .../PackageSelectionWindow.xaml | 20 ++- PettingZoo/TODO.md | 3 +- PettingZoo/UI/ErrorHighlightingTransformer.cs | 1 + PettingZoo/UI/Subscribe/SubscribeViewModel.cs | 16 +-- PettingZoo/UI/Subscribe/SubscribeWindow.xaml | 4 +- .../SubscribeWindowStrings.Designer.cs | 2 +- .../UI/Subscribe/SubscribeWindowStrings.resx | 4 +- .../Publisher/PayloadEditorControl.xaml.cs | 10 +- .../PayloadEditorStrings.Designer.cs | 9 ++ .../Tab/Publisher/PayloadEditorStrings.resx | 3 + .../Tab/Publisher/PayloadEditorViewModel.cs | 34 ++++- .../UI/Tab/Publisher/RawPublisherViewModel.cs | 36 ++++- .../RawPublisherViewStrings.Designer.cs | 27 ++++ .../Publisher/RawPublisherViewStrings.resx | 13 +- .../UI/Tab/Publisher/TapetiPublisherView.xaml | 2 +- .../Tab/Publisher/TapetiPublisherView.xaml.cs | 3 + .../Tab/Publisher/TapetiPublisherViewModel.cs | 26 +++- 32 files changed, 687 insertions(+), 87 deletions(-) create mode 100644 PettingZoo.Core/Validation/IPayloadValidator.cs rename {PettingZoo/UI => PettingZoo.Core/Validation}/TextPosition.cs (96%) create mode 100644 PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.Designer.cs create mode 100644 PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.resx diff --git a/PettingZoo.Core/Generator/IExampleGenerator.cs b/PettingZoo.Core/Generator/IExampleGenerator.cs index 95b0e95..f8fdf44 100644 --- a/PettingZoo.Core/Generator/IExampleGenerator.cs +++ b/PettingZoo.Core/Generator/IExampleGenerator.cs @@ -1,4 +1,5 @@ using System; +using PettingZoo.Core.Validation; namespace PettingZoo.Core.Generator { @@ -20,14 +21,11 @@ namespace PettingZoo.Core.Generator public string? Namespace { get; } public string ClassName { get; } - public string FullClassName => !string.IsNullOrEmpty(Namespace) ? Namespace + "." : "" + ClassName; + public string FullClassName => (!string.IsNullOrEmpty(Namespace) ? Namespace + "." : "") + ClassName; } - /* - public interface IValidatingExample : IExample + public interface IValidatingExample : IExample, IPayloadValidator { - bool Validate(string payload, out string validationMessage); } - */ } diff --git a/PettingZoo.Core/Rendering/MessageBodyRenderer.cs b/PettingZoo.Core/Rendering/MessageBodyRenderer.cs index 029be9c..4482da1 100644 --- a/PettingZoo.Core/Rendering/MessageBodyRenderer.cs +++ b/PettingZoo.Core/Rendering/MessageBodyRenderer.cs @@ -18,8 +18,6 @@ namespace PettingZoo.Core.Rendering return contentType != null && ContentTypeHandlers.TryGetValue(contentType, out var handler) ? handler(body) : Encoding.UTF8.GetString(body); - - // ToDo hex output if required } diff --git a/PettingZoo.Core/Validation/IPayloadValidator.cs b/PettingZoo.Core/Validation/IPayloadValidator.cs new file mode 100644 index 0000000..8eb9dcf --- /dev/null +++ b/PettingZoo.Core/Validation/IPayloadValidator.cs @@ -0,0 +1,25 @@ +using System; + +namespace PettingZoo.Core.Validation +{ + public class PayloadValidationException : Exception + { + public TextPosition? ErrorPosition { get; } + + + public PayloadValidationException(string message, TextPosition? errorPosition) : base(message) + { + ErrorPosition = errorPosition; + } + } + + + + public interface IPayloadValidator + { + bool CanValidate(); + + /// + void Validate(string payload); + } +} diff --git a/PettingZoo/UI/TextPosition.cs b/PettingZoo.Core/Validation/TextPosition.cs similarity index 96% rename from PettingZoo/UI/TextPosition.cs rename to PettingZoo.Core/Validation/TextPosition.cs index ed679eb..a024954 100644 --- a/PettingZoo/UI/TextPosition.cs +++ b/PettingZoo.Core/Validation/TextPosition.cs @@ -1,6 +1,6 @@ using System; -namespace PettingZoo.UI +namespace PettingZoo.Core.Validation { public readonly struct TextPosition : IEquatable { diff --git a/PettingZoo.Tapeti/AssemblyParser/AssemblyParser.cs b/PettingZoo.Tapeti/AssemblyParser/AssemblyParser.cs index b7a5072..50c5168 100644 --- a/PettingZoo.Tapeti/AssemblyParser/AssemblyParser.cs +++ b/PettingZoo.Tapeti/AssemblyParser/AssemblyParser.cs @@ -1,44 +1,55 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Runtime.Loader; using Newtonsoft.Json; using PettingZoo.Core.Generator; +using PettingZoo.Core.Validation; namespace PettingZoo.Tapeti.AssemblyParser { public class AssemblyParser : IDisposable { - private readonly AssemblyLoadContext loadContext; + private readonly string[] extraAssembliesPaths; + private AssemblyLoadContext? loadContext; + public AssemblyParser(params string[] extraAssembliesPaths) { - // Using the MetadataLoadContext introduces extra complexity since types can not be compared - // (a string from the loaded assembly does not equal our typeof(string) for example). - // So instead we'll use a regular AssemblyLoadContext. Not ideal, and will probably cause other side-effects - // if we're not careful, but I don't feel like writing a full metadata parser right now. - // If you have a better idea, it's open-source! :-) - loadContext = new AssemblyLoadContext(null, true); - - foreach (var extraAssembly in extraAssembliesPaths.SelectMany(p => Directory.Exists(p) - ? Directory.GetFiles(p, "*.dll") - : Enumerable.Empty())) - { - loadContext.LoadFromAssemblyPath(extraAssembly); - } + this.extraAssembliesPaths = extraAssembliesPaths; } public void Dispose() { - loadContext.Unload(); + loadContext?.Unload(); GC.SuppressFinalize(this); } public IEnumerable GetExamples(Stream assemblyStream) { + if (loadContext == null) + { + /* + Using the MetadataLoadContext introduces extra complexity since types can not be compared + (a string from the loaded assembly does not equal our typeof(string) for example). + So instead we'll use a regular AssemblyLoadContext. Not ideal, and will probably cause other side-effects + if we're not careful, but I don't feel like writing a full metadata parser right now. + If you have a better idea, it's open-source! :-) + */ + loadContext = new AssemblyLoadContext(null, true); + + foreach (var extraAssembly in extraAssembliesPaths.SelectMany(p => Directory.Exists(p) + ? Directory.GetFiles(p, "*.dll") + : Enumerable.Empty())) + { + loadContext.LoadFromAssemblyPath(extraAssembly); + } + } + var assembly = loadContext.LoadFromStream(assemblyStream); foreach (var type in assembly.GetTypes().Where(t => t.IsClass)) @@ -47,7 +58,7 @@ namespace PettingZoo.Tapeti.AssemblyParser - private class TypeExample : IClassTypeExample + private class TypeExample : IClassTypeExample, IValidatingExample { public string AssemblyName => type.Assembly.GetName().Name ?? ""; public string? Namespace => type.Namespace; @@ -55,6 +66,9 @@ namespace PettingZoo.Tapeti.AssemblyParser private readonly Type type; + private bool validationInitialized; + private bool validationAvailable; + public TypeExample(Type type) { @@ -64,20 +78,85 @@ namespace PettingZoo.Tapeti.AssemblyParser public string Generate() { - /* - We can't create an instance of the type to serialize easily, as most will depend on - assemblies not included in the NuGet package, so we'll parse the Type ourselves. - This is still much easier than using MetadataReader, as we can more easily check against - standard types like Nullable. - - The only external dependencies should be the attributes, like [RequiredGuid]. The messaging models - themselves should not inherit from classes outside of their assembly, or include properties - with types from other assemblies. With that assumption, walking the class structure should be safe. - The extraAssemblies passed to TapetiClassLibraryExampleSource can also be used to give it a better chance. - */ var serialized = TypeToJObjectConverter.Convert(type); return serialized.ToString(Formatting.Indented); } + + + public bool CanValidate() + { + return InitializeValidation(); + } + + + public void Validate(string payload) + { + if (!InitializeValidation()) + return; + + // Json exceptions are already handled by the PayloadEditorViewModel + var deserialized = JsonConvert.DeserializeObject(payload, type); + if (deserialized == null) + throw new PayloadValidationException(AssemblyParserStrings.JsonDeserializationNull, null); + + try + { + var validationContext = new ValidationContext(deserialized); + Validator.ValidateObject(deserialized, validationContext, true); + } + catch (ValidationException e) + { + var members = string.Join(", ", e.ValidationResult.MemberNames); + if (!string.IsNullOrEmpty(members)) + throw new PayloadValidationException(string.Format(AssemblyParserStrings.ValidationErrorsMembers, members, e.ValidationResult.ErrorMessage), null); + + throw new PayloadValidationException(string.Format(AssemblyParserStrings.ValidationErrors, e.ValidationResult.ErrorMessage), null); + } + } + + + private bool InitializeValidation() + { + if (validationInitialized) + return validationAvailable; + + // Attempt to create an instance (only works if all dependencies are present, which is not yet + // guaranteed because we aren't fetching NuGet dependencies yet). We're giving it a fighting chance + // by referencing Tapeti.Annotations, System.ComponentModel.Annotations and Tapeti.DataAnnotations.Extensions in + // this class library. + try + { + var instance = Activator.CreateInstance(type); + if (instance != null) + { + // Attributes are only evaluated when requested, so call validation once to give it a better chance to + // detect if we'll be able to validate the message + try + { + var validationContext = new ValidationContext(instance); + Validator.ValidateObject(instance, validationContext, true); + + validationAvailable = true; + } + catch (ValidationException) + { + // The fact that it validated is good enough, this can be expected with an empty object + validationAvailable = true; + } + catch (Exception) + { + // ignored + } + } + } + catch (Exception) + { + // No go, try to create an example without validation + } + + validationInitialized = true; + return validationAvailable; + } } } } diff --git a/PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.Designer.cs b/PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.Designer.cs new file mode 100644 index 0000000..56d9f10 --- /dev/null +++ b/PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PettingZoo.Tapeti.AssemblyParser { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class AssemblyParserStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal AssemblyParserStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PettingZoo.Tapeti.AssemblyParser.AssemblyParserStrings", typeof(AssemblyParserStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to JSON deserialization returned null. + /// + internal static string JsonDeserializationNull { + get { + return ResourceManager.GetString("JsonDeserializationNull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed validation: {0}. + /// + internal static string ValidationErrors { + get { + return ResourceManager.GetString("ValidationErrors", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed validation for {0}: {1}. + /// + internal static string ValidationErrorsMembers { + get { + return ResourceManager.GetString("ValidationErrorsMembers", resourceCulture); + } + } + } +} diff --git a/PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.resx b/PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.resx new file mode 100644 index 0000000..2ed858f --- /dev/null +++ b/PettingZoo.Tapeti/AssemblyParser/AssemblyParserStrings.resx @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + JSON deserialization returned null + + + Failed validation: {0} + + + Failed validation for {0}: {1} + + \ No newline at end of file diff --git a/PettingZoo.Tapeti/NuGet/NuGetPackageManager.cs b/PettingZoo.Tapeti/NuGet/NuGetPackageManager.cs index def5e42..6c66477 100644 --- a/PettingZoo.Tapeti/NuGet/NuGetPackageManager.cs +++ b/PettingZoo.Tapeti/NuGet/NuGetPackageManager.cs @@ -27,6 +27,7 @@ namespace PettingZoo.Tapeti.NuGet public NuGetPackageManager(ILogger logger) { this.logger = logger; + cache = new SourceCacheContext(); sources = new List { diff --git a/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj b/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj index 8234b3e..46db93e 100644 --- a/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj +++ b/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj @@ -9,6 +9,7 @@ + @@ -16,6 +17,7 @@ + @@ -25,6 +27,11 @@ + + True + True + AssemblyParserStrings.resx + True True @@ -46,6 +53,10 @@ + + ResXFileCodeGenerator + AssemblyParserStrings.Designer.cs + PublicResXFileCodeGenerator ClassSelectionStrings.Designer.cs diff --git a/PettingZoo.Tapeti/TapetiClassLibraryExampleGenerator.cs b/PettingZoo.Tapeti/TapetiClassLibraryExampleGenerator.cs index e34ad5d..ef2b4e7 100644 --- a/PettingZoo.Tapeti/TapetiClassLibraryExampleGenerator.cs +++ b/PettingZoo.Tapeti/TapetiClassLibraryExampleGenerator.cs @@ -51,7 +51,7 @@ namespace PettingZoo.Tapeti var progressWindow = new PackageProgressWindow(); progressWindow.Left = windowBounds.Left + (windowBounds.Width - progressWindow.Width) / 2; - progressWindow.Left = windowBounds.Top + (windowBounds.Height - progressWindow.Height) / 2; + progressWindow.Top = windowBounds.Top + (windowBounds.Height - progressWindow.Height) / 2; progressWindow.Show(); Task.Run(async () => diff --git a/PettingZoo.Tapeti/TypeToJObjectConverter.cs b/PettingZoo.Tapeti/TypeToJObjectConverter.cs index 5742475..2bb652f 100644 --- a/PettingZoo.Tapeti/TypeToJObjectConverter.cs +++ b/PettingZoo.Tapeti/TypeToJObjectConverter.cs @@ -43,9 +43,6 @@ namespace PettingZoo.Tapeti foreach (var propertyInfo in classType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { - // Note: unfortunately we can not call GetCustomAttributes here for now, as that would - // trigger assemblies not included in the package to be loaded, which may not exist - var value = TypeToJToken(propertyInfo.PropertyType, newTypesEncountered); result.Add(propertyInfo.Name, value); } @@ -62,6 +59,8 @@ namespace PettingZoo.Tapeti actualType = equivalentType; + // TODO check for JsonConverter attribute? doubt we'll be able to generate a nice value for it, but at least we can provide a placeholder + // String is also a class if (actualType == typeof(string)) return ""; diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.Designer.cs b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.Designer.cs index e3ccd23..799f6d4 100644 --- a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.Designer.cs +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.Designer.cs @@ -78,6 +78,24 @@ namespace PettingZoo.Tapeti.UI.ClassSelection { } } + /// + /// Looks up a localized string similar to Only classes ending in "Message". + /// + public static string CheckboxMessageOnly { + get { + return ResourceManager.GetString("CheckboxMessageOnly", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter. + /// + public static string LabelFilter { + get { + return ResourceManager.GetString("LabelFilter", resourceCulture); + } + } + /// /// Looks up a localized string similar to Select class. /// diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.resx b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.resx index 9c4c9a0..559cf46 100644 --- a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.resx +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.resx @@ -112,10 +112,10 @@ 2.0 - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Cancel @@ -123,6 +123,12 @@ Select + + Only classes ending in "Message" + + + Filter + Select class diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionViewModel.cs b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionViewModel.cs index 5cecfbb..51f8623 100644 --- a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionViewModel.cs +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionViewModel.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Reactive.Linq; +using System.Threading; using System.Windows.Input; using PettingZoo.Core.Generator; using PettingZoo.WPF.ViewModel; @@ -13,8 +17,34 @@ namespace PettingZoo.Tapeti.UI.ClassSelection private BaseClassTreeItem? selectedItem; private readonly DelegateCommand selectCommand; + private string filterText = ""; + private bool filterMessageOnly = true; + private ObservableCollection filteredExamples; + public ObservableCollection Examples { get; } = new(); + public string FilterText + { + get => filterText; + set => SetField(ref filterText, value); + } + + + public bool FilterMessageOnly + { + get => filterMessageOnly; + set => SetField(ref filterMessageOnly, value); + } + + public ObservableCollection FilteredExamples + { + get => filteredExamples; + + [MemberNotNull(nameof(filteredExamples))] + set => SetField(ref filteredExamples!, value); + } + + public BaseClassTreeItem? SelectedItem { get => selectedItem; @@ -39,6 +69,29 @@ namespace PettingZoo.Tapeti.UI.ClassSelection selectCommand = new DelegateCommand(SelectExecute, SelectCanExecute); TreeFromExamples(examples); + + Observable.FromEventPattern( + h => PropertyChanged += h, + h => PropertyChanged -= h) + .Where(e => e.EventArgs.PropertyName is nameof(FilterText) or nameof(FilterMessageOnly)) + .Throttle(TimeSpan.FromMilliseconds(250)) + .ObserveOn(SynchronizationContext.Current!) + .Subscribe(_ => UpdateFilteredExamples()); + + UpdateFilteredExamples(); + } + + + [MemberNotNull(nameof(filteredExamples))] + private void UpdateFilteredExamples() + { + if (!FilterMessageOnly && string.IsNullOrWhiteSpace(FilterText)) + FilteredExamples = Examples; + + FilteredExamples = new ObservableCollection(Examples + .Select(i => i.Filter(FilterText, FilterMessageOnly)) + .Where(i => i != null) + .Cast()); } @@ -109,7 +162,7 @@ namespace PettingZoo.Tapeti.UI.ClassSelection } - public class BaseClassTreeItem + public abstract class BaseClassTreeItem { private readonly SortedSet children = new(new BaseClassTreeItemComparer()); @@ -117,7 +170,7 @@ namespace PettingZoo.Tapeti.UI.ClassSelection public IReadOnlyCollection Children => children; - public BaseClassTreeItem(string name) + protected BaseClassTreeItem(string name) { Name = name; } @@ -127,6 +180,9 @@ namespace PettingZoo.Tapeti.UI.ClassSelection { children.Add(item); } + + + public abstract BaseClassTreeItem? Filter(string filterText, bool messageOnly); } @@ -142,6 +198,32 @@ namespace PettingZoo.Tapeti.UI.ClassSelection Name = string.IsNullOrEmpty(parentFolderName) ? Name : parentFolderName + "." + Name; return this; } + + + public override BaseClassTreeItem? Filter(string filterText, bool messageOnly) + { + var childFilterText = filterText; + + // If the folder name matches, include everything in it (...that matches messageOnly) + if (!string.IsNullOrWhiteSpace(filterText) && Name.Contains(filterText, StringComparison.CurrentCultureIgnoreCase)) + childFilterText = ""; + + var filteredChildren = Children + .Select(c => c.Filter(childFilterText, messageOnly)) + .Where(c => c != null) + .Cast(); + + var result = new NamespaceFolderClassTreeItem(Name); + var hasChildren = false; + + foreach (var filteredChild in filteredChildren) + { + result.AddChild(filteredChild); + hasChildren = true; + } + + return hasChildren ? result : null; + } } @@ -154,6 +236,18 @@ namespace PettingZoo.Tapeti.UI.ClassSelection { Example = example; } + + + public override BaseClassTreeItem? Filter(string filterText, bool messageOnly) + { + // Assumes examples don't have child items, so no further filtering is required + if (messageOnly && !Name.EndsWith(@"Message", StringComparison.CurrentCultureIgnoreCase)) + return null; + + return string.IsNullOrWhiteSpace(filterText) || Name.Contains(filterText, StringComparison.CurrentCultureIgnoreCase) + ? this + : null; + } } diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionWindow.xaml b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionWindow.xaml index c692ef6..f9d022b 100644 --- a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionWindow.xaml +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionWindow.xaml @@ -12,7 +12,8 @@ Width="800" ResizeMode="CanResizeWithGrip" WindowStartupLocation="CenterOwner" - d:DataContext="{d:DesignInstance classSelection:DesignTimeClassSelectionViewModel, IsDesignTimeCreatable=True}"> + d:DataContext="{d:DesignInstance classSelection:DesignTimeClassSelectionViewModel, IsDesignTimeCreatable=True}" + FocusManager.FocusedElement="{Binding ElementName=DefaultFocus}"> + + @@ -68,7 +73,7 @@ - + @@ -82,6 +87,11 @@ + + + @@ -116,11 +126,11 @@ - +