Silverlight TreeView Case Study - FAQ Maintenance - Part 2

Introduction

The purpose of this series is to examine an actual implementation of a rich client user experience [UX] centered around the maintenance of a list of Frequently Asked Questions [FAQ].  In Part 1 of this series, we outlined our basic requirements, designed the data model and started building out the administrative application.  In this article we will finish building out the client application by adding Question maintenance, we’ll add some custom validation to Categories and Questions and we’ll build out the public facing component of the application.

 

Step 3 – Build Silverlight Client Administrative Application (continued)

First let’s get a quick refresher of where we are.  We have a UI layout which handles manipulating FAQ categories, including add/update/delete and drag & drop reordering.  We also have enabled change notification to the user when they select a new category and the existing category has changes.  The UI portions of question editing are also included in the XAML of the View, but we haven’t done any work to actually enable editing of Questions in the ViewModel.

 

Adding Questions

One of the features that we’d like to enable in the UI is lazy loading of Question data as the category is expanded.  We have the rudimentary requirements for that already in place, both in the retrieval of QuestionCategory instances along with the categories we are loading, and in the IsExpanded property of the TreeViewItemBase class.  We can couple these two items to present the expansion indicator [using Question Category existence], and to actually perform the load of questions [when IsExpanded changes].

Before we get into loading the questions though, we’ll need to go through and add the Question related properties to the ViewModel(s).  We’ll do that by implementing the QuestionTreeViewItem child ViewModel class, add a Questions property to the CategoryTreeViewItem and add a SelectedQuestion property to the FAQMaintenanceViewModel class, which will be used by the UI to show/hide the Question Edit UI.  Once that is complete, we can move into implementing the Lazy Load operation.

In order to facilitate the Lazy Load, we’ll override the OnExpandedChanged method of the TreeViewItemBase class on CategoryTreeViewItem.  The method will simply look to see if the item’s questions have been loaded.  If not, it will invoke the service to load the question detail by category.  Once the load comes back with data, the QuestionTreeViewItem(s) are built up to represent the Questions in the selected category. 

However, this isn’t quite enough, in order to indicate to the TreeView that there are in fact questions in need of loading, we consult the Category entity’s ‘Questions’ collection.  If there are any entries there, we add a single ‘dummy’ QuestionTreeViewItem [based on a static instance declared on the QuestionTreeViewItem class] to the CategoryTreeViewItem’s Questions collection.  This tells the TreeView that the category can be expanded, and then everything works as expected.

 

Editing Questions

Now that we can display existing questions [and add new ones], we need to handle the edit process for questions, much like we did for categories.  We want to notify the user if changes have been made, and rollback/save as required by the user.  We’ll follow a similar pattern as we used for categories to detect IsSelected changes on the tree view item and prompt the user appropriately.  The catch here is that we’re actually dealing with a hierarchy of objects that represent the question.  The object hierarchy looks like this:

QuestionCategory –> Question –> Answer(s)

Up to this point, we’ve been pretty much directly using the IEditableObject implementation and HasChanges property of the EntityObject base class to track changes.  This was already not quite enough with categories, since EntityObject.HasChanges doesn’t return ‘true’ if the entity is new.  For Questions, the interface is woefully inadequate, as it doesn’t track changes to child objects/collections at all.  So, as we did with CategoryTreeViewItem, we’ll introduce BeginEdit/CancelEdit/EndEdit methods on the ViewModel to handle edit tracking in the ViewModel itself, still relying on the underlying entities for rollback.

Answers need some additional ‘loving’ here as well, since we want to be able to delete an answer and still have the ‘Cancel’ button work to restore it.  Here we’ll leverage the SetterValueBindingHelper again to control the enabled state of the ListBoxItem instances based on an IsDeleted property on the AnswerListItem view model class.  As an aside, since we’re adding quite a bit of logic into the ‘child’ view models, I’ve also chosen to split them out into separate class files to ease maintenance.

With our new and improved Begin/Cancel/End edit changes in place, editing of the questions also works now as you might expect it to.  Removing any newly added Answers [and undeleting any removed ones] on cancel, saving changes on ‘Save’ and prompting for save based on selection changes in the tree.

 

Question Drag & Drop

