WPF Issue Tracker
For a class project in Georgia Tech’s CS 2335, the class was instructed to create a client/server issue tracker with several specific features. We are hosting this project on Google Code, where you can use your favorite subversion client (I prefer TortoiseSVN) to download and enjoy our code.
While creating the project, we ran into several issues, the solutions to which I felt were somewhat non-trivial. Thanks to several blog posts and WPF books, we were able to create a very solid application that received the maximum grade possible.
Networking
Up until this point, we’d only been introduced to more traditional methods of networking (as used in a chat application in the previous lab). I discovered the Windows Communication Foundation (WCF), which makes client/server networking extremely easy. You simply define an interface for the client to call methods on the server, and optionally a callback interface for the server to call methods on the client. We weren’t able to get the callbacks working, but even with that oversight the application is still relatively solid. With some outside help, callbacks would probably be easily fixed; if not, one could always use something like a timer or refresh button.
Overall Architecture
The choice of WCF (see above) really lends itself to a specific class structure; in particular, a “client” project, a “server” project, and a “common” project with the interface between the two (INetworkManager.cs) and any types used therein (User.cs and Issue.cs). I also made use of C#’s namespaces here, making each of the projects part of a common (solution-wide) namespace, and gave each of the projects a child namespace.
Client Architecture
I made extensive use of the Application object; while it is technically a singleton, it is a pain to use. The following code makes for easier access to its methods:
public static App Instance() { return ((App)App.Current); }
While that’s a fairly simple trick, it saves a nontrivial amount of development time. I also developed the application to have multiple windows, including a login window, a “main” client window, and child windows that let the user view details on users or tasks. I felt the best decision here would be to hide/show each as appropriate from the Application class with ShowLogin(), ShowClient(), etc. Note that this means that I needed some code in the Client window that instructed the program to shut down when that window was closed; otherwise, it would simply hang.
Data management
The list of valid users and current issues are both maintained on the server. Each time they are updated, they are reserialized to disk, a trivial task with a relatively small number of issues and users.
If the system were used with a large project, the backend could most likely be swapped for a database, especially given the ease with which databases are accessed from .Net.
Serialization
We had to make some changes so that the data would persist across sessions. First of all, we will be serializing the collection of users and issues, which depend on other data types. So for the issue manager, we added a couple properties:
[XmlRoot("IssueManager")] [XmlInclude(typeof(Data.Issue))] [XmlInclude(typeof(Data.User))] public class IssueManager { ... }
In addition, you can’t exactly serialize a dictionary; instead, you must fool .Net into doing it anyway. I used this as a basis to serialize more complex types, but wasn’t able to get it working.
/// <summary> /// Note that we cannot serialize a generic dictionary. /// </summary> [XmlIgnore] public Dictionary<Int64?, Data.Issue> Issues { get { return issues; } set { issues = value; } } /// <summary> /// Property created to fool xml serialization /// </summary> [XmlArray("Issues")] [XmlArrayItem("IssuesLine", Type = typeof(DictionaryEntry))] public DictionaryEntry[] UserArray { get { //Make an array of DictionaryEntries to return DictionaryEntry[] ret = new DictionaryEntry[Issues.Count]; int i = 0; DictionaryEntry de; //Iterate through Stuff to load items into the array. foreach (KeyValuePair<Int64?, Data.Issue> issuesLine in Issues) { de = new DictionaryEntry(); de.Key = issuesLine.Key; de.Value = issuesLine.Value; ret[i] = de; i++; } return ret; } set { Issues.Clear(); for (int i = 0; i < value.Length; i++) { Issues.Add((Int64?)value[i].Key, (Data.Issue)value[i].Value); } } }
I may have also needed a default (parameterless) constructor for Serialization to work. Now that it is serializable, we need to actually store the data. Every time I updated the collection, I call Serialize(), which is the following function:
/// <summary> /// Serialize the list of issues. This method runs every time an issue is changed. /// </summary> public void Serialize() { XmlSerializer serializer = new XmlSerializer(typeof(IssueManager)); string filename = "issues.xml"; StreamWriter outStream = new StreamWriter(filename, false); serializer.Serialize(outStream, this); outStream.Close(); }
Deserialization
Once you have serialization hooked up, deserialization is a snap. This function is called when the application starts:
void AppStartup(object sender, StartupEventArgs args) { // show the server form ShowServer(); // you may now make calls to App.WriteLine. App.WriteLine("Started the dialog"); // create manager objects CreateUserManager(); CreateIssueManager(); }
Here is where we read in the xml; note that most of these lines are actually related to error handling:
/// <summary> /// Deserialize the contents of issues.xml into an IssueManager object /// </summary> public void CreateIssueManager() { // deserialize or create a new IssueManager XmlSerializer deserial = new XmlSerializer(typeof(IssueManager)); try { System.IO.TextReader read = new System.IO.StreamReader("issues.xml"); IManager = (IssueManager)deserial.Deserialize(read); read.Close(); App.WriteLine("Deserialized IssueManager from issues.xml"); } catch (System.IO.FileNotFoundException) { App.WriteLine("Couldn't find issues.xml; creating new IssueManager"); IManager = new IssueManager(); } catch (System.Xml.XmlException) { App.WriteLine("Error while parsing issues.xml!"); IManager = new IssueManager(); } catch (Exception ex) { // print out exception and inner exception if available StringBuilder b = new StringBuilder("Error parsing issues.xml: "); b.Append(ex.Message); if (ex.InnerException != null) { b.Append(" "); b.Append(ex.InnerException.Message); } App.WriteLine(b.ToString()); IManager = new IssueManager(); } }
GUI considerations
Login form
Given that most users will log into a system far more times than they will register for it, I hid the extra registration fields so experienced users would be less confused about what to do next. My model for this was how popular instant messaging clients only ever show the username/password, and direct you to a website to register.
I attempted to use a PasswordBox and to keep the password in a SecureString, but SecureString is particularly difficult to use in that capacity. Thus, as soon as it is needed, the password is put into a string object and passed around as insecure as ever.
Client: Sorting ListView
I found how to sort a ListView here. In my code, I had to sort more than one ListView, so I added a handler to each of the columns in each ListView, such as the following:
<GridViewColumn DisplayMemberBinding="{Binding Name}"> <GridViewColumnHeader Click="IssueSort_Click" Tag="Name" Content="Name"/> </GridViewColumn>
Which called this function:
private void IssueSort_Click(object sender, RoutedEventArgs e) { Sort_Click(sender, e, issuesListView); }
Which called this function:
private void Sort_Click(object sender, RoutedEventArgs e, ListView lv) { GridViewColumnHeader column = sender as GridViewColumnHeader; string field = column.Tag as string; if (_CurSortCol != null) { AdornerLayer.GetAdornerLayer(_CurSortCol).Remove(_CurAdorner); lv.Items.SortDescriptions.Clear(); } ListSortDirection newDir = ListSortDirection.Ascending; if (_CurSortCol == column && _CurAdorner.Direction == newDir) { newDir = ListSortDirection.Descending; } _CurSortCol = column; _CurAdorner = new SortAdorner(_CurSortCol, newDir); AdornerLayer.GetAdornerLayer(_CurSortCol).Add(_CurAdorner); lv.Items.SortDescriptions.Add(new SortDescription(field, newDir)); }
You will also need their SortAdorner class, which I took the liberty of commenting for you:
public class SortAdorner : Adorner { // At the top, we have two static Geometries: // one for the up arrow private readonly static Geometry _AscGeometry = Geometry.Parse("M 0,0 L 10,0 L 5,5 Z"); // and one for the down arrow private readonly static Geometry _DescGeometry = Geometry.Parse("M 0,5 L 10,5 L 5,0 Z"); // sort direction public ListSortDirection Direction { get; private set; } // The UIElement will be the element that the adorner will adorn public SortAdorner(UIElement element, ListSortDirection dir) : base(element) { Direction = dir; } /// <summary> /// All of the rendering occurs in this function /// </summary> /// <param name="drawingContext"></param> protected override void OnRender(DrawingContext drawingContext) { base.OnRender(drawingContext); /// First, if the column header is less than 20 pixels wide, /// we don't bother drawing the arrow (it looks kind of silly). if (AdornedElement.RenderSize.Width < 20) return; /// Then we push a TranslateTransform onto the drawing context /// transform stack. This makes sure that the arrow will be /// drawn at the right edge of the column and vertically in the /// center. drawingContext.PushTransform( new TranslateTransform( AdornedElement.RenderSize.Width - 15, (AdornedElement.RenderSize.Height - 5) / 2)); /// In the next line, we draw the geometry (looking at /// the Direction to figure out which one) drawingContext.DrawGeometry(Brushes.Black, null, Direction == ListSortDirection.Ascending ? _AscGeometry : _DescGeometry); /// Finally, we pop the translate transform off of the transform /// stack (just to keep the transform stack clean). drawingContext.Pop(); } }
There are certainly other ways to sort ListViews out there, this is simply the one that I used.
Issue Detail: Drop-down list of users
When a user assigns someone to an issue, they are able to click a drop-down and select someone. This required us to ping the server for a list of potential users, but was a nice graphical and usability touch. The drop down box itself looks like this in XAML:
<ComboBox Grid.Column="1" Grid.Row="6" Name="assignedToBox" DropDownOpened="assignedToBox_DropDownOpened"/>
Which called this event handler:
private void assignedToBox_DropDownOpened(object sender, EventArgs e) { // clear the current items assignedToBox.Items.Clear(); // if the user isn't an admin, the only choice is the user's username if (User.Access == Data.User.Privileges.Normal) { assignedToBox.Items.Add(User.ID); } else { // otherwise the user can choose from any available user foreach (KeyValuePairusersLine in Users) { assignedToBox.Items.Add(usersLine.Key); } } }
Some of the magic is happening behind the scenes, though; accessing Users is actually querying the server, as follows:
public DictionaryUsers { get { lock (App.Instance().Proxy) { return App.Instance().Proxy.GetUserList(); } } }
Those are all of the hints that I think I need to point out. You're welcome to explore and let me know if you have any questions about functionality or design choices.
Leave a Reply