Visualizing Project Hierarchy

Overview

Visualizing your project can reveal relationships and potential resource issues before they become a problem in ways that information presented in a tabular format just can't. I created this VisualScript report to take data from Jira and show the hierarchy of a single project in one view. A visual like this may reveal epics with no stories or issues, epics with too many issues, issues with a lot of subtasks, and more.

Projects contain a set of epics, stories and sub-tasks that can be naturally organized and visualized in an org chart. Org charts are typically used to show a hierarchy of people in an organization, but its structure is also useful for depicting other forms of hierarchical information. I used color to differentiate between the types of issues and added hyperlinks to permit instant access to issues.

Project hierarchy

I also set up a user interface for end users that is extremely simple. To create the visual and add it to their dashboard, they just need to provide the name of their project.

Project hierarchy project name

How I Implemented It

Obtaining the Data

You start any VisualScript report by querying for data from a source, in this case Jira. I used the VisualScript function VSCallFunctionForJiraQuery to query Jira using the Jira REST API. The only parameter to pass is "project=".

function VisualScript(localhost, params, callback) { var epics = {}, stories = {}, subTasks = {}; VSCallFunctionForJiraQuery(gSDJSLocalHost, "project = " + params, handleEpics);

Building the Model

The next thing I do is build a model of the information that I want to depict. I scan the list of returned issues and build a list of epics, stories, and sub-tasks.

// Build the model. Scan through the issues, bulding the list of epics, stories and subTasks. Create VisualScript shapes for the epics now. issuesLen = issues.length; for (i = 0; i < issuesLen; i++) { switch (issues[i].fields.issuetype.name) { case "Epic": epics[issues[i].key] = { issue: issues[i] }; break; case "Story": stories[issues[i].key] = { issue: issues[i] }; break; case "Sub-task": subTasks[issues[i].key] = { issue: issues[i] }; } }

Next, I need some extra information that's not available from the query. I need to know the name of the custom field that this Jira installation is using for "epic link". Unlike keys and summary information that are a standard part of the Jira API, the epic is implemented as a custom field. I need to issue an API call on a single issue while passing it the "expand=names" parameter. This will cause Jira to add additional information to the results about the use of custom fields. To implement this, I find the first story in my model, then issue the API request.

// Get the first story var firstStory; for (var story in stories) { firstStory = story; break; } if (firstStory) { // Use the first story to find the custom field that's used for epic links, then process the stories and subtasks using that information VSCallFunctionForJiraIssue(gSDJSLocalHost, firstStory + "?expand=names", buildVisualScript); } else { callback(null); }

The information is passed as a parameter to the function buildVisualScript. Note that if there were no stories in the query results, there's no point in continuing (and the callback is passed a null).

Building the VisualScript

Once I have all the information I need in my model, I can build the VisualScript markup that will be used to render my visual. After setting up my document, I set up the root shape, which naturally contains the project name (params). Beneath the project shape, I add a connector to connect all the epics, stories, and issues. I add a shape for each epic in the model.

Next up, I add stories. Stories can either be linked to an epic or not. When they're linked to an epic, I add the story shape under the relevant epic; otherwise the story is added directly to the project's connector.

Finally, I add the sub-tasks. I add each sub-task in the model to its story's connector.

// Build VisualScript from the model function buildVisualScript(firstStoryInfo) { var vs = {}, rootShape, rootConnector, targetEpicKey; var colors = { epic: "#ACE2FC", story: "#91AECF", subtask: "#FADE9E" }; var browseUrl = localhost + "/browse/"; var epicLinkField = getEpicLinkField(firstStoryInfo); vs = new VS.Document(); vs.SetTemplate(VS.Templates.Orgchart); rootShape = vs.GetTheShape() .SetLabel(params); rootConnector = rootShape.AddShapeConnector(); // Add a shape for each epic for (var epicKey in epics) { epics[epicKey].shape = rootConnector.AddShape() .SetLabel(epics[epicKey].issue.fields.summary) .SetFillColor(colors.epic) .SetHyperlink(browseUrl + epics[epicKey].issue.key); } // Now add shapes for all the stories. for (var storyKey in stories) { // Add to the epic (if there) or to the root shape if there is no epic if (epicLinkField && stories[storyKey].issue.fields[epicLinkField]) { targetEpicKey = stories[storyKey].issue.fields[epicLinkField]; if (!epics[targetEpicKey].shapeConnector) { epics[targetEpicKey].shapeConnector = epics[targetEpicKey].shape.AddShapeConnector(); } target = epics[targetEpicKey].shapeConnector; } else { target = rootConnector; } // Add a new shape for this story to the target's first/only shape connector stories[storyKey].shape = target.AddShape() .SetLabel(stories[storyKey].issue.fields.summary) .SetShapeType(VS.ShapeTypes.Rect) .SetFillColor(colors.story) .SetTextTruncate(60) .SetHyperlink(browseUrl + storyKey); } // Now add the subtasks for (var subTaskKey in subTasks) { var parentStoryID = subTasks[subTaskKey].issue.fields.parent.key; if (!stories[parentStoryID]) { continue; } if (!stories[parentStoryID].shapeConnector) { stories[parentStoryID].shapeConnector = stories[parentStoryID].shape.AddShapeConnector(); } target = stories[parentStoryID].shapeConnector; if (target) { subTasks[subTaskKey].shape = target.AddShape() .SetLabel(subTasks[subTaskKey].issue.fields.summary) .SetShapeType("Rect") .SetFillColor(colors.subtask) .SetTextTruncate(60) .SetHyperlink(browseUrl + subTaskKey); } }

Rendering the VisualScript

The last step is to call the callback script's with the VisualScript object (vs). This will render the VisualScript markup as a visual.

// Render the VisualScript
callback(vs.toJSON());

Creating the UI

After the script is complete, I set up the UI so that the user can provide the required parameter in the future. In the top section of the VisualScript Studio edit page, you'll see a field labeled "Parameter Template". The specification string for the UI Builder's "Parameter Template" is:

Parameter template

This sets up a single field that will be passed to the script.

When I enter the above string is entered into the UI Builder's Parameter Template, this form is displayed:

New name and description

I then override the "Displayed Name" to be a little more of a friendly string and add a brief description:

More Information

This is just one example of the useful visuals that can be produced using VisualScript and VisualScript Studio with a minimum of development effort.

The full source for this script can be seen here. To make the code easier to understand, I highlighted the part used for querying the data in green, the part for building the model in orange, and the part that creates VisualScript from the data in blue.

function VisualScript(localhost, params, callback) { var epics = {}, stories = {}, subTasks = {}; // Shows a display of all epics, stories and subtasks of the parameter project. Please refer to https://www.smartdraw.com/developers/documentation.htm for information on use of VisualScript VSCallFunctionForJiraQuery(gSDJSLocalHost, "project = " + params, handleEpics); function handleEpics(issues) { var i, issuesLen; //debugger; if (!issues || issues.length == 0) { alert((!params || params.length == 0) ? "Please enter a project into the parameters" : "No issues found for project " + params); callback(null); return; }
// Build the model. Scan through the issues, bulding the list of epics, stories and subTasks. Create VisualScript shapes for the epics now. issuesLen = issues.length; for (i = 0; i < issuesLen; i++) { switch (issues[i].fields.issuetype.name) { case "Epic": epics[issues[i].key] = { issue: issues[i] }; break; case "Story": stories[issues[i].key] = { issue: issues[i] }; break; case "Sub-task": subTasks[issues[i].key] = { issue: issues[i] }; } } // Get the first story var firstStory; for (var story in stories) { firstStory = story; break; } if (firstStory) { // Use the first story to find the custom field that's used for epic links, then process the stories and subtasks using that information VSCallFunctionForJiraIssue(gSDJSLocalHost, firstStory + "?expand=names", buildVisualScript); } else { callback(null); } }
// Build VisualScript from the model function buildVisualScript(firstStoryInfo) { var vs = {}, rootShape, rootConnector, targetEpicKey; var colors = { epic: "#ACE2FC", story: "#91AECF", subtask: "#FADE9E" }; var browseUrl = localhost + "/browse/"; var epicLinkField = getEpicLinkField(firstStoryInfo); vs = new VS.Document(); vs.SetTemplate(VS.Templates.Orgchart); rootShape = vs.GetTheShape() .SetLabel(params); rootConnector = rootShape.AddShapeConnector(); // Add a shape for each epic for (var epicKey in epics) { epics[epicKey].shape = rootConnector.AddShape() .SetLabel(epics[epicKey].issue.fields.summary) .SetFillColor(colors.epic) .SetHyperlink(browseUrl + epics[epicKey].issue.key); } // Now add shapes for all the stories. for (var storyKey in stories) { // Add to the epic (if there) or to the root shape if there is no epic if (epicLinkField && stories[storyKey].issue.fields[epicLinkField]) { targetEpicKey = stories[storyKey].issue.fields[epicLinkField]; if (!epics[targetEpicKey].shapeConnector) { epics[targetEpicKey].shapeConnector = epics[targetEpicKey].shape.AddShapeConnector(); } target = epics[targetEpicKey].shapeConnector; } else { target = rootConnector; } // Add a new shape for this story to the target's first/only shape connector stories[storyKey].shape = target.AddShape() .SetLabel(stories[storyKey].issue.fields.summary) .SetShapeType(VS.ShapeTypes.Rect) .SetFillColor(colors.story) .SetTextTruncate(60) .SetHyperlink(browseUrl + storyKey); } // Now add the subtasks for (var subTaskKey in subTasks) { var parentStoryID = subTasks[subTaskKey].issue.fields.parent.key; if (!stories[parentStoryID]) { continue; } if (!stories[parentStoryID].shapeConnector) { stories[parentStoryID].shapeConnector = stories[parentStoryID].shape.AddShapeConnector(); } target = stories[parentStoryID].shapeConnector; if (target) { subTasks[subTaskKey].shape = target.AddShape() .SetLabel(subTasks[subTaskKey].issue.fields.summary) .SetShapeType("Rect") .SetFillColor(colors.subtask) .SetTextTruncate(60) .SetHyperlink(browseUrl + subTaskKey); } } // Render the VisualScript callback(vs.toJSON()); } // Get the field name for the epic of a story function getEpicLinkField(firstStoryInfo) { // Find the custom field used to contain the epic link for (var field in firstStoryInfo.names) { if (firstStoryInfo.names[field] == "Epic Link") { return field; } } } }