Saturday, August 25, 2007

Smart Client (SCSF) Membership App - Views

This post is part of a series which discusses the journey I took building a smart client membership application using Microsoft's Smart Client Software Factory (SCSF). You can navigate the entire series from here.

Views are probably the easiest artifact for smart client developers to understand and build. The fact that views are implemented as user controls and that there are recipes for generating them only lend to their ease of use. Finally, there is a lot of documentation and good diagrams about views to peruse. Even the model-view-presenter (MVP) paradigm is pretty well known or at least accessible with all the information available. What is perhaps a bit trickier is how to make multiple views work in concert within the SCSF architecture of shells, work items, and controllers.

GS_PersonTabView In this article, I will be dissecting the Person views show in the left screenshot. The Members project treeMemberProjectTree shown on the right currently contains four view folders - two for Person and two for Household. These views were generated using the "Add View (with presenter)" recipe and then customized as needed. For the list views, I used the SmartPart Quickstart that comes with the SCSF installation as a basis for my lists. I chose the Windows.Forms.ListView control as I want to be able to show a richer user interface that's more intuitive such as showing each household in a tree view with the individual persons listed as sub-items underneath the household. I'd also like as much "free-form" navigation as possible. For example, selecting a person from the aforementioned tree-view will display that person's information even though the user has currently navigated to households. The relationship between people and households is so great that it doesn't make sense from an application perspective to force the user to always navigate by going to the menu or toolbar. Seeing a list of households and the people associated, it's natural to want to click on the person and navigate to manage that person's information. This kind of flexibility is more easily met using the ListView as the basis of the work.

The PersonListView shown running in the above screenshot is displayed via a command handler that is wired up to the 'People" menu item and toolbar button. Here's the code that causes the view to be displayed:

  [CommandHandler(CommandNames.ManagePersons)]
public void ManagePersonsHandler(object sender, EventArgs e)
{
ShowViewInWorkspace<PersonListView>(WorkspaceNames.LeftWorkspace);
}

The MVP pattern is implemented for you by the recipe-generated code. When the view is created a presenter instance is also created and made available as a property of the view class:

  [SmartPart]
public partial class PersonListView
{
/// <summary>
/// Sets the presenter. The dependency injection system
/// will automatically create a new presenter for you.
/// </summary>
[CreateNew]
public PersonListViewPresenter Presenter
{
set
{
_presenter = value;
_presenter.View = this;
}
}
}

Following the MVP pattern, the view should only be concerned with displaying the information it is given without knowledge of anything else the application might have available to it or be doing. To help with this the view's OnLoad() method signals that it is "ready" by calling over to the presenter's OnViewReady() method:

  protected override void OnLoad(EventArgs e)
{
_presenter.OnViewReady();
base.OnLoad(e);
}

The presenter's code is where the action takes place - it "contacts" the model, in this case by calling the _memberSvc.GetPersonList() method, to get the data and then gives that data to the view via the View.SetPersons() method:

  [ServiceDependency]
public MemberService MemberService
{
set { _memberSvc = value; }
}

/// <summary>
/// This method is a placeholder that will be called
/// by the view when it has been loaded.
/// </summary>
public override void OnViewReady()
{
base.OnViewReady();
LoadPersons();
}

private void LoadPersons()
{
PersonListItemCollection persons = _memberSvc.GetPersonList();
if (persons != null)
{
View.SetPersons(persons);
}
}

Note that the MemberService is filling the role of the model and will be discussed in another article. The view's SetPersons() method was taken almost verbatim from the SmartPart QuickStart and is responsible for taking data and rendering it:

  #region IPersonListView Members

void IPersonListView.SetPersons(PersonListItemCollection persons)
{
Guard.ArgumentNotNull(persons, "persons");
InnerSetPersons(persons);
}

#endregion

private void InnerSetPersons(PersonListItemCollection persons)
{
List<ListViewItem> toRemove = new List<ListViewItem>();

foreach (ListViewItem item in _personListView.Items)
{
PersonListItem listPerson = PersonMapper.FromListViewItem(item);
int index = persons.IndexOfId(listPerson.ContactId.Value);
if (index > -1)
toRemove.Add(item);
else
persons.Remove(persons[index]);
}

toRemove.ForEach(delegate(ListViewItem lvi) { _personListView.Items.Remove(lvi); });
foreach (PersonListItem person in persons)
_personListView.Items.Add(PersonMapper.ToListViewItem(person));
}

The next part to look at is how users interact with the views. When a person shown in the list view is clicked, the right-hand workspace should show that person's details. To accomplish this we must fall back on the "formula" for MVP - the view is only concerned with rendering the data. The PersonListView therefore does not "know" what to do with the click - the selected person is displayed elsewhere (outside the view) by another part of the application. To accomplish this separation of concerns, the initial Windows Forms event (that is, the on click handler) needs to be translated into a CAB Event that is published to the world (okay, the rest of the application) to be handled elsewhere:

  private void personListView_SelectedIndexChanged(object sender, EventArgs e)
{
if (_personListView.SelectedItems.Count == 0)
return;

//Pass the event directly to the presenter to handle
PersonListItem person = PersonMapper.FromListViewItem(_personListView.SelectedItems[0]);
_presenter.ShowPersonDetails(person.ContactId.Value);
}