If you remember from the first part of the series, I mentioned that we had to write some specialized code for Drag & Drop to handle the fact that the hierarchical data template’s ItemsSource property wasn’t being bound quite yet.  Well, that particular code is no longer necessary, as adding our Questions property on the CategoryTreeViewItem class allows the TreeView to know that it can’t drop CategoryTreeViewItems on themselves.  However, we have a similar problem on the Question side.  Before we walk through how to fix it, let’s take a moment to review what the XAML looks like to support the hierarchical binding of Category –> Question.

FAQMaintenanceView.xaml

<sdk:TreeView ItemsSource="{Binding Path=Categories}">
    <!-- Behaviors -->

    <!-- ItemContainerStyle -->

    <sdk:TreeView.ItemTemplate>
        <sdk:HierarchicalDataTemplate ItemsSource="{Binding Path=Questions}">
            <sdk:HierarchicalDataTemplate.ItemTemplate>
                <DataTemplate>

            <!-- Question Display Contents -->

                </DataTemplate>
            </sdk:HierarchicalDataTemplate.ItemTemplate>

            <!-- Category Display Contents -->

        </sdk:HierarchicalDataTemplate>
    </sdk:TreeView.ItemTemplate>
</sdk:TreeView>

 

As you can see above, the ‘Question Display Contents’ and the ‘Category Display Contents’ are what we actually want displayed.  We could have also used templates defined in the Resources section of the page if we wanted, but the key here is that we’ve declared the TreeView’s ItemsSource to be the collection of CategoryTreeViewItems, and the ItemTemplate as a HierarchicalDataTemplate, with an ItemsSource pointing to the Questions property of the CategoryTreeViewItem.  To get the dual display feature between the Category and Question, we defined the HierarchicalDataTemplate’s ItemTemplate with a DataTemplate of how we want the Questions presented.

So that’s all well and good, but the problem is that the TreeViewDragDropTarget doesn’t like TreeView controls to be fixed depth.  We essentially have the same problem that we had before, but now it relates to the question level of the tree rather than the category level.  So, we’re going to have to adapt our Drop handler for the TreeViewDragDropTarget and take over the dropping of questions [category level drops are working fine now, and would continue to work without the existing event handler].  If you were to test the application, you would see that both questions and categories can be dropped onto QuestionTreeViewItem(s).  Categories will show up as ‘empty’, since the templating system doesn’t know how to display them, and questions will actually be rendered correctly, but as children of the question they were dropped upon.

Since we’re on the topic of Drag & Drop, we also wanted the users to be able to have questions which existed in multiple categories.  One nice way to implement this would be to leverage the ‘copy’ feature of drag & drop operations to ‘copy’ a question from one category to another.  We can do this by altering the ‘AllowedSourceEffects’ property of the TreeViewDragDropTarget to be ‘Move, Copy, Scroll’ and we’ll get all the nice drag decorators inserted for us during the drag operation.  However, the TreeViewDragDropTarget doesn’t know how to deal with Copy, so we’ll have to handle the actual copying ourselves.

To address these challenges, we’ll make some alterations to the Drop handler we defined in the last article.  The resulting code looks like this:

FAQMaintenanceViewModel.cs

