Applications implementing an MDI user interface can display any number of child forms. Therefore, there should not necessarily be a reason to limit the type of forms to only those packaged with the application. Creating a plug-in framework can extend the functionality of an enterprise far beyond its original intent. In many cases, supporting customization within an enterprise application further extends its reach and adoption rate. This is where the MVC architecture pulls everything together. The controller directs the application flow, the model retrieves and updates the data, and the view presents everything to the user.
The controller is the most complex element of an MVC architecture. Its responsibilities must include managing multiple viewers and exchanging business objects, all based on user interaction.
As Figure 8-6 illustrates, the implementation of the controller puts it in the middle of the application. Its function is based on configuration information that identifies what business objects and what actions trigger specific views to be displayed. For example, an Issue business object and a View action might display one form while a User object and a New action display a completely different form. This outlines a specific need for mapping the relationships between business objects, actions, and forms.
The controller is only as strong as its mappings. To keep the controller flexible, it should not know any specifics about what business objects it is working with or which forms should be displayed. It should only know that a combination of business objects and actions results in the display of forms. It is expected that the forms will know what to do with the business objects they receive.
The first step is to add a new Class Library project to the IssueTracker solution, named ApplicationFramework . Controller- related code needs to reside in a separate assembly because references can only be made to .dll files, not .exe files. Because the controller will need to manage a mapping of objects and actions to forms, there needs to be a structure for capturing such information. Listing 8-2 outlines the ObjectMappingEntry class, which represents a single action mapping. The controller will manage an array of ObjectMappingEntry objects to select the correct form in response to user action.
public class ObjectMappingEntry { string _Viewer; string _BusinessObjectName; Controller.ControllerActions _Action; public string Viewer { set { _Viewer = value; } get { return _Viewer; } } public string BusinessObjectName { set { _BusinessObjectName = value; } get { return _BusinessObjectName; } } public Controller.ControllerActions Action { set { _Action = value; } get { return _Action; } } }
The ObjectMappingEntry class is essentially a simple structure of three data members and supporting get and set accessor methods . Each data member is needed to help the controller take action. The BusinessObjectName property specifies which business object this mapping entry applies to, such as Issue or UserCollection. The Action property is based on the ControllerActions enumeration defined within the Controller object. It specifies which action is being taken. The Viewer property simply labels which MDI child form needs to be displayed in response to a specific business object and action.
Note | Because viewers may potentially be loaded from foreign assemblies, the Viewer property must be fully qualified with its namespace, such as WinUI.FormIssueSummary. |
With the ObjectMappingEntry class defined, you can implement the Controller class to take action based on entry values. Listing 8-3 outlines the code that implements the Controller object and directs the application flow based on user actions.
public class Controller { //definition of action types public enum ControllerActions { New = 1, View = 2, Edit = 3, Delete = 4 } //reference to the controller MDI parent static private Form _ParentForm; //container for all of the individual object mappings static ArrayList _ObjectMappings = new ArrayList(); //property accessor to set parent form public Form ParentForm { set { _ParentForm = value; } } public Controller() { //hard-code new issue mapping ObjectMappingEntry entry; entry = new ObjectMappingEntry(); entry.Action = ControllerActions.View; entry.BusinessObjectName = "IssueCollection"; entry.Viewer = "WinUI.FormIssueSummary"; _ObjectMappings.Add( entry ); entry = new ObjectMappingEntry(); entry.Action = ControllerActions.View; entry.BusinessObjectName = "Issue"; entry.Viewer = "WinUI.FormIssueDetails"; _ObjectMappings.Add( entry ); entry = new ObjectMappingEntry(); entry.Action = ControllerActions.New; entry.BusinessObjectName = "Issue"; entry.Viewer = "WinUI.FormIssueDetails"; _ObjectMappings.Add( entry ); return; } public static void Process( object argObject, ControllerActions argAction ) { //based on mapping, display specific form foreach( ObjectMappingEntry objMapping in _ObjectMappings ) { //find the right business object if( argObject.GetType().Name.CompareTo( objMapping.BusinessObjectName ) == 0 ) { //find the right action if( objMapping.Action == argAction ) { //start the viewer Type typeViewer = Assembly.GetExecutingAssembly().GetType( objMapping.Viewer ); Form formViewer = (Form)Activator.CreateInstance(typeViewer); formViewer.MdiParent = _ParentForm; formViewer.Show(); break; } } } return; } }
First, the Controller class implements the ControllerActions enumeration. This describes what action is being taken by the user against a specific business object or collection. In most cases, New, View, Edit, and Delete is sufficient, but you can add additional actions as necessary.
Next, the Controller's properties are implemented. The ParentForm property specifies the MDI parent that contains the controller. This is necessary so that the Controller can assign the MDI parent to new child forms that are created and displayed. The _ObjectMappings array is a collection of ObjectMappingEntry items. When the Controller is asked to take action, this ArrayList will be searched for an applicable mapping to use.
Next, the code implements the Controller's class constructor. This is currently where hard-coded object-action mappings are being added. Later, these mappings will migrate into an external configuration file. Table 8-1 describes three object mappings.
BUSINESS OBJECT | CONTROLLER ACTION | CHILD FORM |
---|---|---|
IssueCollection | View | WinUI.FormIssueSummary |
Issue | View | WinUI.FormIssueDetails |
Issue | New | WinUI. FormIssueDetails |
Finally, the code implements the Process method to take the action. This method accepts two parameters: a business object to process and an action to take. The method begins by iterating through the ArrayList, inspecting object mappings. The business object's data type compares against business object labels stored within the ObjectMappingEntry. If it finds a matching object name, it also checks the ControllerActions for a match. If it also finds a matching action, then the Controller object can display the appropriate form.
Again, the Controller object has no awareness of any specific form, and there is no FormIssueSummary variable available to create a new instance. So, the Controller object turns to the assembly. The Assembly.GetExecutingAssembly method points to the assembly running the active code ”in this case, WinUI.exe. Because the FormIssueSummary object is defined within the same assembly, you can use the GetType method to return a Type object for the named object. The Activator.CreateInstance method uses the Type object to instantiate the class and present the child form.
Now that you have implemented the Controller object, you can put it to use. The IssueTracker application begins by displaying the FormIssueSummary child form. A mapping has already been defined that binds an IssueCollection object and a View action to the FormIssueSummary child form. In Listing 8-4, the Form_Load event handler only needs to populate an IssueCollection object and pass it along to the Controller.
private void FormMain_Load( object sender, System.EventArgs e ) { IssueCollection myColl = new IssueCollection(); Controller.Process( myColl, Controller.ControllerActions.View ); return; }
In this case, the IssueCollection is empty. Later, you will populate it with actual data. The collection and a ControllerAction will be passed to the static Process method. The Process method will reference its collection of mappings and display the appropriate child form.
Using the Controller object need not be limited to MDI parents. Child forms, or any other object class that needs to direct form actions, can also use the Controller class. In Listing 8-5, the FormIssueSummary class uses the Controller class to display the FormIssueDetails child form in response to double-clicking a row.
private void listView1_DoubleClick( object sender, System.EventArgs e ) { Issue myIssue = new Issue(); myIssue.IssueID = 101; Controller.Process( myIssue, Controller.ControllerActions.View ); }
Essentially, the code functions the same way. When the user double-clicks a row in the list view control, its event handler creates a new Issue object, sets its properties, and passes it to the Controller object's Process method along with a ControllerAction value set to View.
The model element of the MVC architecture already exists with the abstractions that the Business Facade and Business Rules projects provide. The child forms use these projects and their methods to set and get values from the model. Listing 8-6 demonstrates a child form's use of the model by implementing the Form_Load event handler for the FormIssueSummary form.
private void FormIssueSummary_Load(object sender, System.EventArgs e) { IssueManager mgrIssues = new IssueManager(); IssueCollection collIssues = mgrIssues.GetAllIssues(); foreach( Issue issueItem in collIssues ) { listView1.Items.Add( issueItem.Summary ); } return; }
The event handler begins by instantiating an IssueManager object provided by the Business Facade project. The IssueManager returns a collection of Issue objects that abstract values stored in the database. This collection is then iterated to add rows to the list view control. Figure 8-7 shows the final outcome.
The Form_Load event handler is similar for the FormIssueDetails form. You can even apply data binding to child form controls, as shown in Listing 8-7.
private void FormIssueDetails_Load(object sender, System.EventArgs e) { IssueManager managerIssue = new IssueManager(); Issue issue = managerIssue.GetIssue( m_intIssueID ); txtEntryDate.DataBindings.Add( "Text", issue, "EntryDate" ); cboType.DataBindings.Add( "Text", issue, "TypeID" ); cboStatus.DataBindings.Add( "Text", issue, "StatusID" ); cboPriority.DataBindings.Add( "Text", issue, "PriorityID" ); txtSummary.DataBindings.Add( "Text", issue, "Summary" ); txtDescription.DataBindings.Add( "Text", issue, "Description" ); return; }
Again, you have put IssueManager to use. This time, you have invoked the GetIssue method along with a specific Issue ID. You then used .NET's data binding to bind the populated Issue object each relevant form control. Figure 8-8 shows the filled FormIssueDetails form.
So far, the controller and model elements of the MVC architecture have largely completed the application. The application starts with a Form_Load event handler that uses the Controller class to display a summary form. The DoubleClick event handler in that form uses the Controller class to display a details form.
Simple MDI child forms have worked out well to this point, but to round out a solid application framework, you need to attend to the view area.
So far, the standard MDI user interface methodology has worked out well to implement an MVC architecture. However, to turn MDI into an extendable solution ”rather than a flexible solution ”you need to put some additional elements into place, including consistent viewer implementation, the ability to load viewers dynamically, and the ability to retrieve controller settings from an external file. You can fulfill these three basic needs with the help of three additional .NET concepts: forms inheritance, external assembly sharing, and application configuration files.
Another characteristic of application extensibility is to load viewers at runtime that might become available after the enterprise application has already been deployed. The .NET Framework's reflection capabilities radically simplify this. Listing 8-8 revisits the Controller object's Process method with an approach to instantiating form objects from a separate assembly. In this implementation, the Controller class can load a form even if it exists in a separate assembly file.
public static void Process( object argObject, ControllerActions argAction ) { //based on mapping, display specific form foreach( ObjectMappingEntry objMapping in _ObjectMappings ) { //find the right business object if( argObject.GetType().Name.CompareTo( objMapping.BusinessObjectName ) == 0 ) { //find the right action if( objMapping.Action == argAction ) { Assembly asm = Assembly.Load( objMapping.Viewer.Substring( 0, objMapping.Viewer.IndexOf( '.' ) ) ); Type typeViewer = asm.GetType( objMapping.Viewer ); FrameworkViewer formViewer = (FrameworkViewer)Activator.CreateInstance( typeViewer ); formViewer.MdiParent = _ParentForm; formViewer.Show(); } } } return; }
The most significant difference is that the form object's Type is not obtained from the application via the GetExecutingAssembly method but rather from a foreign assembly via the Assembly.Load method. The Assembly.Load method loads a named assembly into the application's domain by name. In this case, a substring operation pulls the namespace from the fully qualified name. Otherwise, there is little or no difference from the original implementation.
Note | This version of the Process method will no longer display forms defined within the same executable file. In general, a separate assembly should manage all forms, including those deployed with the application. Product upgrades may be as simple as replacing the assembly. |
Just like Web forms, Windows forms should also adhere to standards set by an application framework. If there is to be any sort of plug-in framework, it will only be possible through implementation consistency. One effective way to enforce that consistency is through published interfaces. Another way to enforce consistency is through forms inheritance.
Forms inheritance enables a new Windows form to be created by inheriting from an existing form class. This is helpful in building a plug-in framework because you can create a base form that implements basic operational tasks , such as registering forms, returning menu information, and returning a toolbar icon. Rather than implementing this functionality over and over, the inherited form can benefit from the functionality defined in the base class.
Add a new Windows form to the ApplicationFramework project by selecting it in the Solution Explorer and choosing Add ˜ Add Windows Form. Next, enter FrameworkViewer.cs for the new filename. This will become the base class from which future application viewers will inherit. All that remains is to add the necessary viewer functionality to this form.
To demonstrate possible functionality that might appear in the base class, consider the following: The Controller class has identified four different states that viewers might operate in: New, View, Edit, and Delete. The application framework guidelines might decide that the only difference between a viewer in a New state and a viewer in an Edit state is that data fields are read-only when in the View state. If all viewers in the View state are read-only, then it makes sense to disable all fields in one central location. Listing 8-9 outlines the FrameworkViewer base class, which includes the functionality that evaluates the View mode of the viewer and disables all data entry fields as needed.
public class FrameworkViewer : System.Windows.Forms.Form { private Controller.ControllerActions _ViewMode; public Controller.ControllerActions ViewMode { set { _ViewMode = value; } } public FrameworkViewer() { InitializeComponent(); } private void InitializeComponent() { this.Load += new System.EventHandler(this.FrameworkViewer_Load); } private void FrameworkViewer_Load(object sender, System.EventArgs e) { if( ViewMode == Controller.ControllerActions.View ) { //set all edit and list controls to read only foreach( Control controlItem in Controls ) { if( controlItem.GetType().Name.CompareTo( "TextBox" ) == 0 controlItem.GetType().Name.CompareTo( "ComboBox" ) == 0 controlItem.GetType().Name.CompareTo( "ListBox" ) == 0 ) { controlItem.Enabled = false; } } } return; } }
The code behind the FrameworkViewer class appears like many other form classes. The first element added is a ControllerActions member that identifies the state of the form as New, View, Edit, or Delete. Next, a property accessor allows the controller to set that value. The default constructor that follows is essentially the one provided by Visual Studio. The InitializeComponent method, however, includes only a single statement that designates a Form_Load event handler. This event handler is implemented in the method that follows . The event handler iterates through the form's collection of controls and evaluates their Type values. If a control has a Type value that matches a textbox, combo box, or list box, then the control is disabled. This has the effect of rendering any viewer that is initialized in the View state as read-only.
To take advantage of this functionality, the Controller class needs to set the View mode when the view is initially created. Make the following changes to Listing 8-8 to support this new framework feature:
Type typeViewer = asm.GetType( objMapping.Viewer ); FrameworkViewer formViewer = (FrameworkViewer)Activator.CreateInstance( typeViewer ); formViewer.ViewMode = argAction; formViewer.MdiParent = _ParentForm; formViewer.Show();
Turning any MDI child form into a FrameworkViewer is an easy step. The only change necessary is in the class declaration of the form's code-behind page. The class declaration for the FormIssueDetails class now looks like the following:
public class FormIssueDetails : FrameworkViewer //System.Windows.Forms.Form
The code compiles normally, and you can still edit the form within the Visual Studio form designer. Once converted, the viewer has access to all the benefits that the application framework provides. Figure 8-9 shows the FormIssueDetails viewer as it appears in its View state.
In the case of third-party integrators who do not have code or project access, add a new project reference to the framework assembly by clicking Add Reference, selecting the Projects tab, clicking the Browse button, and pointing to the ApplicationFramework file. Next, add a Windows form to the project and change its base class to FrameworkViewer as specified earlier.
After saving the changes to the source file, preview the form in the form designer. All form code including properties, placed controls, and events are inherited. Like all other objects, the rules of object-oriented inheritance apply when it comes to private, public, and protected methods and attributes.
Earlier, Table 8-1 outlined the application's object-action-form mappings, which defined how the Controller object should respond to a specific user action. Listing 8-3 hard-coded this information directly into the Controller object's constructor. This is far from flexible, and you should certainly externalize it into a configuration file. Coincidentally, the .NET Framework provides services for storing application information into an Extensible Markup Language (XML) file. Create a text file named after the application executable file, app.config, and enter the information outlined in Listing 8-10. Next, save the app.config file to the WinUI project folder and add it as an existing item to the WinUI project. Finally, update the following statement within the AssemblyInfo.cs file:
[assembly: AssemblyConfiguration("app.config")]
<?xml version="1.0" encoding="utf-8" ?> <configuration> <configSections> <sectionGroup name="Viewers"> <section name="Include" /> </sectionGroup> </configSections> <Viewers> <Include BusinessObjectName="IssueCollection" Action="2" Viewer="ApplicationFramework.FormIssueSummary" /> <Include BusinessObjectName="Issue" Action="2" Viewer="ApplicationFramework.FormIssueDetails" /> <Include BusinessObjectName="Issue" Action="1" Viewer="ApplicationFramework.FormIssueDetails" /> <Include BusinessObjectName="Object" Action="3" Viewer="ApplicationFramework.FormDataManager" /> </Viewers> </configuration>
The app.config file contains the same information that was hard-coded into the constructor of the Controller class. With the migration to an external file, you can update the constructor to look more like Listing 8-11.
public Controller() { ObjectMappingEntry entry; XmlDocument xmldoc = new XmlDocument(); xmldoc.Load( "WinUI.exe.config" ); XmlNode root = xmldoc.DocumentElement; try { XmlNodeList xnodelist = root.SelectNodes( "/configuration/Viewers/Include" ); foreach( XmlNode xnode in xnodelist ) { //create a new entry object entry = new ObjectMappingEntry(); //translate the integer into a ControllerAction switch( int.Parse(xnode.Attributes["Action"].Value) ) { case 1: entry.Action = ControllerActions.New; break; case 2: entry.Action = ControllerActions.View; break; case 3: entry.Action = ControllerActions.Edit; break; case 4: entry.Action = ControllerActions.Delete; break; } //set the BusinessObjectName entry.BusinessObjectName = xnode.Attributes["BusinessObjectName"].Value; //set the viewer name entry.Viewer = xnode.Attributes["Viewer"].Value; //add this mapping to the collection _ObjectMappings.Add( entry ); } } catch( Exception x ) { EventLog systemLog = new EventLog(); systemLog.Source = "IssueTracker"; systemLog.WriteEntry( x.Message, EventLogEntryType.Error, 0 ); } return; }
The AppSettingsReader object is a useful tool for pulling custom application attributes from an app.config file. Its only downside is that it expects all values to be represented by a key name-value pair. For representing collections of related values, you can use XPath to navigate to specific nodes and extract specific values. First, XPath navigates to the node containing all the viewer entries. Next, a new ObjectMappingEntry object is created and each child node is iterated to retrieve the necessary viewer attributes. Values are pulled that indicate the action, business object name, and viewer name for each mapping that is stored into the ArrayList.