Introduction
I have been waiting for Catel 3.2 to write about some features, especially about the all new modularity stuff (a.k.a. the Prism extension) and how it works in the second part of "Catel creates a perfect combination with Prism". But, always there is an excuse, we are still working on some Prism features and improvements, therefore the second part will have to wait.
So, this post will be about an interesting approach and how transform "untestable" code, from the code-behind, as "testable" view model code. The name is "View Model First". I will try to illustrate it transforming a "View First" implementation into a "View Model First" one.
The problem
"Digesting" the sample codes of Telerik components (WPF/SL) I noticed that some of them, as part of its name, contain the tag "MVVM". The fact is that most of these components have MVVM support but several examples of this suite are written in code-behind. I know it is a demo application but some of the Telerik components are actually written to be used in code-behind, therefore some logic that could be easy to test becomes hard to test.
Let's take a look into the "Tree to Grid Drag" drag and drop example. I will remove some part of the code for simplification.
public partial class Example : System.Windows.Controls.UserControl { public Example() { // Allow dropping into the ListBox and GridView only if the // dragged items are all products: RadDragAndDropManager.AddDropQueryHandler(wishlistView, OnDropQuery); RadDragAndDropManager.AddDropQueryHandler(orderView, OnDropQuery); // Change the drag cue and choose an action for the sucessful drop // in the Wishlist: RadDragAndDropManager.AddDropInfoHandler(wishlistView, OnWishlistDropInfo); // Change the drag cue and choose an action for the sucessful drop // in the Order GridView: RadDragAndDropManager.AddDropInfoHandler(orderView, OnGridViewDropInfo); // Allow dragging of the Wishlist and Order items: RadDragAndDropManager.AddDragQueryHandler(wishlistView, OnWishListDragQuery); RadDragAndDropManager.AddDragQueryHandler(orderView, OnOrderDragQuery); // Handle the case when items are dragged away from the ListBox // and the Order: RadDragAndDropManager.AddDragInfoHandler(wishlistView, OnWishListDragInfo); RadDragAndDropManager.AddDragInfoHandler(orderView, OnOrderDragInfo); } }
You should notice that everything is about the RadDragAndDropManager. This static class needs a control reference to setup the drag and drop stuff. This operation forces us to write code that could be part of the VM code on the code-behind. At least the highlighted code could be part of the logic of the view model of Example control removing the access to the list of elements via ListBox control reference.
private void OnWishlistDropInfo(object sender, DragDropEventArgs e) { System.Windows.Controls.ItemsControl wishlist = e.Options.Destination as System.Windows.Controls.ItemsControl; ICollection draggedItems = e.Options.Payload as ICollection; // Get the drag cu that the TreeView or we have created TreeViewDragCue cue = e.Options.DragCue as TreeViewDragCue; if (e.Options.Status == DragStatus.DropPossible) { // Set a suitable text: cue.DragActionContent = String.Format("Add {0} item{1} to Wishlist", draggedItems.Count, draggedItems.Count > 1 ? "s" : String.Empty); cue.IsDropPossible = true; wishlist.Background = this.Resources["DropPossibleBackground"] as Brush; } else if (e.Options.Status == DragStatus.DropImpossible) { cue.DragActionContent = null; cue.IsDropPossible = false; } else if (e.Options.Status == DragStatus.DropComplete) { IList items = wishlist.ItemsSource as IList; foreach (object draggedItem in draggedItems) { items.Add(draggedItem); } } if (e.Options.Status != DragStatus.DropPossible) { wishlist.Background = new SolidColorBrush(Colors.White); } }
So how to transform the code above into MVVM "compatible" code? That is the problem that I will try to solve in this post.
The solution
The approach to solve this problem is quite simple and has a name "View Model First". The only thing we have to do is visualize the solution from the view model point of view.
The very first steps to re-write the drag and drop example with view model support is to install the Catel.MVVM nuget package, at least for me ;).
Using the Catel naming conventions the Example control will be renamed as ExampleView (now it inherits from Catel.Windows.Controls.UserControl) and the ExampleViewModel will be introduced (inheriting from Catel.MVVM.ViewModelBase).
I will also rename the ProductViewModel and CategoryViewModel as Product and Category and now inherits from DataObjectBase. They are actually model classes instead view model ones.
The ExampleViewModel class looks like this:
public class ExampleViewModel : ViewModelBase { public static readonly PropertyData OrdersProperty = RegisterProperty("Orders", typeof(ObservableCollection<Product>)); public static readonly PropertyData SelectedOrdersProperty = RegisterProperty("SelectedOrders", typeof(ObservableCollection<Product>)); public static readonly PropertyData SelectedWishesProperty = RegisterProperty("SelectedWishes", typeof(ObservableCollection<Product>)); public static readonly PropertyData WishesProperty = RegisterProperty("Wishes", typeof(ObservableCollection<Product>)); public ObservableCollection<Product> Wishes { get { return this.GetValue<ObservableCollection<Product>>(WishesProperty); } set { this.SetValue(WishesProperty, value); } } public ObservableCollection<Product> SelectedWishes { get { return this.GetValue<ObservableCollection<Product>>(SelectedWishesProperty); } set { this.SetValue(SelectedWishesProperty, value); } } public ObservableCollection<Product> Orders { get { return this.GetValue<ObservableCollection<Product>>(OrdersProperty); } set { this.SetValue(OrdersProperty, value); } } public ObservableCollection<Product> SelectedOrders { get { return this.GetValue<ObservableCollection<Product>>(SelectedOrdersProperty); } set { this.SetValue(SelectedOrders, value); } } }
With this transformation we are able to attach or bind the ItemsSource property of the wishlistView (ListBox) and orderView (RadGridView) to Wishes and Orders view model properties respectively. I will also add some logic to fill the SelectedWishes and SelectedOrders properties with an event to command approach to be synchronized with the selected elements on the UI.
At this point I will introduce a service named IDragAndDropService, in order to "map" a property of the view model with a control that have it attached to the ItemsSource property. The interface of this service will be defined as follow:
public interface IDragAndDropService { void AddDragInfoHandler(IViewModel viewModel, Expression<Func<IList>> propertyExpression, EventHandler<DragDropEventArgs> eventHandler, string dependencyPropertyName = "ItemsSourceProperty"); void AddDragQueryHandler(IViewModel viewModel, Expression<Func<IList>> propertyExpression, EventHandler<DragDropQueryEventArgs> eventHandler, string dependencyPropertyName = "ItemsSourceProperty"); void AddDropInfoHandler(IViewModel viewModel, Expression<Func<IList>> propertyExpression, EventHandler<DragDropEventArgs> eventHandler, string dependencyPropertyName = "ItemsSourceProperty"); void AddDropQueryHandler(IViewModel viewModel, Expression<Func<IList>> propertyExpression, EventHandler<DragDropQueryEventArgs> eventHandler, string dependencyPropertyName = "ItemsSourceProperty"); }
The code above was the first thing that came to my mind with the more pure style of the Catel developers ;)
Now during the initialization of the example view model we can use it, resolving it instance from the ServiceLocator, to setup the drag and drop operations.
protected override void Initialize() { var dragAndDropService = this.GetService<IDragAndDropService>(); // Allow dropping into the Wishlist and Order only if the dragged items are all products: dragAndDropService.AddDropQueryHandler(this, () => this.Wishes, this.OnDropQuery); dragAndDropService.AddDropQueryHandler(this, () => this.Orders, this.OnDropQuery); // Choose an action for the sucessful drop in the Wishlist via Wishes property: dragAndDropService.AddDropInfoHandler(this, () => this.Wishes, this.OnWishesDropInfo); // Choose an action for the sucessful drop in the Order GridView via Orders property: dragAndDropService.AddDropInfoHandler(this, () => this.Orders, this.OnOrdersDropInfo); // Allow dragging of the Wishlist and Order items: dragAndDropService.AddDragQueryHandler(this, () => this.Wishes, this.OnWishesDragQuery); dragAndDropService.AddDragQueryHandler(this, () => this.Orders, this.OnOrdersDragQuery); // Handle the case when items are dragged away from the Wishlist and the Order: dragAndDropService.AddDragInfoHandler(this, () => this.Wishes, this.OnWishesDragInfo); dragAndDropService.AddDragInfoHandler(this, () => this.Orders, this.OnOrdersDragInfo); }
Then we are able to move some code from code-behind to our view model just like this:
private void OnWishesDragQuery(object sender, DragDropQueryEventArgs e) { List<ProductModel> productModels = this.SelectedWishes.ToList(); e.QueryResult = productModels.Count > 0; if ((bool)e.QueryResult) { e.Options.Payload = productModels; } } private void OnWishesDragInfo(object sender, DragDropEventArgs e) { if (e.Options.Status == DragStatus.DragComplete) { IEnumerable<ProductModel> productModels = (e.Options.Payload as IEnumerable).Cast<ProductModel>(); foreach (ProductModel draggedItem in productModels) { this.Wishes.Remove(draggedItem); this.SelectedWishes.Remove(draggedItem); } } } private void OnOrdersDropInfo(object sender, DragDropEventArgs e) { if (e.Options.Status == DragStatus.DropComplete) { IEnumerable<ProductModel> productModels = (e.Options.Payload as IEnumerable).Cast<ProductModel>(); foreach (ProductModel draggedItem in productModels) { this.Orders.Add(draggedItem); } } }
The code that we moved from the code-behind is quite ease to test using any mock library, mocking the IDragAndDropService interface.
But we have to implement the RadDragAndDropService to make the UI controls works as we expected.
Implementing the RadDragAndDropService
The only issue here is about get the active view(s) from the view model instance. Here is where Catel 3.3 comes to save us with the introduction of the ViewManager. The logic inside the MVVM logic of Catel carries with the auto-registration of the control and its association with its view models. So the only thing that we have to do is get the active view(s) using the GetViewsOfViewModel method and lookup the first control.
Basically the search is a wide search through the visual tree a returns the first one dependency object that commits the condition (the dependencyProperty is attached to the view model property) and call the RadDragAndDropManager as usual, just like this:
public void AddDragInfoHandler(IViewModel viewModel, Expression<Func<IList>> propertyExpression, EventHandler<DragDropEventArgs> eventHandler, string dependencyPropertyName = "ItemsSourceProperty") { Argument.IsNotNull("viewModel", viewModel); Argument.IsNotNull("propertyExpression", propertyExpression); Argument.IsNotNull("eventHandler", eventHandler); Argument.IsNotNullOrWhitespace("dependencyPropertyName", dependencyPropertyName); Argument.IsOfType("propertyExpression.Body", propertyExpression.Body, typeof(MemberExpression)); var memberExpression = (MemberExpression)propertyExpression.Body; if (memberExpression.Member.MemberType != MemberTypes.Property) { throw new ArgumentException(MemberExpressionShouldBeAPropertyErrorMessage); } IView[] activeViews = this.ServiceLocator.ResolveType<IViewManager>().GetViewsOfViewModel(viewModel); foreach (IView activeView in activeViews) { DependencyObject dependencyObject = FindChildDependencyObjectAttachedToProperty(activeView, propertyExpression, dependencyPropertyName); RadDragAndDropManager.AddDragInfoHandler(dependencyObject, eventHandler); } }
The highlighted code shows you the usage of the ViewManager and the RadDragAndDropManager.
To review the details of the implementation grab the code sample here.
Conclusions
1) There is "always" a way to re-write a "View First" approach as "View Model First" one. Sometimes could be hard but is a win-win situation when you invest time to write custom services because you will gain testability and control of your code. So use the "View Model First" as much as you can.
2) Now with the Catel ViewManager is almost possible write any services that allow you to "interact" with "untestable" components without referencing it directly from your view model code.
3) An update of Prism extension of Catel using the ViewManager will come as soon as possible, planned for Catel 3.4.
4) Writing about code testability remembered me that I have to start to write about my experience using NCover 4, but this will part of my next blog post, I think, at least for now ;).
No comments:
Post a Comment