public void TreeViewItemDropped(object sender, EventArgs args)
{
    // we just want Microsoft.Windows.DragEventArgs here
    Microsoft.Windows.DragEventArgs dragArgs = args as Microsoft.Windows.DragEventArgs;
    if (dragArgs == null) return;

    // determine what type of item we're dragging
    if (!dragArgs.Data.GetDataPresent(typeof(ItemDragEventArgs))) return;
    ItemDragEventArgs itemArgs = dragArgs.Data.GetData(typeof(ItemDragEventArgs)) as ItemDragEventArgs;

    Collection<Selection> itemsDragged = itemArgs.Data as Collection<Selection>;
    if (itemsDragged == null) return;

    // now, where are we going [target]?
    FrameworkElement element = dragArgs.OriginalSource as FrameworkElement;
    if (element == null '' element.DataContext == null) return; // we can't know

    // only care about Question drops [or drops onto question]
    QuestionTreeViewItem questionCat = itemsDragged[0].Item as QuestionTreeViewItem;
    CategoryTreeViewItem catDragged = itemsDragged[0].Item as CategoryTreeViewItem;

    QuestionTreeViewItem destQuest = element.DataContext as QuestionTreeViewItem;

    if (catDragged != null)
    {
        // A category is being moved, are we trying to drop it on a question?
        // If so, get cancel and get out
        if (destQuest != null)
        {
            dragArgs.Effects = DragDropEffects.None;
            dragArgs.Handled = true;
            itemArgs.Handled = true;
            itemArgs.Cancel = true;
            return;
        }

        // otherwise, we're dropping onto a category, so let's just 
        // stop handling it the default behavior will work
        return;
    }

    CategoryTreeViewItem destination = element.DataContext as CategoryTreeViewItem;
    CategoryTreeViewItem source = questionCat.Parent;
    Question question = questionCat.QuestionCategory.Question;

    if (destination == null && element.DataContext is QuestionTreeViewItem)
    {
        // assume they meant the question's parent [for reordering]
        destination = ((QuestionTreeViewItem)element.DataContext).Parent;
    }

    if (destination == null '' (DestinationHasQuestion(destination, question) && source != destination))
    {
        // we don't allow drops on anything other than a category
        // and they can't have the same question twice in the same destination
        dragArgs.Effects = DragDropEffects.None;
        dragArgs.Handled = true;

        itemArgs.Cancel = true;
        itemArgs.Handled = true;
        return;
    }
    else if (DragDropState == DragDropEffects.Copy)
    {
        if (source == destination)
        {
            dragArgs.Effects = DragDropEffects.None;
            dragArgs.Handled = true;

            itemArgs.Cancel = true;
            itemArgs.Handled = true;
            return;
        }
        else
        {
            SuppressSelectionChangeTracking(true);

            // we're copying, let the drop go through, but do the
            // copy at the same time.
            QuestionCategory newQuestCat = new QuestionCategory()
            {
                Category = destination.Category,
                Question = question
            };

            // Create a new question tree view item for the new 
            // category->question link
            QuestionTreeViewItem newQuest = new QuestionTreeViewItem(CurrentContext, newQuestCat, destination);

            int insertionIndex = -1;

            if (destQuest != null)
            {
                insertionIndex = destination.Questions.IndexOf(destQuest);
                if (itemArgs.DragDecoratorContentMouseOffset.Y >= (element.ActualHeight / 2))
                {
                    insertionIndex++;
                }
            }

            // place it in the source list at the index of the currently
            // selected item, so that it gets picked up instead of the
            // originally dragged item
            if (insertionIndex >= 0)
            {
                destination.Questions.Insert(insertionIndex, newQuest);
            }
            else
            {
                destination.Questions.Add(newQuest);
            }

            newQuest.IsSelected = true;

            // mark the action as handled
            dragArgs.Effects = DragDropEffects.Copy;
            dragArgs.Handled = true;
        }
    }
    else if (DragDropState == DragDropEffects.Move)
    {
        SuppressSelectionChangeTracking(true);

        // take the item out of the source
        itemArgs.RemoveDataFromDragSource();

        if (source != destination)
        {
            QuestionCategory newQuestCat = new QuestionCategory()
            {
                Category = destination.Category,
                Question = question
            };

            CurrentContext.QuestionCategories.Remove(questionCat.QuestionCategory);

            questionCat.QuestionCategory = newQuestCat;
            questionCat.Parent = destination;
        }

        int insertionIndex = -1;

        if (destQuest != null)
        {
            insertionIndex = destination.Questions.IndexOf(destQuest);
            if (itemArgs.DragDecoratorContentMouseOffset.Y >= (element.ActualHeight / 2))
            {
                insertionIndex++;
            }
        }

        // place it in the source list at the index of the currently
        // selected item, so that it gets picked up instead of the
        // originally dragged item
        if (insertionIndex >= 0)
        {
            destination.Questions.Insert(insertionIndex, questionCat);
        }
        else
        {
            destination.Questions.Add(questionCat);
        }

        questionCat.IsSelected = true;

        // mark the action as handled
        dragArgs.Effects = DragDropEffects.Move;
        dragArgs.Handled = true;
    }
    else
    {
        // other changes will be handled natively
        SuppressSelectionChangeTracking(true);
    }
}

 

