cschleiden
Published on

VSTS Tags MRU extension – Part 1

Authors

I often find myself adding the same tags over and over to work items. Example: While we use features to group our user stories, it is often convenient to also add a tag per feature, since these can show up on the cards on the different boards, making it easy to see what belongs to which feature:

So let's say I'm working on a feature called “Tag Extension”. Our feature is broken down into a few user stories and and we have applied a tag “Tag Extension” to all of them:

Then we add another story using the add panel on the backlog. It’s parented to the feature but it’s missing the tag applied to the other ones:

While I could now open the user story and add the tag, what I'd like to have is something like this:

Open the context menu for a work item anywhere in the product, have a list of the tags I added last to any work item, and allowing me to easily add one of them with a single click.

Fortunately, we can build this with just a few lines of code using the VSTS extensions API. There is one little drawback – more on that later – but we can get quite close to what I just described. I will be using the seed project I mentioned earlier, you can just clone the repo or download it as a zip if you want to follow along: https://github.com/cschleiden/vsts-extension-ts-seed-simple.

You can also skip immediately ahead to the finished version:
https://github.com/cschleiden/vsts-extension-tags-mru

Capturing tags as they are added

The first task to generating the MRU list is to capture which tags are added to work items. In order to receive notifications about changes to the work item form, we need to add a contribution of type ms.vss-work-web.work-item-notifications to our extension. This allows us to listen to events like onFieldChanged (a field on the form has been changed) or onSaved (work item has been saved). So, we can just replace the existing contribution in the manifest with this:

{
"id": "tags-mru-work-item-form-observer",
"type": "ms.vss-work-web.work-item-notifications",
"targets": [
"ms.vss-work-web.work-item-form"
],
"properties": {
"uri": "index.html"
}
}

and place the matching typescript code in app.ts (replacing the existing VSS.register call):

// Register work item change listener
VSS.register("tags-mru-work-item-form-observer", (context) => {
return {
onFieldChanged: (args) => {
if (args.changedFields["System.Tags"]) {
var changedTags: string = args.changedFields["System.Tags"];

console.log(\`Tags changed: ${changedTags}\`);
}
},
onLoaded: (args) => {
console.log("Work item loaded");
},
onUnloaded: (args) => {
console.log("Work item unloaded");
},
onSaved: (args) => {
console.log("Work item saved");
},
onReset: (args) => {
console.log("Work item reset");
},
onRefreshed: (args) => {
console.log("Work item refreshed");
}
};
});

When we publish this extension to our account, create a new work item, add a couple tags, and then save the work item, we will see messages like these in the console:

As you can see, all tags are reported as a single field separated by semicolons. That means, that we need a way to identify when a tag is added. An easy way to accomplish this, is to get the list of tags when a work item is opened, and then when it’s saved to diff the original and current tags.

To get the tags when the work item is opened, we can utilize the WorkItemFormService. We need to import the framework module providing it:

import TFS\_Wit\_Services = require("TFS/WorkItemTracking/Services");

and then we can get an instance of the service when a work item is opened, and get the current value of the System.Tags field.

onLoaded: (args) => {
// Get original tags from work item
TFS\_Wit\_Services.WorkItemFormService.getService().then(wi => {
(<IPromise<string>>wi.getFieldValue("System.Tags")).then(
(changedTags: string) => {
// TODO: Save
});
});
}

Since it’s possible to open multiple work items in VSTS at the same time, we cannot simply store original and updated tags in two variables, but need both current and updated tags keyed to a work item, identified by its id. A simple singleton solution could be the following:

/*\* Split tags into string array \*/
function splitTags(rawTags: string): string[] {
return rawTags.split(";").map(t => t.trim());
}

/**
* Tags are stored as a single field, separated by ";".
* We need to keep track of the tags when a work item was
* opened, and the ones when it&#8217;s closed. The intersection
* are the tags added.
*/
class WorkItemTagsListener {
private static instance: WorkItemTagsListener = null;

public static getInstance(): WorkItemTagsListener {
if (!WorkItemTagsListener.instance) {
WorkItemTagsListener.instance = new WorkItemTagsListener();
}

return WorkItemTagsListener.instance;
}

/*\* Holds tags when work item was opened \*/
private orgTags: { [workItemId: number]: string[] } = {};

/*\* Tags added \*/
private newTags: { [workItemId: number]: string[] } = {};

public setOriginalTags(workItemId: number, tags: string[]) {
this.orgTags[workItemId] = tags;
}

public setNewTags(workItemId: number, tags: string[]) {
this.newTags[workItemId] = tags;
}

public clearForWorkItem(workItemId: number) {
delete this.orgTags[workItemId];
delete this.newTags[workItemId];
}

public commitTagsForWorkItem(workItemId: number): IPromise {
// Generate intersection between old and new tags
var diffTags = this.newTags[workItemId]
.filter(t => this.orgTags[workItemId].indexOf(t) < 0);
// TODO: Store
return Q(null);
}
}

hooking it up to the observer:

// Register work item change listener VSS.register("tags-mru-work-item-form-observer", (context) => {
return {
onFieldChanged: (args) => {
// (2)
if (args.changedFields["System.Tags"]) {
var changedTags: string = args.changedFields["System.Tags"];
WorkItemTagsListener.getInstance()
.setNewTags(args.id, splitTags(changedTags));
}
},
onLoaded: (args) => {
// (1)
// Get original tags from work item
TFS_Wit_Services.WorkItemFormService.getService().then(wi => {
(<IPromise>wi.getFieldValue("System.Tags")).then(
changedTagsRaw => {
WorkItemTagsListener.getInstance()
.setOriginalTags(args.id, splitTags(changedTagsRaw));
});
});
},
onUnloaded: (args) => {
// (4)
WorkItemTagsListener.getInstance().clearForWorkItem(args.id);
},
onSaved: (args) => {
// (3)
WorkItemTagsListener.getInstance().commitTagsForWorkItem(args.id);
},
onReset: (args) => {
// (5)
WorkItemTagsListener.getInstance().setNewTags(args.id, []);
},
onRefreshed: (args) => {
// (5)
WorkItemTagsListener.getInstance().setNewTags(args.id, []);
}
};
});
  1. Retrieve the tags of a work item when it’s opened, storing them in the WorkItemTagsListener instance
  2. Whenever the System.Tags field is changed, store the tags as the new tags in the TagsListener instance
  3. When the work item is actually saved, commit the new tags to the MRU list (not yet implemented)
  4. Reset the work item’s data when it’s unloaded
  5. Only reset the new tags when edits to a work item are discarded

This enables us to detect added tags to any work items. The next part will cover actually storing the tags per user, showing them in a context menu, and applying to work items.