Remember the MVP pattern has all three pieces working together so the view simply passes the action on to the presenter to take care of by calling the presenter's ShowPersonDetails() method. It is here in the presenter's method that the user's action is converted to a CAB event and published:

  [EventPublication(EventTopicNames.ShowPersonDetails, PublicationScope.Global)]
public event EventHandler<EventArgs<int>> ShowPersonDetailsHandler;

public void ShowPersonDetails(int contactId)
{
//To maintain a separation of concerns publish an event to be handled elsewhere
if (ShowPersonDetailsHandler != null)
ShowPersonDetailsHandler(this, new EventArgs<int>(contactId));
}

We know we want to display the person's details on the right-hand workspace but the trick is where to place that code and how to make it happen. I jumped the gun in the previous article about work items by showing the logic for creating a person work item and kicking it off. The full code for this is in the ModuleController.cs class. The reason it is there is one of logical grouping and hierarchy of responsibility/ownership. The coarse hierarchy is Shell -> Members_Module -> Child_Work_Items and since the PersonListView is displayed by the module during the menu command handling event, it is the module that "owns" the view (actually, "owns" the model-view-presenter trio). Therefore, when the presenter raises the ShowPersonDetails event, it is best to put that code as close to where it is related while going back up the "chain". Since the Members module contains all things relating to members and since the PersonListView is owned and managed by that module (actually, that module's ModuleController) then the next step back up the chain from the presenter is the ModuleController - remember that the module controller is a specialized work item. So the proper place to scope the event (see previous post about work items being a scoping container) is the next work item up the chain which is also the work item for the entire module. Here is the code in the ModuleController.cs that responds to the CAB Event:

  [EventSubscription(EventTopicNames.ShowPersonDetails, ThreadOption.UserInterface)]
public void OnShowPersonDetails(object sender, EventArgs<int> eventArgs)
{
int personId = eventArgs.Data;

//Create a key for the workitem so we can check
//later if the workitem has already been created.
string key = "Person" + personId;

PersonWorkItem workItem = WorkItem.WorkItems.Get<PersonWorkItem>(key);

if (workItem == null)
{
// add a new work item representing this instance to
// to the collection of work items in this module
workItem = WorkItem.WorkItems.AddNew<PersonWorkItem>(key);

// add a new detail view smart part to the collection of smart parts
workItem.SmartParts.AddNew<PersonDetailView>("PersonDetailView");
workItem.SmartParts.AddNew<PersonChannelsView>("PersonChannelsView");

workItem.Run(personId, WorkItem.Workspaces[WorkspaceNames.RightWorkspace]);
}
else
workItem.Activate();
}

At first there appears to be a lot going on but it boils down to creating a PersonWorkItem instance for the selected person and then "running" the work item that is, telling it the workspace to display its views in. One thing I discovered the hard way through lots of Google "digging" is that if you're using SmartPartPlaceholders on a view you need to ensure the smart parts that are going to be displayed in the placeholders already exist in the SmartParts collection before the view is shown. This is why I'm manually creating the detail and contact channel views and adding them to the collections before the work item's Run method is called. Here is what the PersonWorkItem's Run() method looks like:

  public void Run(int PersonId, IWorkspace ContentWorkspace)
{
Run();

// save the Person data in the work item
Person person = _memberSvc.GetPerson(personId);
Items.Add(person, "Person");

// create a person tab view and show it
tabView = SmartParts.AddNew<PersonTabView>();
TabSmartPartInfo tabInfo = new TabSmartPartInfo();
tabInfo.Title = person.FullName;
tabInfo.ActivateTab = true;
ContentWorkspace.Show(tabView, tabInfo);
}

As you can see above, the person id is passed in and used to retrieve the Person instance. Note that this person instance data is stored in the work item's Items collection - not in it's State. Unfortunately, lots of examples show how easy it is to use the [State] attribute on a property to automagically copy the data from the work item state into the view's property member. This is bad. The state bag is for persistence of data across invocations of the application and it is type-less. That means everything has to be cast and value types are boxed/unboxed which is not the best performing thing to do. The Items collection is a .NET 2.0 generic collection that was specifically designed for holding things, a.k.a. "items". It is easily accessed in views and presenters using the WorkItem property that refers to the work item they are contained in. I confess that I did the bad thing first, using the same examples and documentation but it became harder to follow and manage and I soon found several posts saying using the State was bad, okay it wasn't optimal or necessary if you weren't planning on saving it. Instead of coding your property using the [State] attribute you should do this:

  public Person Person
{
get { return WorkItem.Items.Get<Schema.Person>("Person"); }
}

In summary, I've talked about views, the MVP pattern and how it's implemented, where to put logic, how to convert user events into CAB Events and "catch" them elsewhere, how the work items are brought into play and how to bring it all together. In the next article I'll go into details about the service layer and how it's implemented.

Original post

No comments:

Post a Comment