I apologize for the massive code block, but as you can see there is quite a bit to handle [and yes, it could use some refactoring].  First, we want to make sure we’re handling the right type of event, in this case, only those Drop events fired using the Toolkit’s DragEventArgs class [as compared to the core Silverlight class of the same name].  Once we have that information, we gather what is being dragged [from a ViewModel standpoint] and what the item is being dropped onto.  To properly handle all the scenarios, we need to make sure that nothing can become a child of a Question, so if the user is dragging a category onto a question, we just cancel the event [we could also do the logic to, say, move the category below the questions parent, but that’s even more code].  If the user is dragging a question, then we need to know whether this is a ‘copy’ or ‘move’, and where the user dropped it, because chances are they want the question moved/copied close to that point.

Once we have all the information gathered, we proceed to do the Move/Copy [and we suppress selection changed tracking until the final submission is done on the server].  This avoids any categories/questions involved prompting the user for change confirmation during the move/copy operation.  One quick note when you are ‘cancelling’ a drop operation, you must both set the ‘Handled’ property of the DragEventArgs class to true AND set the Effects property to ‘None’, otherwise the operation will continue as if you had done nothing.

 

Custom Validation

With Drag & Drop fully functional, we can turn to our custom validation routines.  These last bits will help finalize our application and bring everything together for our client.  We’ll cover both server and client-side validation, so that we can see exactly what’s happening.

We have three different checks that we are going to implement, the first of which uses identical code on the client and server.  The ‘QuestionHasAnAnswer’ custom validation simply checks that the Question being edited has at least one answer and displays an error message if it does not.  The difficulty here is not getting the validation to happen, but rather surfacing the error to the end user. 

We would like to use the ValidationSummary control provided by the System.Windows.Controls.Data.Input assembly.  Placing the control in the same ‘parent’ container as the ‘Question Edit’ form should work properly.  However, by default, it only looks at BindingValidationError events thrown by FrameworkElement(s) in the same parent container, and there is no such object-level binding in our test form.  To solve this problem, we’ll add a hidden TextBlock element whose Text property is bound to the Question itself, with the NotifyOnValidationErrors binding property set to ‘True’.  This is sufficient to trigger the Validation Summary display as we need.

The second two validations are similar to each other, so I won’t cover them both here.  We’ll look at the ‘CategoryNameIsUnique’ validation as an example of a pattern we can use for our other client/server validators.  The goal is to do as much as possible client-side, and fall back to server-side validation during the actual submission process to avoid any unnecessary round-trips.

CategoryValidator.shared.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.ComponentModel.DataAnnotations;
using FAQMaintenance.Web.Models;

#if SILVERLIGHT
using FAQMaintenance.Web.Services;
#endif

namespace FAQMaintenance.Validators
{
    public static class CategoryValidator
    {
        public static ValidationResult CategoryNameIsUnique(Category category, ValidationContext context)
        {
            IEnumerable<Category> otherCategories = null;

#if !SILVERLIGHT
            using (FAQMaintenanceContainer db = new FAQMaintenanceContainer())
            {
                otherCategories = db.Categories
                                  .Where(i => i.CategoryId != category.CategoryId &&
                                              i.Name.ToLower().Equals(category.Name.ToLower()))
                                  .ToList();
            }
#else
            FAQMaintenanceDomainContext domainContext = context.GetService(typeof(FAQMaintenanceDomainContext)) as FAQMaintenanceDomainContext;
            if (domainContext != null)
            {
               otherCategories = domainContext.Categories
                                 .Where(i => i.CategoryId != category.CategoryId &&
                                             StringComparer.InvariantCultureIgnoreCase.Compare(i.Name, category.Name) == 0)
                                 .ToList();
            }
#endif

            if (otherCategories != null && otherCategories.Count() > 0)
            {
                return new ValidationResult("A category with that name already exists, please choose another name.", new string[] { "Name" });
            }

            return ValidationResult.Success;
        }
    }
}

 

We’ll cover the server-side logic first, we use a new instance of the domain context to do a database lookup for any categories that match the incoming name and which aren’t the category being validated.  We place the results into a list which will be checked in the shared code.  If we find any matches, we’ll attach our error message to the ‘Name’ property and return the validation result.  We use a separate FAQMaintenanceContainer because trying to attach to the current RIA services contextual container can screw up the generated code on the server side [validations are done before the object in question is attached into the server-side context].

