Team Foundation Server Custom Controls

So we’ve installed TFS 2010, and now the company is starting to pick up the pace with all types of users performing different roles.  So far, most of our modifications to the process template have been cosmetic, like moving tabs around, or adding fields.  A group of users came to us the other day, asking why the history tab has comments interwoven with the historical changes.  At that point all I could say is “Whatever you say in the past is history man.”  No, I didn’t say that, but it did get me thinking why there isn't a clear way to view the comments in chronological order. 

In comes the Custom Control.  A custom control is a way to extend the process template in a way you see fit.  I definitely wouldn’t jump to using this as my first choice but in this case it fits.  Our end result would be another tab next to the history tab called “Comment History”, and in the tab would be a readonly window displaying only the comments from the work items history.  Before I start, I would like to list all the sites I used in researching this, as I didn’t just pull this out of a black hat.

One of the key items which I will discuss which isn’t found in these threads is a way keep you work item layout xml to a minimum.

NOTE: You should do all this work on the TFS server as it will make referencing certain DLLs much easier.

1.  Open Visual Studio 2010, create 2 new class library projects, one called WitCustomControls, and one called WitCustomControls.WebAccess.  Set both projects to .NET 3.5.  The reason why we have 2 libraries will become clear in a bit.

2.  In the WitCustomControls library, add a windows form User Control called CommentHistoryControl.  In the the WitCustomControls.WebAccess create a class called HistoryCommentControl.  For each project, create a file called HistoryCommentControl.wicc (this is a work item custom control file), and a class called WorkItemHistoryComment.  Right click the .wicc file and go to Properties; set the Build Action to Content (do this in both projects).  (NOTE: I know I’m doing a little duplicate coding here, but for the concept of the example I’m keeping it simple).  Remove any default class files like Class.cs.

image

3.  Let’s fill in the .wicc files real quick.  When the work item UI is loaded, whether it be in Visual Studio (winform) or team web access, the control that is referenced in the work item layout xml is searched for by that name, in our case HistoryCommentControl. The file defines the library and class to load to render the control.  Do the following:

  • In the WitCustomControls project .wicc file add:
<?xml version="1.0" encoding="utf-8" ?>
<CustomControl xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Assembly>WitCustomControls.dll</Assembly>
<FullClassName>WitCustomControls.HistoryCommentControl</FullClassName>
</CustomControl>

  • In the WitCustomControls.WebAccess project .wicc file add:
<?xml version="1.0" encoding="utf-8" ?>
<CustomControl xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Assembly>WitCustomControls.WebAccess.dll</Assembly>
<FullClassName>WitCustomControls.WebAccess.HistoryCommentControl</FullClassName>
</CustomControl>

4.  Now the references we will need to inherit and implement the IWorkItemControl interface.  This is a little tricky, part of the DLLs are from Visual Studio install, and the other part come from TFS install. 

For both projects you will need:

  1. C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\ReferenceAssemblies\v2.0\Microsoft.TeamFoundation.WorkItemTracking.Client.dll
  2. C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\PrivateAssemblies\Microsoft.TeamFoundation.WorkItemTracking.Controls.dll

For the WitCustomControls.WebAccess project you will need these additional ones:

  1. C:\Windows\assembly\GAC_MSIL\Microsoft.TeamFoundation.WebAccess.Controls\10.0.0.0__b03f5f7f11d50a3a\Microsoft.TeamFoundation.WebAccess.Controls.dll
  2. C:\Windows\assembly\GAC_MSIL\Microsoft.TeamFoundation.WebAccess.WorkItemTracking\10.0.0.0__b03f5f7f11d50a3a\Microsoft.TeamFoundation.WebAccess.WorkItemTracking.dll

5.  I created a helper class to group the comment history data.  In the WorkItemHistoryComment file for both projects, add the following:

public class WorkItemHistoryComment
{
public DateTime ChangedDate;
public string ChangedBy;
public string Comment;
}

6.  The real work happens because these controls implement an interface called IWorkItemControl.  Below shows the code needed to create the content for the controls display.  In a nutshell, because we implement the interface, we get a reference to the WorkItem.  From that we can iterate over the revisions of the work item and look at all the fields that were modified.  The comment that is associated with a work item is in the WorkItem.History property.  For the winform control I used a WebBrowser control docked in the control.  The core of what we were trying to accomplish is in the method ExtractCommentsFromWorkItem(…) and AppendHtmlCommentSection(…).

WitCustomControls user control code:

