title | titleSuffix | description | ms.subservice | ms.assetid | ms.topic | ms.author | author | ms.date | monikerRange |
---|---|---|---|---|---|---|---|---|---|
Add a dashboard widget |
Azure DevOps |
Tutorial for creating a widget that you can then add to a dashboard in Azure DevOps or Team Foundation Server (TFS) |
azure-devops-ecosystem |
1D393A4A-2D25-479D-972B-304F99B5B1F8 |
conceptual |
chcomley |
chcomley |
10/31/2019 |
>= azure-devops-2019 |
[!INCLUDE version-gt-eq-2019]
Widgets on a dashboard are implemented as contributions in the extension framework. A single extension can have multiple contributions. Learn how to create an extension with multiple widgets as contributions.
This article is divided into three parts, each building on the previous - beginning with a simple widget and ending with a comprehensive widget.
[!INCLUDE extension-docs-new-sdk]
To create extensions for Azure DevOps or TFS, there are some prerequisite software and tools you'll need:
Knowledge: Some knowledge of JavaScript, HTML, CSS is required for widget development.
- An organization in Azure DevOps for installing and testing your widget, more information can be found here
- A text editor. For many of the tutorials, we used
Visual Studio Code
, which can be downloaded here - The latest version of node, which can be downloaded here
- Cross-platform CLI for Azure DevOps (tfx-cli) to package your extensions.
- tfx-cli can be installed using
npm
, a component of Node.js by runningnpm i -g tfx-cli
- tfx-cli can be installed using
- A home directory for your project. This directory is referred to as
home
throughout the tutorial.
Extension file structure:
|--- README.md
|--- sdk
|--- node_modules
|--- scripts
|--- VSS.SDK.min.js
|--- img
|--- logo.png
|--- scripts
|--- hello-world.html // html page to be used for your widget
|--- vss-extension.json // extension's manifest
- The first part of this guide shows you how to create a new widget, which prints a simple "Hello World" message.
- The second part builds on the first one by adding a call to an Azure DevOps REST API.
- The third part explains how to add configuration to your widget.
Note
If you're in a hurry and want to get your hands on the code right away, you can download the samples here.
Once downloaded, go to the widgets
folder, then follow Step 6 and Step 7 directly to publish the sample extension which has the three sample widgets of varying complexities.
Get started with some basic styles for widgets that we provide out of the box for you and some guidance on widget structure.
This part presents a widget that prints "Hello World" using JavaScript.
The core SDK script, VSS.SDK.min.js
, enables web extensions to communicate to the host Azure DevOps frame. The script does operations like initializing, notifying extension is loaded, or getting context about the current page.
Get the Client SDK VSS.SDK.min.js
file and add it to your web app. Place it in the home/sdk/scripts
folder.
Use the 'npm install' command to retrieve the SDK:
npm install vss-web-extension-sdk
To learn more about the SDK, visit the Client SDK GitHub Page.
Your HTML page is the glue that holds your layout together and includes references to CSS and JavaScript.
You can name this file anything, just be sure to update all references to hello-world
with the name you use.
Your widget is HTML based and is hosted in an iframe.
Add the below HTML in hello-world.html
. We add the mandatory reference to VSS.SDK.min.js
file and include an h2
element in the , which is updated with the string Hello World in the upcoming step.
<!DOCTYPE html>
<html>
<head>
<script src="sdk/scripts/VSS.SDK.min.js"></script>
</head>
<body>
<div class="widget">
<h2 class="title"></h2>
</div>
</body>
</html>
Even though we are using an HTML file, most of the HTML head elements other than script and link are ignored by the framework.
We use JavaScript to render content in the widget. In this article, we wrap all of our JavaScript code inside a <script>
element in the HTML file. You can choose to have this code in a separate JavaScript file and refer it in the HTML file.
The code renders the content. This JavaScript code also initializes the VSS SDK, maps the code for your widget to your widget name, and notifies the extension framework of widget successes or failures.
In our case, below is the code that would print "Hello World" in the widget. Add this script
element in the head
of the HTML.
<script type="text/javascript">
VSS.init({
explicitNotifyLoaded: true,
usePlatformStyles: true
});
VSS.require("TFS/Dashboards/WidgetHelpers", function (WidgetHelpers) {
WidgetHelpers.IncludeWidgetStyles();
VSS.register("HelloWorldWidget", function () {
return {
load: function (widgetSettings) {
var $title = $('h2.title');
$title.text('Hello World');
return WidgetHelpers.WidgetStatusHelper.Success();
}
}
});
VSS.notifyLoadSucceeded();
});
</script>
VSS.init
initializes the handshake between the iframe hosting the widget and the host frame.
We pass explicitNotifyLoaded: true
so that the widget can explicitly notify the host when we're done loading. This control allows us to notify load completion after ensuring that the dependent modules are loaded.
We pass usePlatformStyles: true
so that the Azure DevOps core styles for html elements (such as body, div, and so on) can be used by the Widget. If the widget prefers to not use these styles, they can pass in usePlatformStyles: false
.
VSS.require
is used to load the required VSS script libraries. A call to this method automatically loads general libraries like JQuery and JQueryUI.
In our case, we depend on the WidgetHelpers library, which is used to communicate widget status to the widget framework.
So, we pass the corresponding module name TFS/Dashboards/WidgetHelpers
and a callback to VSS.require
.
The callback is called once the module is loaded.
The callback has the rest of the JavaScript code needed for the widget. At the end of the callback, we call VSS.notifyLoadSucceeded
to notify load completion.
WidgetHelpers.IncludeWidgetStyles
includes a stylesheet with some basic css to get you started. Make sure to wrap your content inside an HTML element with class widget
to make use of these styles.
VSS.register
is used to map a function in JavaScript, which uniquely identifies the widget among the different contributions in your extension. The name should match the id
that identifies your contribution as described in Step 5. For widgets, the function that is passed to VSS.register
should return an object that satisfies the IWidget
contract,
for example, the returned object should have a load property whose value is another function that has the core logic to render the widget.
In our case, it's to update the text of the h2
element to "Hello World".
It's this function that is called when the widget framework instantiates your widget.
We use the WidgetStatusHelper
from WidgetHelpers to return the WidgetStatus
as success.
Warning
If the name used to register the widget doesn't match the ID for the contribution in the manifest, then the widget functions unexpectedly.
The vss-extension.json
should always be at the root of the folder (in this guide, HelloWorld
). For all the other files, you can place them in whatever structure you want inside the folder, just make sure to update the references appropriately in the HTML files and in the vss-extension.json
manifest.
Your logo is displayed in the Marketplace, and in the widget catalog once a user installs your extension.
You need a 98 px x 98-px catalog icon. Choose an image, name it logo.png
, and place it in the img
folder.
To support TFS 2015 Update 3, you need an additional image that is 330 px x 160 px. This preview image is shown in this catalog. Choose an image, name it preview.png
, and place it in the img
folder as before.
You can name these images however you want as long as the extension manifest in the next step is updated with the names you use.
- Every extension must have an extension manifest file
- Read the extension manifest reference
- Find out more about the contribution points in Extensibility points
Create a json file (vss-extension.json
, for example) in the home
directory with the following contents:
{
"manifestVersion": 1,
"id": "vsts-extensions-myExtensions",
"version": "1.0.0",
"name": "My First Set of Widgets",
"description": "Samples containing different widgets extending dashboards",
"publisher": "fabrikam",
"categories": ["Azure Boards"],
"targets": [
{
"id": "Microsoft.VisualStudio.Services"
}
],
"icons": {
"default": "img/logo.png"
},
"contributions": [
{
"id": "HelloWorldWidget",
"type": "ms.vss-dashboards-web.widget",
"targets": [
"ms.vss-dashboards-web.widget-catalog"
],
"properties": {
"name": "Hello World Widget",
"description": "My first widget",
"catalogIconUrl": "img/CatalogIcon.png",
"previewImageUrl": "img/preview.png",
"uri": "hello-world.html",
"supportedSizes": [
{
"rowSpan": 1,
"columnSpan": 2
}
],
"supportedScopes": ["project_team"]
}
}
],
"files": [
{
"path": "hello-world.html", "addressable": true
},
{
"path": "sdk/scripts", "addressable": true
},
{
"path": "img", "addressable": true
}
]
}
For more information about required attributes, see the Extension manifest reference
Note
The publisher here needs to be changed to your publisher name. To create a publisher now, visit Package/Publish/Install.
The icons stanza specifies the path to your extension's icon in your manifest.
Each contribution entry defines properties.
- The ID to identify your contribution. This ID should be unique within an extension. This ID should match with the name you used in Step 3 to register your widget.
- The type of contribution. For all widgets, the type should be
ms.vss-dashboards-web.widget
. - The array of targets to which the contribution is contributing. For all widgets, the target should be
[ms.vss-dashboards-web.widget-catalog]
. - The properties are objects that include properties for the contribution type. For widgets, the following properties are mandatory.
Property | Description |
---|---|
name | Name of the widget to display in the widget catalog. |
description | Description of the widget to display in the widget catalog. |
catalogIconUrl | Relative path of the catalog icon that you added in Step 4 to display in the widget catalog. The image should be 98 px x 98 px. If you've used a different folder structure or a different file name, then specify the appropriate relative path here. |
previewImageUrl | Relative path of the preview image that you added in Step 4 to display in the widget catalog for TFS 2015 Update 3 only. The image should be 330 px x 160 px. If you've used a different folder structure or a different file name, then specify the appropriate relative path here. |
uri | Relative path of the HTML file that you added in Step 1. If you've used a different folder structure or a different file name, then specify the appropriate relative path here. |
supportedSizes | Array of sizes supported by your widget. When a widget supports multiple sizes, the first size in the array is the default size of the widget. The widget size is specified for the rows and columns occupied by the widget in the dashboard grid. One row/column corresponds to 160 px. Any dimension above 1x1 gets an additional 10 px that represent the gutter between widgets. For example, a 3x2 widget is 160*3+10*2 wide and 160*2+10*1 tall. The maximum supported size is 4x4 . |
supportedScopes | At the moment, we support only team dashboards. The value has to be project_team . In the future, when we support other dashboard scopes, there'll be more options to choose from here. |
The files stanza states the files that you want to include in your package - your HTML page, your scripts, the SDK script, and your logo.
Set addressable
to true
unless you include other files that don't need to be URL-addressable.
Note
For more information about the extension manifest file, such as its properties and what they do, check out the extension manifest reference.
Once you've written your extension, the next step towards getting it into the Marketplace is to package all of your files together. All extensions are packaged as VSIX 2.0 compatible .vsix files - Microsoft provides a cross-platform command line interface (CLI) to package your extension.
You can install or update the Cross-platform CLI for Azure DevOps (tfx-cli) using npm
, a component of Node.js, from your command line.
npm i -g tfx-cli
Packaging your extension into a .vsix file is effortless once you have the tfx-cli. Go to your extension's home directory and run the following command.
tfx extension create --manifest-globs vss-extension.json
Note
An extension/integration's version must be incremented on every update.
When updating an existing extension, either update the version in the manifest or pass the --rev-version
command line switch. This increments the patch version number of your extension and save the new version to your manifest.
After you have your packaged extension in a .vsix file, you're ready to publish your extension to the Marketplace.
All extensions, including extensions from Microsoft, are identified as being provided by a publisher. If you aren't already a member of an existing publisher, you'll create one.
- Sign in to the Visual Studio Marketplace Publishing Portal
- If you aren't already a member of an existing publisher, you'll be prompted to create a publisher. If you're not prompted to create a publisher, scroll down to the bottom of the page and select Publish Extensions underneath Related Sites.
- Specify an identifier for your publisher, for example:
mycompany-myteam
- The identifier is used as the value for the
publisher
attribute in your extensions' manifest file.
- The identifier is used as the value for the
- Specify a display name for your publisher, for example:
My Team
- Specify an identifier for your publisher, for example:
- Review the Marketplace Publisher Agreement and select Create
Now your publisher is defined. In a future release, you can grant permissions to view and manage your publisher's extensions. It's easy and more secure for teams and organizations to publish extensions under a common publisher, but without the need to share a set of credentials across a set of users.
Update the vss-extension.json
manifest file in the samples to replace the dummy publisher ID fabrikam
with your publisher ID.
After creating a publisher, you can now upload your extension to the Marketplace.
- Find the Upload new extension button, navigate to your packaged .vsix file, and select upload.
You can also upload your extension via the command line by using the tfx extension publish
command instead of tfx extension create
to package and publish your extension in one step.
You can optionally use --share-with
to share your extension with one or more accounts after publishing.
You'll need a personal access token, too.
tfx extension publish --manifest-globs your-manifest.json --share-with yourOrganization
-
Go to your project in Azure DevOps,
http://dev.azure.com/{yourOrganization}/{yourProject}
-
Select Overview, and then select Dashboards.
-
Choose Add a widget.
-
Highlight your widget, and then select Add.
The widget appears on your dashboard.
Widgets can call any of the REST APIs in Azure DevOps to interact with Azure DevOps resources. In this example, we use the REST API for WorkItemTracking to fetch information about an existing query and display some query info in the widget right below the "Hello World" text.
Copy the file hello-world.html
from the previous example, and rename the copy to hello-world2.html
. Your folder now looks like below:
|--- README.md
|--- sdk
|--- node_modules
|--- scripts
|--- VSS.SDK.min.js
|--- img
|--- logo.png
|--- scripts
|--- hello-world.html // html page to be used for your widget
|--- hello-world2.html // renamed copy of hello-world.html
|--- vss-extension.json // extension's manifest
Add a new
div
element right below the h2
to hold the query information.
Update the name of the widget from HelloWorldWidget
to HelloWorldWidget2
in the line where you call VSS.register
.
This allows the framework to uniquely identify the widget within the extension.
<!DOCTYPE html>
<html>
<head>
<script src="sdk/scripts/VSS.SDK.min.js"></script>
<script type="text/javascript">
VSS.init({
explicitNotifyLoaded: true,
usePlatformStyles: true
});
VSS.require("TFS/Dashboards/WidgetHelpers", function (WidgetHelpers) {
WidgetHelpers.IncludeWidgetStyles();
VSS.register("HelloWorldWidget2", function () {
return {
load: function (widgetSettings) {
var $title = $('h2.title');
$title.text('Hello World');
return WidgetHelpers.WidgetStatusHelper.Success();
}
}
});
VSS.notifyLoadSucceeded();
});
</script>
</head>
<body>
<div class="widget">
<h2 class="title"></h2>
<div id="query-info-container"></div>
</div>
</body>
</html>
To enable access to Azure DevOps resources, scopes need to be specified in the extension manifest. We add the vso.work
scope to our manifest.
This scope indicates the widget needs read-only access to queries and work items. See all available scopes here.
Add the below at the end of your extension manifest.
{
...,
"scopes":[
"vso.work"
]
}
Warning
Adding or changing scopes after publishing an extension is currently not supported. If you've already uploaded your extension, remove it from the Marketplace. Go to , right-click your extension and select "Remove".
There are many client-side libraries that can be accessed via the SDK to make REST API calls in Azure DevOps. These libraries are called REST clients and are JavaScript wrappers around Ajax calls for all available server-side endpoints. You can use methods provided by these clients instead of writing Ajax calls yourself. These methods map the API responses to objects that can be consumed by your code.
In this step, we update the VSS.require
call to load TFS/WorkItemTracking/RestClient
, which provides the WorkItemTracking REST client.
We can use this REST client to get information about a query called Feedback
under the folder Shared Queries
.
Inside the function that we pass to VSS.register
, we create a variable to hold the current project ID. We need this variable to fetch the query.
We also create a new method getQueryInfo
to use the REST client. This method that is then called from the load method.
The method getClient
gives an instance of the REST client we need.
The method getQuery
returns the query wrapped in a promise.
The updated VSS.require
looks as follows:
VSS.require(["TFS/Dashboards/WidgetHelpers", "TFS/WorkItemTracking/RestClient"],
function (WidgetHelpers, TFS_Wit_WebApi) {
WidgetHelpers.IncludeWidgetStyles();
VSS.register("HelloWorldWidget2", function () {
var projectId = VSS.getWebContext().project.id;
var getQueryInfo = function (widgetSettings) {
// Get a WIT client to make REST calls to Azure DevOps Services
return TFS_Wit_WebApi.getClient().getQuery(projectId, "Shared Queries/Feedback")
.then(function (query) {
// Do something with the query
return WidgetHelpers.WidgetStatusHelper.Success();
}, function (error) {
return WidgetHelpers.WidgetStatusHelper.Failure(error.message);
});
}
return {
load: function (widgetSettings) {
// Set your title
var $title = $('h2.title');
$title.text('Hello World');
return getQueryInfo(widgetSettings);
}
}
});
VSS.notifyLoadSucceeded();
});
Notice the use of the Failure method from WidgetStatusHelper
.
It allows you to indicate to the widget framework that an error has occurred and take advantage to the standard error experience provided to all widgets.
If you do not have the
Feedback
query under theShared Queries
folder, then replaceShared Queries\Feedback
in the code with the path of a query that exists in your project.
The last step is to render the query information inside the widget.
The getQuery
function returns an object of type Contracts.QueryHierarchyItem
inside a promise.
In this example, we display the query ID, the query name, and the name of the query creator under the "Hello World" text.
Replace the // Do something with the query
comment with the below:
// Create a list with query details
var $list = $('<ul>');
$list.append($('<li>').text("Query Id: " + query.id));
$list.append($('<li>').text("Query Name: " + query.name));
$list.append($('<li>').text("Created By: " + ( query.createdBy? query.createdBy.displayName: "<unknown>" ) ) );
// Append the list to the query-info-container
var $container = $('#query-info-container');
$container.empty();
$container.append($list);
Your final hello-world2.html
is as follows:
<!DOCTYPE html>
<html>
<head>
<script src="sdk/scripts/VSS.SDK.min.js"></script>
<script type="text/javascript">
VSS.init({
explicitNotifyLoaded: true,
usePlatformStyles: true
});
VSS.require(["TFS/Dashboards/WidgetHelpers", "TFS/WorkItemTracking/RestClient"],
function (WidgetHelpers, TFS_Wit_WebApi) {
WidgetHelpers.IncludeWidgetStyles();
VSS.register("HelloWorldWidget2", function () {
var projectId = VSS.getWebContext().project.id;
var getQueryInfo = function (widgetSettings) {
// Get a WIT client to make REST calls to Azure DevOps Services
return TFS_Wit_WebApi.getClient().getQuery(projectId, "Shared Queries/Feedback")
.then(function (query) {
// Create a list with query details
var $list = $('<ul>');
$list.append($('<li>').text("Query ID: " + query.id));
$list.append($('<li>').text("Query Name: " + query.name));
$list.append($('<li>').text("Created By: " + (query.createdBy ? query.createdBy.displayName: "<unknown>") ));
// Append the list to the query-info-container
var $container = $('#query-info-container');
$container.empty();
$container.append($list);
// Use the widget helper and return success as Widget Status
return WidgetHelpers.WidgetStatusHelper.Success();
}, function (error) {
// Use the widget helper and return failure as Widget Status
return WidgetHelpers.WidgetStatusHelper.Failure(error.message);
});
}
return {
load: function (widgetSettings) {
// Set your title
var $title = $('h2.title');
$title.text('Hello World');
return getQueryInfo(widgetSettings);
}
}
});
VSS.notifyLoadSucceeded();
});
</script>
</head>
<body>
<div class="widget">
<h2 class="title"></h2>
<div id="query-info-container"></div>
</div>
</body>
</html>
In this step, we update the extension manifest to include an entry for our second widget.
Add a new contribution to the array in the contributions
property and add the new file hello-world2.html
to the array in the files property.
You need another preview image for the second widget. Name this preview2.png
and place it in the img
folder.
{
...,
"contributions":[
...,
{
"id": "HelloWorldWidget2",
"type": "ms.vss-dashboards-web.widget",
"targets": [
"ms.vss-dashboards-web.widget-catalog"
],
"properties": {
"name": "Hello World Widget 2 (with API)",
"description": "My second widget",
"previewImageUrl": "img/preview2.png",
"uri": "hello-world2.html",
"supportedSizes": [
{
"rowSpan": 1,
"columnSpan": 2
}
],
"supportedScopes": ["project_team"]
}
}
],
"files": [
{
"path": "hello-world.html", "addressable": true
},
{
"path": "hello-world2.html", "addressable": true
},
{
"path": "sdk/scripts", "addressable": true
},
{
"path": "img", "addressable": true
}
],
"scopes":[
"vso.work"
]
}
Package, publish, and share your extension. If you've already published the extension, you can repackage the extension, as described here, and directly update it to the Marketplace.
Now, go to your team dashboard at https:\//dev.azure.com/{yourOrganization}/{yourProject}
. If this page is already open, then refresh it.
Hover on the Edit button in the bottom right, and select the Add button. The widget catalog opens where you find the widget you installed.
Choose your widget and select the 'Add' button to add it to your dashboard.
In Part 2 of this guide, you saw how to create a widget that shows query information for a hard-coded query. In this part, we add the ability to configure the query to be used instead of the hard-coded one. When in configuration mode, the user gets to see a live preview of the widget based on their changes. These changes get saved to the widget on the dashboard when the user selects Save.
Implementations of Widgets and Widget Configurations are a lot alike. Both are implemented in the extension framework as contributions. Both use the same SDK file, VSS.SDK.min.js
. Both are based on HTML, JavaScript, and CSS.
Copy the file html-world2.html
from the previous example and rename the copy to hello-world3.html
. Add another HTML file called configuration.html
.
Your folder now looks like the following example:
|--- README.md
|--- sdk
|--- node_modules
|--- scripts
|--- VSS.SDK.min.js
|--- img
|--- logo.png
|--- scripts
|--- configuration.html
|--- hello-world.html // html page to be used for your widget
|--- hello-world2.html // renamed copy of hello-world.html
|--- hello-world3.html // renamed copy of hello-world2.html
|--- vss-extension.json // extension's manifest
Add the below HTML in
configuration.html
. We basically add the mandatory reference to the VSS.SDK.min.js
file and a select
element for the dropdown to select a query from a preset list.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<script src="sdk/scripts/VSS.SDK.min.js"></script>
</head>
<body>
<div class="container">
<fieldset>
<label class="label">Query: </label>
<select id="query-path-dropdown" style="margin-top:10px">
<option value="" selected disabled hidden>Please select a query</option>
<option value="Shared Queries/Feedback">Shared Queries/Feedback</option>
<option value="Shared Queries/My Bugs">Shared Queries/My Bugs</option>
<option value="Shared Queries/My Tasks">Shared Queries/My Tasks</option>
</select>
</fieldset>
</div>
</body>
</html>
Use JavaScript to render content in the widget configuration just like we did for the widget in Step 3 of Part 1 in this guide.
This JavaScript code renders content, initializes the VSS SDK, maps the code for your widget configuration to the configuration name,
and passes the configuration settings to the framework. In our case, below is the code that loads the widget configuration.
Open the file configuration.html
and the below <script>
element to the <head>
.
<script type="text/javascript">
VSS.init({
explicitNotifyLoaded: true,
usePlatformStyles: true
});
VSS.require("TFS/Dashboards/WidgetHelpers", function (WidgetHelpers) {
VSS.register("HelloWorldWidget.Configuration", function () {
var $queryDropdown = $("#query-path-dropdown");
return {
load: function (widgetSettings, widgetConfigurationContext) {
var settings = JSON.parse(widgetSettings.customSettings.data);
if (settings && settings.queryPath) {
$queryDropdown.val(settings.queryPath);
}
return WidgetHelpers.WidgetStatusHelper.Success();
},
onSave: function() {
var customSettings = {
data: JSON.stringify({
queryPath: $queryDropdown.val()
})
};
return WidgetHelpers.WidgetConfigurationSave.Valid(customSettings);
}
}
});
VSS.notifyLoadSucceeded();
});
</script>
VSS.init
, VSS.require
, and VSS.register
play the same role as they played for the widget as described in Part 1.
The only difference is that for widget configurations, the function that is passed to VSS.register
should return an object that satisfies the IWidgetConfiguration
contract.
The load
property of the IWidgetConfiguration
contract should have a function as its value.
This function has the set of steps to render the widget configuration.
In our case, it's to update the selected value of the dropdown element with existing settings if any.
This function gets called when the framework instantiates your widget configuration
The onSave
property of the IWidgetConfiguration
contract should have a function as its value.
This function gets called by the framework when the user selects Save in the configuration pane.
If the user input is ready to save, then serialize it to a string, form the custom settings
object
and use WidgetConfigurationSave.Valid()
to save the user input.
In this guide, we use JSON to serialize the user input into a string. You can choose any other way to serialize the user input to string.
It is accessible to the widget via the customSettings property of the WidgetSettings
object.
The widget has to deserialize this, which is covered in Step 4.
To enable live preview update when the user selects a query from the dropdown, we attach a change event handler to the button. This handler notifies the framework that the configuration has changed.
It also passes the customSettings
to be used for updating the preview. To notify the framework, the notify
method on the widgetConfigurationContext
needs to be called. It takes two parameters, the name of the
event, which in this case is WidgetHelpers.WidgetEvent.ConfigurationChange
, and an EventArgs
object for the event, created from the customSettings
with the help of WidgetEvent.Args
helper method.
Add the below in the function assigned to the load
property.
$queryDropdown.on("change", function () {
var customSettings = {
data: JSON.stringify({
queryPath: $queryDropdown.val()
})
};
var eventName = WidgetHelpers.WidgetEvent.ConfigurationChange;
var eventArgs = WidgetHelpers.WidgetEvent.Args(customSettings);
widgetConfigurationContext.notify(eventName, eventArgs);
});
You need to notify the framework of configuration change at least once so that the "Save" button can be enabled.
At the end, your configuration.html
looks like this:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<script src="sdk/scripts/VSS.SDK.min.js"></script>
<script type="text/javascript">
VSS.init({
explicitNotifyLoaded: true,
usePlatformStyles: true
});
VSS.require("TFS/Dashboards/WidgetHelpers", function (WidgetHelpers) {
VSS.register("HelloWorldWidget.Configuration", function () {
var $queryDropdown = $("#query-path-dropdown");
return {
load: function (widgetSettings, widgetConfigurationContext) {
var settings = JSON.parse(widgetSettings.customSettings.data);
if (settings && settings.queryPath) {
$queryDropdown.val(settings.queryPath);
}
$queryDropdown.on("change", function () {
var customSettings = {data: JSON.stringify({queryPath: $queryDropdown.val()})};
var eventName = WidgetHelpers.WidgetEvent.ConfigurationChange;
var eventArgs = WidgetHelpers.WidgetEvent.Args(customSettings);
widgetConfigurationContext.notify(eventName, eventArgs);
});
return WidgetHelpers.WidgetStatusHelper.Success();
},
onSave: function() {
var customSettings = {data: JSON.stringify({queryPath: $queryDropdown.val()})};
return WidgetHelpers.WidgetConfigurationSave.Valid(customSettings);
}
}
});
VSS.notifyLoadSucceeded();
});
</script>
</head>
<body>
<div class="container">
<fieldset>
<label class="label">Query: </label>
<select id="query-path-dropdown" style="margin-top:10px">
<option value="" selected disabled hidden>Please select a query</option>
<option value="Shared Queries/Feedback">Shared Queries/Feedback</option>
<option value="Shared Queries/My Bugs">Shared Queries/My Bugs</option>
<option value="Shared Queries/My Tasks">Shared Queries/My Tasks</option>
</select>
</fieldset>
</div>
</body>
</html>
We've set up widget configuration to store the query path selected by the user.
We now have to update the code in the widget to use this stored configuration instead of the hard-coded Shared Queries/Feedback
from the previous example.
Open the file hello-world3.html
and update the name of the widget from HelloWorldWidget2
to HelloWorldWidget3
in the line where you call VSS.register
.
This allows the framework to uniquely identify the widget within the extension.
The function mapped to HelloWorldWidget3
via VSS.register
currently returns an object that satisfies the IWidget
contract.
Since our widget now needs configuration, this function needs to be updated to return an object that satisfies the IConfigurableWidget
contract.
To do this, update the return statement to include a property called reload as below. The value for this property is a function that calls the getQueryInfo
method one more time.
This reload method gets called by the framework every time the user input changes to show the live preview. This is also called when the configuration is saved.
return {
load: function (widgetSettings) {
// Set your title
var $title = $('h2.title');
$title.text('Hello World');
return getQueryInfo(widgetSettings);
},
reload: function (widgetSettings) {
return getQueryInfo(widgetSettings);
}
}
The hard-coded query path in
getQueryInfo
should be replaced with the configured query path, which can be extracted from the parameter widgetSettings
that is passed to the method.
Add the below in the very beginning of the getQueryInfo
method and replace the hard-coded querypath with settings.queryPath
.
var settings = JSON.parse(widgetSettings.customSettings.data);
if (!settings || !settings.queryPath) {
var $container = $('#query-info-container');
$container.empty();
$container.text("Sorry nothing to show, please configure a query path.");
return WidgetHelpers.WidgetStatusHelper.Success();
}
At this point, your widget is ready to render with the configured settings.
Both the
load
and thereload
properties have a similar function. This is the case for most simple widgets. For complex widgets, there would be certain operations that you would want to run just once no matter how many times the configuration changes. Or there might be some heavy-weight operations that need not run more than once. Such operations would be part of the function corresponding to theload
property and not thereload
property.
Open the vss-extension.json
file to include two new entries to the array in the contributions
property. One for the HelloWorldWidget3
widget and the other for its configuration.
You need yet another preview image for the third widget. Name this preview3.png
and place it in the img
folder.
Update the array in the files
property to include the two new HTML files we have added in this example.
{
...
"contributions": [
... ,
{
"id": "HelloWorldWidget3",
"type": "ms.vss-dashboards-web.widget",
"targets": [
"ms.vss-dashboards-web.widget-catalog",
"fabrikam.vsts-extensions-myExtensions.HelloWorldWidget.Configuration"
],
"properties": {
"name": "Hello World Widget 3 (with config)",
"description": "My third widget",
"previewImageUrl": "img/preview3.png",
"uri": "hello-world3.html",
"supportedSizes": [
{
"rowSpan": 1,
"columnSpan": 2
}
],
"supportedScopes": ["project_team"]
}
},
{
"id": "HelloWorldWidget.Configuration",
"type": "ms.vss-dashboards-web.widget-configuration",
"targets": [ "ms.vss-dashboards-web.widget-configuration" ],
"properties": {
"name": "HelloWorldWidget Configuration",
"description": "Configures HelloWorldWidget",
"uri": "configuration.html"
}
}
],
"files": [
{
"path": "hello-world.html", "addressable": true
},
{
"path": "hello-world2.html", "addressable": true
},
{
"path": "hello-world3.html", "addressable": true
},
{
"path": "configuration.html", "addressable": true
},
{
"path": "sdk/scripts", "addressable": true
},
{
"path": "img", "addressable": true
}
],
...
}
Note the contribution for widget configuration follows a slightly different model than the widget itself. A contribution entry for widget configuration has:
- The ID to identify your contribution. This should be unique within an extension.
- The type of contribution. For all widget configurations, this should be
ms.vss-dashboards-web.widget-configuration
- The array of targets to which the contribution is contributing. For all widget configurations, this has a single entry:
ms.vss-dashboards-web.widget-configuration
. - The properties that contain a set of properties that includes name, description, and the URI of the HTML file used for configuration.
To support configuration, the widget contribution needs to be changed as well. The array of targets for the widget needs to be updated to include the ID for the configuration in the form
<publisher
>.<id for the extension
>.<id for the configuration contribution
> which in this case is fabrikam.vsts-extensions-myExtensions.HelloWorldWidget.Configuration
.
Warning
If the contribution entry for your configurable widget doesn't target the configuration using the right publisher and extension name as described previously, the configure button doesn't show up for the widget.
At the end of this part, the manifest file should contain three widgets and one configuration. You can get the complete manifest from the sample here.
If you have not published your extension yet, then read this section to package, publish, and share your extension. If you have already published the extension before this point, you can repackage the extension as described here and directly update it to the Marketplace.
Now, go to your team dashboard at https://dev.azure.com/{yourOrganization}/{yourProject}. If this page is already open, refresh it. Hover on the Edit button in the bottom right, and select the Add button. This should open the widget catalog where you find the widget you installed. Choose your widget and select the 'Add' button to add it to your dashboard.
You would see a message asking you to configure the widget.
There are two ways to configure widgets. One is to hover on the widget, select the ellipsis that appears on the top-right corner and then select Configure. The other is to select the Edit button in the bottom right of the dashboard, and then select the configure button that appears on the top-right corner of the widget. Either opens the configuration experience on the right side, and a preview of your widget in the center. Go ahead and choose a query from the dropdown. The live preview shows the updated results. Select "Save" and your widget displays the updated results.
You can add as many HTML form elements as you need in the configuration.html
for additional configuration.
There are two configurable features that are available out of the box: widget name and widget size.
By default, the name that you provide for your widget in the extension manifest is stored as the widget name for every instance of your widget that ever gets added to a dashboard.
You can allow users to configure this, so that they can add any name they want to their instance of your widget.
To allow such configuration, add isNameConfigurable:true
in the properties section for your widget in the extension manifest.
If you provide more than one entry for your widget in the supportedSizes
array in the extension manifest, then users can configure the widget's size as well.
The extension manifest for the third sample in this guide would look like the below if we enable the widget name and size configuration:
{
...
"contributions": [
... ,
{
"id": "HelloWorldWidget3",
"type": "ms.vss-dashboards-web.widget",
"targets": [
"ms.vss-dashboards-web.widget-catalog", "fabrikam.vsts-extensions-myExtensions.HelloWorldWidget.Configuration"
],
"properties": {
"name": "Hello World Widget 3 (with config)",
"description": "My third widget",
"previewImageUrl": "img/preview3.png",
"uri": "hello-world3.html",
"isNameConfigurable": true,
"supportedSizes": [
{
"rowSpan": 1,
"columnSpan": 2
},
{
"rowSpan": 2,
"columnSpan": 2
}
],
"supportedScopes": ["project_team"]
}
},
...
}
With the previous change, repackage and update your extension. Refresh the dashboard that has this widget (Hello World Widget 3 (with config)). Open the configuration mode for your widget, you should now be able to see the option to change the widget name and size.
Go ahead and choose a different size from the drop-down. You see the live preview get resized. Save the change and the widget on the dashboard is resized as well.
Warning
If you remove an already supported size, then the widget fails to load properly. We are working on a fix for a future release.
Changing the name of the widget doesn't result in any visible change in the widget. This is because our sample widgets don't display the widget name anywhere. Let us modify the sample code to display the widget name instead of the hard-coded text "Hello World".
To do this, replace the hard-coded text "Hello World" with widgetSettings.name
in the line where we set the text of the h2
element.
This ensures that the widget name gets displayed every time the widget gets loaded on page refresh.
Since we want the live preview to be updated every time the configuration changes, we should add the same code in the reload
part of our code as well.
The final return statement in hello-world3.html
is as follows:
return {
load: function (widgetSettings) {
// Set your title
var $title = $('h2.title');
$title.text(widgetSettings.name);
return getQueryInfo(widgetSettings);
},
reload: function (widgetSettings) {
// Set your title
var $title = $('h2.title');
$title.text(widgetSettings.name);
return getQueryInfo(widgetSettings);
}
}
Repackage and update your extension again. Refresh the dashboard that has this widget. Any changes to the widget name, in the configuration mode, update the widget title now.