When we move to the client-side, things get a little more complicated.  First, in order to avoid a service call to retrieve categories, we are retrieving the current domain context with a call to the ‘GetService’ method of the ValidationContext.  To facilitate this, we need to make a small change in our base view model’s constructor when instantiating the ‘CurrentContext’ property to provide a ‘template validation context’ that the validation subsystem will use when validating entities/properties.  The relevant code looks like this:

BaseViewModel.cs

    protected BaseViewModel()
    {
        _currentContext = new FAQMaintenanceDomainContext();
        _currentContext.ValidationContext = new ValidationContext(
                                                new object(), 
                                                new DomainContextServiceProvider(_currentContext), 
                                                new Dictionary<object, object>() {
                                                    {VALIDATION_CTX_CURRENT_CONTEXT_KEY, _currentContext}
                                                });
    }

 

As you can see, we’re setting up the ValidationContext on our FAQMaintenanceDomainContext with a template instance.  The ‘new object()’ first argument is necessary to avoid a constructor exception, and ends up unused during normal validation.  The second argument is a simple provider that returns the domain context when the associated type is requested, normally you’d define an interface [service] that was returned and ask for the interface, but for this example, I avoided the extra code overhead and returned the context directly.  The final argument is a dictionary of items you want to pass to any validators that will execute.  As you can see in the example, you can also use the Items collection to pass in the current context.  Which method you choose is likely to depend on what you need to provide to your validators, the code attached at the end of the post shows how to use both approaches.

Once we have the context in hand, the validation looks much like it did on the service side, but runs entirely within the context of the client, providing much faster results and avoiding a service call for invalid data.  With the validators in place, it’s time to call our administrative client complete and move on.

 

Step 4 - Build ASP.NET page for FAQ display

With the administrative client complete, we can move on to the last step of building a simple ASP.NET page to display the FAQ data.  In this case, I’ll build out a simple web forms page to integrate with an existing website, but using ASP.NET MVC or the like is just as easy [and sometimes even simpler].  We’ll add in a simple jQuery UI Accordion control to display the categories, and use some simple Javascript to show/hide the answers to the questions.  To facilitate proper ordering of questions and answers, we’ll add some server-side extensions that just return the ordered list of QuestionCategory and Answer objects from the Category and Question respectively.  The code-behind is simply a call to the database that loads all categories, questions and answers in one shot and assigns the result to a Repeater control’s DataSource property.  The rest of the logic is in the ASPX markup and a couple lines of Javascript.  The result looks like this:

FAQ Public ViewFAQ Public View

 

Summary

In this article, we walked through the addition of Question maintenance to the administrative application.  We also covered some basic client and service side validation rules and built out a public interface to display our FAQ data.  The application is fairly simple, but it allowed us to show a complete and functional Silverlight application with a rich administrative user experience.  Improving the public facing experience is just a matter of some clever styling, and the client UI gracefully degrades when JavaScript is unavailable.  We have met all our client requirements and provided the administrative staff with a pleasurable maintenance experience.

The completed code can be found here: FAQMaintenance-Part 2-20100831.zip (900 KB)

 

Conclusion

Over the course of this series of articles, we’ve built a rich client experience for administrative users.  The goal of the case study was to provide a ground up implementation of the desired features using Silverlight, and I think we’ve succeeded.  Clearly there are some things that Silverlight does well, and the Silverlight Tookit’s drag & drop capabilities certainly give us a boost when implementing basic functionality [such as list reordering].

However, enabling advanced Drag & Drop scenarios still requires that we dig a little ‘closer to the metal’ and get our hands dirty with managing the UI interactions.  Luckily, we can do so without resorting to placing a lot of code into the View.  However, we also sacrifice something on the testability front, and I’m still unconvinced that there isn’t a better dividing line that can be drawn between view and view model here to allow that testability story to stay fairly strong.  Hopefully I’ll get a chance to explore the dividing line a little more closely in future articles and derive a solution that feels a bit better.  If you have any ideas or have seen what you see as successful implementations, I’d love to hear from you.

Thanks for reading!