public partial class HistoryCommentControl : UserControl, IWorkItemControl
{
private IServiceProvider _serviceProvider;

public HistoryCommentControl()
{
InitializeComponent();
}

#region IWorkItemControl Members

public event EventHandler BeforeUpdateDatasource;

public event EventHandler AfterUpdateDatasource;

public void Clear() { }

public void FlushToDatasource()
{
// do nothing because this is a readonly control
}

public void InvalidateDatasource()
{
if (IsInitialized())
{
// fill in the text box
IEnumerable<WorkItemHistoryComment> comments = ExtractCommentsFromWorkItem(WorkItemDatasource as WorkItem);

if (comments.Count() > 0)
{
StringBuilder sb = new StringBuilder("<html><head>");

sb.Append("<style rel=\"stylesheet\" type=\"text/css\">body {font-family: Tahoma,Geneva,Arial,Helvetica,Sans-serif; font-size:0.8em; } .comment-title { font-weight:bold; background-color:#EEEEEE; } .comment { font-size:0.8em; }</style>");
sb.Append("</head><body>");

AppendHtmlCommentSection(comments, sb);

sb.Append("</body></html>");

// set web browser box
webBrowserCommentHistory.DocumentText = sb.ToString();
}
}
}

public System.Collections.Specialized.StringDictionary Properties { get; set; }

public bool ReadOnly
{
get { return true; }
set { }
}

public void SetSite(IServiceProvider serviceProvider)

{
this._serviceProvider = serviceProvider;
}

public object WorkItemDatasource { get; set; }

public string WorkItemFieldName { get; set; }

#endregion

private bool IsInitialized()
{
// this control doesn't need a field name becasue it's readonly
return (!IsDisposed
&& WorkItemDatasource != null);
}

private IEnumerable<WorkItemHistoryComment> ExtractCommentsFromWorkItem(WorkItem wi)
{
List<WorkItemHistoryComment> comments = new List<WorkItemHistoryComment>();

foreach (Revision rev in wi.Revisions)
{
foreach (Field f in rev.Fields)
{
// determine if this revision has a the history field changed
// if so and not empty show it
if (0 == string.Compare(f.Name, "History", true)
&& !string.IsNullOrEmpty(f.Value as string))
{
comments.Add(new WorkItemHistoryComment
{
ChangedBy = (string)rev.Fields["Changed By"].Value,
ChangedDate = (DateTime)rev.Fields["Changed Date"].Value,
Comment = (string)f.Value
});
}
}
}

return comments.OrderByDescending(c => c.ChangedDate);
}

private void AppendHtmlCommentSection(IEnumerable<WorkItemHistoryComment> comments, StringBuilder sb)
{
// help verbage
sb.Append("<div>To add a comment, use the History tab where the comment input is located.</div><br/<br/>");

// start container for comments
sb.Append("<div><div><table style=\"width:100%;table-layout:fixed;\" cellspacing=\"1\" cellpadding=\"0\"><tbody>");

foreach (WorkItemHistoryComment comment in comments)
{
// row that indicates comment header
sb.Append("<tr><td> <table style=\"width:100%;font-size:0.8em;\" cellspacing=\"0\" cellpadding=\"1\"> <tbody> <tr>");

// from TFS history header row onclick=\"_ctl00_c_we_ctl48_wc.onExpandCollapseChangeTitle(this);\"
// wish we could pigback on this collapsing behavior
sb.Append("<td width=\"16\" align=\"center\"><img border=\"0\" align=\"absMiddle\" style=\"width:18px;height:17px;cursor:pointer\" src=\"https://tfs.pcbancorp.com:8443/tfs/web/Resources/Images/minus.gif\"></td>");
sb.AppendFormat("<td class=\"comment-title\">{0} [{1}]</td>", comment.ChangedBy, comment.ChangedDate);
sb.Append("</tr> </tbdody> </table> </td></tr>");
// row that represents the actual comment
sb.AppendFormat("<tr class=\"comment\"><td><div>{0}</div></td></tr>",
comment.Comment);
}

// finish container for comments
sb.Append("</tbody></table></div></div>");
}
}

WitCustomControls.WebAccess user control code:

public class HistoryCommentControl : BaseWorkItemWebControl

public HistoryCommentControl()

: base(HtmlTextWriterTag.Div) { }

public System.Web.UI.HtmlControls.HtmlGenericControl HtmlControl { get; protected set; }

public override void InitializeControl()
{
base.InitializeControl();

// Create control
EnsureInnerControls();
}

public void EnsureInnerControls()
{
if (HtmlControl != null)
return;

HtmlControl = new System.Web.UI.HtmlControls.HtmlGenericControl("div");

base.Controls.Clear();
base.Controls.Add(HtmlControl);
}

public override void InvalidateDatasource()
{
base.InvalidateDatasource();

EnsureInnerControls();

// set control value
IEnumerable<WorkItemHistoryComment> comments =
ExtractCommentsFromWorkItem(this.WorkItemDatasource as WorkItem);

if (comments.Count() > 0)
{
StringBuilder sb = new StringBuilder();

// get html with comments embedded
AppendHtmlCommentSection(comments, sb);

// set contents of html control
HtmlControl.InnerHtml = sb.ToString();
}
}

public override void FlushToDatasource()
{
base.FlushToDatasource();

HtmlControl.InnerHtml = string.Empty;
}

private IEnumerable<WorkItemHistoryComment> ExtractCommentsFromWorkItem(WorkItem wi)
{
List<WorkItemHistoryComment> comments = new List<WorkItemHistoryComment>();

foreach (Revision rev in wi.Revisions)
{
foreach (Field f in rev.Fields)
{
// determine if this revision has a the history field changed
// if so and not empty show it
if (0 == string.Compare(f.Name, "History", true)
&& !string.IsNullOrEmpty(f.Value as string))
{
comments.Add(new WorkItemHistoryComment
{
ChangedBy = (string)rev.Fields["Changed By"].Value,
ChangedDate = (DateTime)rev.Fields["Changed Date"].Value,
Comment = (string)f.Value
});
}
}
}

return comments.OrderByDescending(c => c.ChangedDate);
}

private void AppendHtmlCommentSection(IEnumerable<WorkItemHistoryComment> comments, StringBuilder sb)
{
// help verbage
sb.Append("<div>To add a comment, use the History tab where the comment input is located.</div><br/<br/>");



// start container for comments

sb.Append("<div class=\"tswa-compositectrl\"><div class=\"tswa-historyctrl-changeshost\"><table style=\"width:100%;table-layout:fixed;\" cellspacing=\"1\" cellpadding=\"0\"><tbody>");



foreach (WorkItemHistoryComment comment in comments)

{

// row that indicates comment header

sb.Append("<tr><td> <table width=\"100%\" cellspacing=\"0\" cellpadding=\"1\"> <tbody> <tr class=\"tswa-historyctrl-revhead\">");

// from TFS history header row onclick=\"_ctl00_c_we_ctl48_wc.onExpandCollapseChangeTitle(this);\"

// wish we could pigback on this collapsing behavior

sb.Append("<td width=\"16\" align=\"center\"><img border=\"0\" align=\"absMiddle\" style=\"width:18px;height:17px;cursor:pointer\" src=\"https://tfs.pcbancorp.com:8443/tfs/web/Resources/Images/minus.gif\"></td>");

sb.AppendFormat("<td>{0} [{1}]</td>", comment.ChangedBy, comment.ChangedDate);

sb.Append("</tr> </tbdody> </table> </td></tr>");

// row that represents the actual comment
sb.AppendFormat("<tr><td><div class=\"tswa-historyctrl-description\">{0}</div></td></tr>",
comment.Comment);
}

// finish container for comments
sb.Append("</tbody></table></div></div>");
}
}

7.  Now that we have our libraries created, we have to package them up so they can be installed in the correct location.  We at the point of understanding why we created 2 libraries.  When Visual Studio opens it loads the control from C:\Documents and Settings\All Users\Application Data\Microsoft\Team Foundation\Work Item Tracking\Custom Controls\10.0 and when TFS web access loads the control it does it from %ProgramFiles%\Microsoft Team Foundation Server 2010\Application Tier\Web Access\Web\App_Data\CustomControls.  Because of this, we will create a setup package that will install our libraries in different locations, the winform control will go in the all user’s app data, and the web control will go in the TFS web application folder. 

Create 2 setup projects (found in Other Project Types^Setup and Deployment^Visual Studio Installer^Setup Project).  One call WitCustomControlsSetup (for the 32-bit version) and one called WitCustomControlsSetup.x64 (for the 64-bit version).  The key difference is that on the 64-bit version, the files need to go in the 64-bit program files directory.  Both will follow the steps below and I will note when the 64-bit version differs.

a.  Right click the setup project and select View^file System.  Create the folder TWAAppDataFolder, the Application Folder will already exist.

b.  Select the Application Folder.  Right click in the output area and add the WitCustomControls libraries project output, repeat this and add it’s Content output as well.

image

c.  Select the TWAAppDataFolder.  Like before, right click in the output area and add the WitCustomControls.WebAccess libraries project output, repeat this and add it’s Content output as well.  The final should be similar to the following.

image

d.  Now right click on the Application Folder and select Properties Window. Set the DefaultLocation value to [CommonAppDataFolder]\All Users\Application Data\Microsoft\Team Foundation\Work Item Tracking\Custom Controls\10.0.  Right click on the TWAAppDataFolder and go to the properties.  Set it’s DefaultLocation to [ProgramFilesFolder]\Microsoft Team Foundation Server 2010\Application Tier\Web Access\Web\App_Data\CustomControls (IMPORTANT: on the 64-bit setup project replace [ProgramFilesFolder] with [ProgramFiles64Folder]).

image

e.  Set the project property InstallAllUsers and RemovePreviousVersion to true.

f.  For the 64-bit version, you must change the setup projects property TargetPlatform to x64.

g.  Remove the TFS DLL dependencies.

image

8.  Install the 64-bit version on your TFS server.  Install the 32/64 –bit on any client machines you want this control to be used in by Visual Studio.

9.  The very last thing we need to do is modify our work item xml file and add our new control to the layout section.  The reason we have 2 libraries and 2 wicc files is so we don’t have to duplicate our layout setup for winform and web access.  If we didn’t we’d have to use the <Layout Target=”Web”> and <Layout Target=”WinForm”>, which is a pain.  Below is the tab I added to our work item layout.  Notice the Type attribute, this is the same name as the wicc file, and there lies the magic of it all!

<Tab Label="Comment History">
<Control Type="HistoryCommentControl" Label="" LabelPosition="Top" Dock="Fill" />
</Tab>