Merge pull request #78 from PeterAntal/users/pantal/analytics-example-widget
Initial add of analytics-example-widget
This commit is contained in:
Коммит
e95b90db5f
|
@ -0,0 +1 @@
|
|||
*.json -crlf
|
|
@ -0,0 +1,12 @@
|
|||
dist/
|
||||
bower_components/
|
||||
node_modules/
|
||||
|
||||
# Custom extension manifest
|
||||
extension-*.json
|
||||
|
||||
# VSIX Packages (vset output)
|
||||
*.vsix
|
||||
|
||||
#npm log
|
||||
npm-debug.log
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017 Microsoft
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,18 @@
|
|||
# Introduction
|
||||
The purpose of this example is to provide a working VSTS Widget Extension, exercising Charting and Analytics data, implemented in Typescript with a React UI model. It provides a configurable trend chart against work item tracking data, scoped to a project, team, work item type, with support for custom filtering of results.
|
||||
|
||||
![Illustration of Analytics widget, with chart and configuration view.](.\extend-analytics-widget.png "Illustration of Analytics widget, with chart and configuration view.")
|
||||
|
||||
|
||||
# Getting Started
|
||||
Critical Steps to start testing your widget:
|
||||
|
||||
From the repo directory:
|
||||
|
||||
1. (Command Line) npm install
|
||||
2. Override the publisher in vss-extension.json with your publisher Id. Learn to create a publisher.
|
||||
3. (Command Line: Create your extension) tfx extension create --manifest-globs vss-extension.json --rev-version
|
||||
4. Publish your extension from Marketplace
|
||||
5. Share your extension to your test account
|
||||
6. From your account, "Manage Extensions", select "Boilerplate Configuration Widget" and "Install" it
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
.widget-config-component > div {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.react-container {
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
.widget-config-component select {
|
||||
width: 399px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.widget-config-component select.project-picker {
|
||||
width: 184px;
|
||||
}
|
||||
|
||||
.widget-config-component select.team-picker {
|
||||
width: 205px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.widget-config-component .field-filter-header,
|
||||
.widget-config-component .field-filter {
|
||||
color: #767676;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.widget-config-component .add-filter-row-button .bowtie-math-plus {
|
||||
color: #107c10;
|
||||
}
|
||||
|
||||
.widget-config-component .add-filter-row-button .text {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.widget-config-component .field-filter select,
|
||||
.widget-config-component .field-filter-header span {
|
||||
margin-bottom: 5px;
|
||||
margin-right:10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.widget-config-component .field-filter select.field-picker,
|
||||
.widget-config-component .field-filter-header span.filter-field-label {
|
||||
width:160px;
|
||||
}
|
||||
|
||||
.widget-config-component .field-filter select.operation-picker,
|
||||
.widget-config-component .field-filter-header span.filter-operation-label{
|
||||
width:80px;
|
||||
}
|
||||
|
||||
.widget-config-component .field-filter select.value-picker,
|
||||
.widget-config-component .field-filter-header span.filter-value-label {
|
||||
width:110px;
|
||||
}
|
||||
|
||||
.widget-config-component .field-filter select.value-picker.is-empty{
|
||||
outline: red solid 1px;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.widget-component{
|
||||
height: calc(100% - 20px);
|
||||
width: calc(100% - 20px);
|
||||
position: absolute;
|
||||
}
|
||||
.chart-component {
|
||||
height: calc(100% - 20px); /*Trim space for header */
|
||||
}
|
||||
|
||||
.chart-component>div,
|
||||
.chart-component .chart-layout-container {
|
||||
height: 100%;
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<script type="text/javascript" src="../lib/VSS.SDK.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
VSS.init({
|
||||
explicitNotifyLoaded: true,
|
||||
usePlatformStyles: true,
|
||||
configureModuleLoader: true
|
||||
});
|
||||
//The widget configuration entry point integrates the experience as a VSTS Widget Configuration.
|
||||
VSS.require(["../dist/config/WidgetConfiguration"], function () {
|
||||
// Loading succeeded
|
||||
VSS.notifyLoadSucceeded();
|
||||
});
|
||||
</script>
|
||||
<link rel="stylesheet" href="AnalyticsConfiguration.css"></link>
|
||||
</head>
|
||||
<body>
|
||||
<div class="react-container"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="text/javascript" src="../lib/VSS.SDK.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
VSS.init({
|
||||
explicitNotifyLoaded: true,
|
||||
usePlatformStyles: true,
|
||||
configureModuleLoader: true
|
||||
});
|
||||
//The widget entry point integrates the view experience as a VSTS Widget.
|
||||
VSS.require(["../dist/widget/Widget"], function () {
|
||||
VSS.notifyLoadSucceeded();
|
||||
});
|
||||
</script>
|
||||
<link rel="stylesheet" type="text/css" href="AnalyticsWidget.css"></link>
|
||||
</head>
|
||||
<body>
|
||||
<div class="widget react-container">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 59 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 1.3 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 940 B |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 2.2 KiB |
|
@ -0,0 +1,209 @@
|
|||
{
|
||||
"name": "analytics-example-widget",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@types/jquery": {
|
||||
"version": "3.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.2.16.tgz",
|
||||
"integrity": "sha512-q2WC02YxQoX2nY1HRKlYGHpGP1saPmD7GN0pwCDlTz35a4eOtJG+aHRlXyjCuXokUukSrR2aXyBhSW3j+jPc0A=="
|
||||
},
|
||||
"@types/jqueryui": {
|
||||
"version": "1.11.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.11.37.tgz",
|
||||
"integrity": "sha512-aKT7dOhxYTTkLS43q5miBeuRpfyh916sgI7m/6EJJvJR6j36e5eRORONZyzD5twTLMdMw6uYR7vqhreIHps9tw==",
|
||||
"requires": {
|
||||
"@types/jquery": "3.2.16"
|
||||
}
|
||||
},
|
||||
"@types/knockout": {
|
||||
"version": "3.4.46",
|
||||
"resolved": "https://registry.npmjs.org/@types/knockout/-/knockout-3.4.46.tgz",
|
||||
"integrity": "sha512-dsnfVF8CPQNv3mk7iQS2mGv7AAUVRcTGExbrlfwDD1AZPaV8cquHsVw3mLIFBsiHL8h4TiECiWtukqTF9dcEug=="
|
||||
},
|
||||
"@types/mousetrap": {
|
||||
"version": "1.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.5.34.tgz",
|
||||
"integrity": "sha512-a2yhRIADupQfOFM75v7GfcQQLUxU705+i/xcZ3N/3PK3Xdo31SUfuCUByWPGOHB1e38m7MxTx/D8FPVsJXZKJw=="
|
||||
},
|
||||
"@types/q": {
|
||||
"version": "0.0.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz",
|
||||
"integrity": "sha1-vShOV8hPEyXacCur/IKlMoGQwMU="
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "16.0.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.0.19.tgz",
|
||||
"integrity": "sha512-0M27ZfEYJhQYJ+uYouV7Bd70eNOCp8OxWUSc+etg33lb8Jq7rTmvBKIWUQwx+HquSsNqlk+5PAcmNTTxJKYUsg=="
|
||||
},
|
||||
"@types/requirejs": {
|
||||
"version": "2.1.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/requirejs/-/requirejs-2.1.31.tgz",
|
||||
"integrity": "sha512-b2soeyuU76rMbcRJ4e0hEl0tbMhFwZeTC0VZnfuWlfGlk6BwWNsev6kFu/twKABPX29wkX84wU2o+cEJoXsiTw=="
|
||||
},
|
||||
"asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
|
||||
},
|
||||
"core-js": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz",
|
||||
"integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY="
|
||||
},
|
||||
"create-react-class": {
|
||||
"version": "15.6.2",
|
||||
"resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.2.tgz",
|
||||
"integrity": "sha1-zx7RXxKq1/FO9fLf4F5sQvke8Co=",
|
||||
"requires": {
|
||||
"fbjs": "0.8.16",
|
||||
"loose-envify": "1.3.1",
|
||||
"object-assign": "4.1.1"
|
||||
}
|
||||
},
|
||||
"encoding": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
|
||||
"integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
|
||||
"requires": {
|
||||
"iconv-lite": "0.4.19"
|
||||
}
|
||||
},
|
||||
"fbjs": {
|
||||
"version": "0.8.16",
|
||||
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz",
|
||||
"integrity": "sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=",
|
||||
"requires": {
|
||||
"core-js": "1.2.7",
|
||||
"isomorphic-fetch": "2.2.1",
|
||||
"loose-envify": "1.3.1",
|
||||
"object-assign": "4.1.1",
|
||||
"promise": "7.3.1",
|
||||
"setimmediate": "1.0.5",
|
||||
"ua-parser-js": "0.7.17"
|
||||
}
|
||||
},
|
||||
"iconv-lite": {
|
||||
"version": "0.4.19",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
|
||||
"integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ=="
|
||||
},
|
||||
"is-stream": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
|
||||
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
|
||||
},
|
||||
"isomorphic-fetch": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz",
|
||||
"integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=",
|
||||
"requires": {
|
||||
"node-fetch": "1.7.3",
|
||||
"whatwg-fetch": "2.0.3"
|
||||
}
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
|
||||
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
|
||||
},
|
||||
"loose-envify": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
|
||||
"integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
|
||||
"requires": {
|
||||
"js-tokens": "3.0.2"
|
||||
}
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
|
||||
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
|
||||
"requires": {
|
||||
"encoding": "0.1.12",
|
||||
"is-stream": "1.1.0"
|
||||
}
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
|
||||
},
|
||||
"promise": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||
"requires": {
|
||||
"asap": "2.0.6"
|
||||
}
|
||||
},
|
||||
"prop-types": {
|
||||
"version": "15.6.0",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz",
|
||||
"integrity": "sha1-zq8IMCL8RrSjX2nhPvda7Q1jmFY=",
|
||||
"requires": {
|
||||
"fbjs": "0.8.16",
|
||||
"loose-envify": "1.3.1",
|
||||
"object-assign": "4.1.1"
|
||||
}
|
||||
},
|
||||
"react": {
|
||||
"version": "15.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-15.6.2.tgz",
|
||||
"integrity": "sha1-26BDSrQ5z+gvEI8PURZjkIF5qnI=",
|
||||
"requires": {
|
||||
"create-react-class": "15.6.2",
|
||||
"fbjs": "0.8.16",
|
||||
"loose-envify": "1.3.1",
|
||||
"object-assign": "4.1.1",
|
||||
"prop-types": "15.6.0"
|
||||
}
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "15.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz",
|
||||
"integrity": "sha1-Qc+t9pO3V/rycIRDodH9WgK+9zA=",
|
||||
"requires": {
|
||||
"fbjs": "0.8.16",
|
||||
"loose-envify": "1.3.1",
|
||||
"object-assign": "4.1.1",
|
||||
"prop-types": "15.6.0"
|
||||
}
|
||||
},
|
||||
"setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
|
||||
},
|
||||
"typescript": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-2.6.1.tgz",
|
||||
"integrity": "sha1-7znN6ierrAtQAkLWcmq5DgyEZjE="
|
||||
},
|
||||
"ua-parser-js": {
|
||||
"version": "0.7.17",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz",
|
||||
"integrity": "sha512-uRdSdu1oA1rncCQL7sCj8vSyZkgtL7faaw9Tc9rZ3mGgraQ7+Pdx7w5mnOSF3gw9ZNG6oc+KXfkon3bKuROm0g=="
|
||||
},
|
||||
"vss-web-extension-sdk": {
|
||||
"version": "4.125.2",
|
||||
"resolved": "https://registry.npmjs.org/vss-web-extension-sdk/-/vss-web-extension-sdk-4.125.2.tgz",
|
||||
"integrity": "sha512-+xkiR+Fegk7OhoPo+AZwLJOWpfqozMC5FvaOtQ7/kBD8lX+ORT7nKZsQkEuIR0FNLXGTaSTOOM9DjRfW60mc3A==",
|
||||
"requires": {
|
||||
"@types/jquery": "3.2.16",
|
||||
"@types/jqueryui": "1.11.37",
|
||||
"@types/knockout": "3.4.46",
|
||||
"@types/mousetrap": "1.5.34",
|
||||
"@types/q": "0.0.32",
|
||||
"@types/react": "16.0.19",
|
||||
"@types/requirejs": "2.1.31"
|
||||
}
|
||||
},
|
||||
"whatwg-fetch": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz",
|
||||
"integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ="
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "analytics-example-widget",
|
||||
"version": "1.0.0",
|
||||
"description": "Widget extension using VSTS Analytics and Charts with React and Typescript",
|
||||
"repository": "https://github.com/Microsoft/vsts-extension-samples",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react": "^15.6.2",
|
||||
"react-dom": "^15.6.1",
|
||||
"typescript": "^2.6.1",
|
||||
"vss-web-extension-sdk": "^4.125.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p .",
|
||||
"postbuild": "npm run gallery-publish",
|
||||
"package": "tfx extension create",
|
||||
"gallery-publish": "tfx extension publish --rev-version --token YOURPERSONALAUTHTOKENHERE",
|
||||
"clean": "rimraf ./dist && rimraf ./*.vsix"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import { WorkItemTypeField } from "../data/AnalyticsTypes";
|
||||
|
||||
export interface FieldFilterConfigurationState {
|
||||
fieldFilterRowValues: FieldFilterRowData[];
|
||||
addRow: () => void;
|
||||
}
|
||||
/**
|
||||
* Describes operators supported in the field filter.
|
||||
*/
|
||||
export interface AllowedOperator {
|
||||
DisplayText: string;
|
||||
value: string;
|
||||
}
|
||||
/**
|
||||
* Encapsulates the state, for use by both config & query execution
|
||||
*/
|
||||
export interface FieldFilterRowSettings {
|
||||
fieldReferenceName: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
|
||||
//Cached information to avoid extra View-side queries to Metadata and the Field Type entity. This runs risk of information being invalidated if a project admin re-configures an existing field(uncommon, but it does happen).
|
||||
fieldQueryName: string;
|
||||
fieldType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the core configuration state, including supporting values
|
||||
*/
|
||||
export interface FieldFilterRowData {
|
||||
/** Minimal state needed to render the view. */
|
||||
settings: FieldFilterRowSettings;
|
||||
|
||||
/** Available fields for the row */
|
||||
allowedFields: WorkItemTypeField[];
|
||||
|
||||
/** Available operators for the row */
|
||||
allowedOperators: AllowedOperator[];
|
||||
|
||||
/** Suggested values for the row */
|
||||
suggestedValues: string[];
|
||||
removeRow: () => void;
|
||||
|
||||
updateField: (field: WorkItemTypeField) => void;
|
||||
updateOperator: (operator: string) => void;
|
||||
updateValue: (value: string) => void;
|
||||
|
||||
}
|
||||
|
||||
//NOTE: Config UI state needs to provide allowed values, whereas on widget rendering, we want to use a streamlined state.
|
|
@ -0,0 +1,44 @@
|
|||
import { FieldFilterRowSettings} from "./FieldFilterContracts";
|
||||
|
||||
export interface WidgetSettings{}
|
||||
|
||||
export interface AnalyticsWidgetSettings extends WidgetSettings{
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
workItemType: string;
|
||||
|
||||
fields: FieldFilterRowSettings[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the settings are valid to save.
|
||||
*/
|
||||
export function areSettingsValid(widgetSettings:AnalyticsWidgetSettings): boolean {
|
||||
return (widgetSettings.projectId != null &&
|
||||
widgetSettings.teamId != null &&
|
||||
widgetSettings.workItemType != null &&
|
||||
widgetSettings.fields != null &&
|
||||
widgetSettings.fields.every(o =>
|
||||
o.fieldQueryName != null &&
|
||||
o.fieldReferenceName != null &&
|
||||
o.fieldType != null &&
|
||||
o.operator != null &&
|
||||
o.value != null &&
|
||||
(o.value != "" || o.fieldType == "String")) //Do not allow empty string when dealing with value types.
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export class WidgetSettingsHelper<T extends WidgetSettings> {
|
||||
public static Serialize<T>(widgetSettings: T): string{
|
||||
return JSON.stringify(widgetSettings);
|
||||
}
|
||||
|
||||
public static Parse<T>(settingsString: string): T{
|
||||
let settings= JSON.parse(settingsString);
|
||||
if(!settings){
|
||||
settings = {};
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
|
||||
import * as Chart_Contracts from "Charts/Contracts";
|
||||
import * as Chart_Service from "Charts/Services";
|
||||
|
||||
/**
|
||||
* Stateless parameters (creation-time) for constructing a ChartComponent.
|
||||
*/
|
||||
export class ChartComponentProps {
|
||||
chartOptions: Chart_Contracts.CommonChartOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* This Component class deals with Interop to the existing VSTS Charting API, which consumes a parent JQuery element to render.
|
||||
* As the chart Service Promise has latency to fulfill, there is an async risk with rendering here, currently.
|
||||
* Chart SDK will be updated to directly support this scenario.
|
||||
*/
|
||||
export class ChartComponent extends React.Component<ChartComponentProps, any> {
|
||||
private chartServicePromise: IPromise<Chart_Service.IChartsService> = Chart_Service.ChartsService.getService();
|
||||
// NOTE: The state variables here are temporary workarounds to compensate for lack of public dispose handler from Chart Service API, which is resolved with M126 binaries.
|
||||
// After chart + dispose API are exposed in updated public API, code associated with this can be removed.
|
||||
private $wrappedContainer: JQuery;
|
||||
|
||||
//Track mount state, due to async chart creation.
|
||||
private isMounted: boolean;
|
||||
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className="chart-component"></div>
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.isMounted = true;
|
||||
this.ensureChartIsInstantiated(this.props.chartOptions);
|
||||
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.ensurePriorInstancesAreCleared();
|
||||
this.isMounted = false;
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
this.ensureChartIsInstantiated(this.props.chartOptions);
|
||||
}
|
||||
|
||||
private ensureChartIsInstantiated(chartOptions: Chart_Contracts.CommonChartOptions) {
|
||||
//Due to asynchronous nature of chart rendering in relation to REACT events, some safety checks are needed, until public API can correct for async flow.
|
||||
this.chartServicePromise.then((chartService) => {
|
||||
this.ensurePriorInstancesAreCleared();
|
||||
|
||||
if(this.isMounted){
|
||||
let container = ReactDOM.findDOMNode(this);
|
||||
if(container){
|
||||
this.$wrappedContainer = $(container);
|
||||
|
||||
if (chartOptions) {
|
||||
chartOptions = this.updateChartOptions(chartOptions);
|
||||
chartService.createChart(this.$wrappedContainer, chartOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This step is neccessary to remove the chart content
|
||||
*/
|
||||
private ensurePriorInstancesAreCleared(): void {
|
||||
if (this.$wrappedContainer) {
|
||||
//Remove children of this component.
|
||||
this.$wrappedContainer.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Extensibility hook for derived type to modify chart options. */
|
||||
protected updateChartOptions(chartOptions: Chart_Contracts.CommonChartOptions): Chart_Contracts.CommonChartOptions {
|
||||
return chartOptions;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders Chart as with ChartComponent, while overriding specific settings for presentational chrome to make the best of limited space:
|
||||
* 1- suppress axis labels & ticks.
|
||||
* 2- suppress legends.
|
||||
* 3- tooltips only show a compact single row.
|
||||
* Any other options around these settings remain unmodified. */
|
||||
export class ChromelessChartComponent extends ChartComponent {
|
||||
protected updateChartOptions(chartOptions: Chart_Contracts.CommonChartOptions): Chart_Contracts.CommonChartOptions {
|
||||
chartOptions.xAxis = chartOptions.xAxis || {};
|
||||
chartOptions.xAxis.labelsEnabled = false;
|
||||
chartOptions.xAxis.markingsEnabled = false;
|
||||
|
||||
chartOptions.yAxis = chartOptions.yAxis || {};
|
||||
chartOptions.yAxis.labelsEnabled = false;
|
||||
chartOptions.yAxis.markingsEnabled = false;
|
||||
|
||||
chartOptions.legend = chartOptions.legend || {};
|
||||
chartOptions.legend.enabled = false;
|
||||
|
||||
chartOptions.tooltip = chartOptions.tooltip || {};
|
||||
chartOptions.tooltip.onlyShowFocusedSeries = true;
|
||||
return chartOptions;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import { WorkItemTypeField } from '../data/AnalyticsTypes';
|
||||
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
import * as FluxTypes from './FluxTypes';
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
import { Picker } from './PickerComponent';
|
||||
|
||||
import { AllowedOperator, FieldFilterConfigurationState, FieldFilterRowData } from '../common/FieldFilterContracts';
|
||||
|
||||
/** Simple implementation of a Field Filter Component. */
|
||||
export class FieldFilterComponent extends React.Component<FieldFilterConfigurationState, {}> {
|
||||
public render(): JSX.Element {
|
||||
|
||||
let content = [];
|
||||
content.push(<FieldFilterHeaderComponent />);
|
||||
for (let i = 0; i < this.props.fieldFilterRowValues.length; i++) {
|
||||
let rowProps = this.props.fieldFilterRowValues[i];
|
||||
content.push(<FieldFilterRowComponent {...rowProps}></FieldFilterRowComponent>);
|
||||
}
|
||||
content.push(
|
||||
<a className="add-filter-row-button" onClick={()=>{this.props.addRow();}}>
|
||||
<span role="button" className="bowtie-icon bowtie-math-plus"></span>
|
||||
<span className="text">Add criteria</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
return <div className="field-filter">
|
||||
{content}
|
||||
</div>;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export class FieldFilterRowComponent extends React.Component<FieldFilterRowData, {}> {
|
||||
public render(): JSX.Element {
|
||||
/* Note - This example is operating with a Picker control to illustrate ability to select from a known list, which does not allow for manual editing.
|
||||
VSS Combo or Fabric ComboBox controls do support hybrid models. */
|
||||
return <div className="field-filter">
|
||||
|
||||
<Picker className="field-picker" itemValues={this.props.allowedFields}
|
||||
getItemText={(field: WorkItemTypeField)=> {return field.FieldName;}}
|
||||
getItemId={(field: WorkItemTypeField)=>{ return field.FieldReferenceName;}}
|
||||
initialSelectionId={this.props.settings.fieldReferenceName}
|
||||
onChange={(value:WorkItemTypeField)=>{this.props.updateField(value);}}></Picker>
|
||||
|
||||
<Picker className="operation-picker" itemValues={this.props.allowedOperators}
|
||||
getItemText={(operator: AllowedOperator)=> {return operator.DisplayText;}}
|
||||
getItemId={(operator: AllowedOperator)=>{ return operator.value;}}
|
||||
initialSelectionId={this.props.settings.operator}
|
||||
onChange={(value:AllowedOperator)=>{this.props.updateOperator(value.value);}}></Picker>
|
||||
|
||||
|
||||
<Picker className="value-picker" itemValues={this.props.suggestedValues}
|
||||
getItemText={(value: string)=> {return value;}}
|
||||
getItemId={(value: string)=>{ return value;}}
|
||||
initialSelectionId={this.props.settings.value}
|
||||
onChange={(value:string)=>{this.props.updateValue(value);}}></Picker>
|
||||
|
||||
<a className="remove-filter-row-button " onClick={()=>{this.props.removeRow();}}><span role="button" className="bowtie-icon bowtie-edit-delete"></span></a>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a header row for Field Filter */
|
||||
export class FieldFilterHeaderComponent extends React.Component<{}, {}> {
|
||||
public render(): JSX.Element {
|
||||
return <div className="field-filter-header">
|
||||
<span className="filter-field-label">Field</span>
|
||||
<span className="filter-operation-label">Operation</span>
|
||||
<span className="filter-value-label">Value</span>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
|
||||
import { QueryExpand, QueryHierarchyItem } from "TFS/WorkItemTracking/Contracts";
|
||||
import * as FluxTypes from "./FluxTypes";
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
import { Action } from "VSS/Flux/Action";
|
||||
|
||||
export interface Props { }
|
||||
export interface State { }
|
||||
|
||||
export interface ConfigurationState extends State {}
|
||||
|
||||
export class StoreBase<T> extends Store {
|
||||
private state: T;
|
||||
private actions: ActionsBase;
|
||||
|
||||
constructor(actions: ActionsBase, initialState: T) {
|
||||
super();
|
||||
this.actions = actions;
|
||||
this.state = initialState;
|
||||
this.actions.stateChanged.addListener((data: T) => { this.recieveAndNotify(data) });
|
||||
}
|
||||
|
||||
public getState(): T {
|
||||
return this.state
|
||||
}
|
||||
public setState(state: T) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
private recieveAndNotify(state: T) {
|
||||
this.setState(state);
|
||||
this.emitChanged();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class ActionsBase {
|
||||
public stateChanged: Action<State>;
|
||||
|
||||
constructor() {
|
||||
this.stateChanged = new Action<State>();
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionCreator<T extends State>{
|
||||
private actions: ActionsBase;
|
||||
|
||||
constructor(actions:ActionsBase){
|
||||
this.actions = actions;
|
||||
}
|
||||
|
||||
public getDefaultState(): T {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
public requestInitialState(): IPromise<T> {
|
||||
let promise = this.requestData();
|
||||
promise.then((state:T) =>{
|
||||
|
||||
//The notify here will start rendering with loaded state.
|
||||
return this.notifyListenersOfStateChange(state);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
public requestData(): IPromise<T> {
|
||||
return Q({} as T) as IPromise<T>;
|
||||
}
|
||||
|
||||
public notifyListenersOfStateChange(state: T) : void{
|
||||
this.actions.stateChanged.invoke(state);
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentBase<P extends Props,S extends State> extends React.Component<P, S> {
|
||||
protected actionCreator: ActionCreator<S>;
|
||||
protected store: StoreBase<S>;
|
||||
protected actions: ActionsBase;
|
||||
|
||||
private setStoreStateDelegate = () => this.setStoreState();
|
||||
private setStateDelegate = (state:S) => {this.setState(state)};
|
||||
|
||||
|
||||
//Set the stage for initial rendering
|
||||
public componentWillMount(): void {
|
||||
this.actions = this.createActions();
|
||||
this.actionCreator = this.createActionCreator();
|
||||
this.store = this.createStore();
|
||||
this.registerStateListener();
|
||||
|
||||
this.initiateRequest();
|
||||
}
|
||||
|
||||
private registerStateListener(){
|
||||
this.actions.stateChanged.addListener(this.setStateDelegate);
|
||||
}
|
||||
|
||||
protected initiateRequest(): void{
|
||||
this.actionCreator.requestInitialState();
|
||||
}
|
||||
|
||||
//Note: store handling with base is awkward. This part needs to be revisited.
|
||||
private setStoreState(): void {
|
||||
this.setState(this.getStoreState());
|
||||
}
|
||||
|
||||
public getStoreState() {
|
||||
return this.store.getState();
|
||||
}
|
||||
|
||||
protected createActionCreator(): ActionCreator<S> {
|
||||
return new ActionCreator<S>(this.actions);
|
||||
}
|
||||
|
||||
protected createActions(): ActionsBase {
|
||||
return new ActionsBase();
|
||||
}
|
||||
|
||||
protected createStore(): StoreBase<S> {
|
||||
return new StoreBase<S>(this.actions, this.actionCreator.getDefaultState() as S);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.store && this.store.addChangedListener(this.setStoreStateDelegate);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.store && this.store.removeChangedListener(this.setStoreStateDelegate);
|
||||
this.actions.stateChanged.removeListener(this.setStateDelegate)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
import * as FluxTypes from '../components/FluxTypes';
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
|
||||
export interface PickerProps<T> {
|
||||
/** Used for presentational styling */
|
||||
className: string;
|
||||
|
||||
itemValues: T[];
|
||||
initialSelectionId: string;
|
||||
|
||||
/**Mapping methods for translating data shapes. Model should be factored to normalize in the Action Creator layer, to cut down on UI handling. */
|
||||
getItemText: (T) => string;
|
||||
getItemId: (T) => string;
|
||||
onChange?(item: T);
|
||||
}
|
||||
|
||||
|
||||
/** Facade for combo box implementation.
|
||||
* Note: A VSS developer would most likely use a Fabric ComboBox with React or VSS Combo if operating against VSTS controls model, as part of a full fledged implementation.
|
||||
* We are omitting Fabric usage, in favor of a stock component with known limitations to avoid extra dependency & build/deploy process baggage in this sample.
|
||||
*/
|
||||
export class Picker<T> extends React.Component<PickerProps<T>, {}> {
|
||||
selectedItem: T;
|
||||
public render(): JSX.Element {
|
||||
let defaultValue = this.props.initialSelectionId != null ? this.props.initialSelectionId : undefined;
|
||||
let classes = (this.props.itemValues.length == 0) ? this.props.className + " is-empty" : this.props.className;
|
||||
return (<select className={classes} value={defaultValue} onChange={(event: React.FormEvent<HTMLSelectElement>) => this.onSelected(event)} >{this.getOptions()}</select>);
|
||||
|
||||
}
|
||||
|
||||
private getOptions() {
|
||||
let values = [];
|
||||
if (this.props.itemValues && this.props.itemValues.length > 0) {
|
||||
if (!this.props.initialSelectionId) {
|
||||
values.push(<option key={-1} selected disabled style={{ display: "none" }}></option>);
|
||||
}
|
||||
for (let i = 0; i < this.props.itemValues.length; i++) {
|
||||
let item = this.props.itemValues[i];
|
||||
values.push(<option key={i} value={this.props.getItemId(item)}>{this.props.getItemText(item)}</option>);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private onSelected(event: React.FormEvent<HTMLSelectElement>) {
|
||||
let target = (event.target as any);
|
||||
let index = target.selectedIndex;
|
||||
// The default picker implementaiton doesn't detect changes if it auto-selects initial choice. The stock impl is sad.
|
||||
if (!this.props.initialSelectionId) {
|
||||
index--;
|
||||
}
|
||||
this.selectedItem = this.props.itemValues[index];
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.selectedItem);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,421 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
import { AllowedOperator, FieldFilterRowData, FieldFilterRowSettings } from '../common/FieldFilterContracts';
|
||||
import { WorkItemField } from 'TFS/WorkItemTracking/Contracts';
|
||||
|
||||
import { ConfigurationContext } from 'VSS/Common/Contracts/Platform';
|
||||
import { Widget } from '../widget/Widget';
|
||||
import { AnalyticsWidgetSettings, WidgetSettings } from '../Common/WidgetSettings';
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Props, State, ComponentBase, ActionsBase, ActionCreator } from "../components/FluxTypes";
|
||||
import { Project, Team, WorkItemTypeField } from "../data/AnalyticsTypes";
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
import { Picker, PickerProps } from "../components/PickerComponent";
|
||||
|
||||
import { ODataClient } from "../data/OdataClient";
|
||||
import { ProjectsQuery, TeamsQuery, WitFieldsQuery } from "../data/CommonQueries";
|
||||
import { PopularValueQuery, PopularValueQueryOptions, PopularValueQueryResults } from "../data/PopularValueQuery";
|
||||
import { AnalyticsConfigState, ConfigOptions, QueryConfigProps } from "./AnalyticsConfigInterfaces";
|
||||
import { getService } from "VSS/Service";
|
||||
import { CacheableQueryService } from "../data/CacheableQueryService";
|
||||
import { MetadataQuery, MetadataInformation, hasMetadataMapping, mapReferenceNameForQuery } from "../data/MetadataQuery";
|
||||
|
||||
/** Responsible for handling events and translating them into service operations.
|
||||
*/
|
||||
export class AnalyticsConfigActionCreator extends ActionCreator<AnalyticsConfigState>{
|
||||
// Config UI state + Configuration information for the widget
|
||||
private state: AnalyticsConfigState;
|
||||
|
||||
//change notification event method monitored by Config View, which needs to communicate to widget Framework to save & repaint widget.
|
||||
private onConfigurationChange: (AnalyticsWidgetSettings) => void;
|
||||
|
||||
|
||||
constructor(actions: ActionsBase, configuration: AnalyticsWidgetSettings, onConfigurationChange: (AnalyticsWidgetSettings) => void) {
|
||||
super(actions);
|
||||
this.state = {
|
||||
configOptions: {
|
||||
projects: [],
|
||||
teams: [],
|
||||
|
||||
types: [],
|
||||
fields: [],
|
||||
typeFields: [],
|
||||
fieldFilter: {
|
||||
fieldFilterRowValues: [],
|
||||
addRow: () => {
|
||||
this.addFieldFilterRow();
|
||||
}
|
||||
}
|
||||
},
|
||||
configuration: configuration
|
||||
} as AnalyticsConfigState;
|
||||
|
||||
if (configuration.fields != null) {
|
||||
configuration.fields.map(o => {
|
||||
return {
|
||||
settings: o
|
||||
} as FieldFilterRowData;
|
||||
});
|
||||
}
|
||||
this.onConfigurationChange = onConfigurationChange;
|
||||
}
|
||||
|
||||
public getInitialState(): AnalyticsConfigState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public requestData(): IPromise<AnalyticsConfigState> {
|
||||
if (!this.state.configuration.projectId) {
|
||||
this.state.configuration.projectId = VSS.getWebContext().project.id;
|
||||
this.state.configuration.teamId = VSS.getWebContext().team.id;
|
||||
}
|
||||
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new ProjectsQuery()).then(projects => {
|
||||
this.state.configOptions.projects = projects;
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
|
||||
//If we have a project selected, keep loading on other data.
|
||||
let teamPromise = this.state.configuration.projectId ? this.loadTeams(this.state.configuration.projectId) : this.endChain();
|
||||
return teamPromise.then((teams) => {
|
||||
return this.loadTypeFields(this.state.configuration.projectId).then(() => {
|
||||
return this.state;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private loadTeams(projectId: string): IPromise<AnalyticsConfigState> {
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new TeamsQuery(projectId)).then(teams => {
|
||||
this.state.configOptions.teams = teams;
|
||||
|
||||
if (!this.state.configuration.teamId) {
|
||||
this.state.configuration.teamId = teams[0].TeamId;
|
||||
}
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
return this.state;
|
||||
});
|
||||
}
|
||||
|
||||
private loadTypeFields(projectId: string): IPromise<AnalyticsConfigState> {
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new MetadataQuery(projectId, MetadataQuery.WorkItemSnapshot)).then(metadata => {
|
||||
this.state.configOptions.metadata = metadata;
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new WitFieldsQuery(projectId)).then(typeFields => {
|
||||
this.state.configOptions.typeFields = typeFields;
|
||||
if (typeFields) {
|
||||
this.state.configOptions.types = this.filterUniqueTypes(typeFields, projectId);
|
||||
|
||||
//if work item type isn't selected, choose a default. For sample purpose, use the first type.
|
||||
if (this.state.configuration.workItemType == null) {
|
||||
if (this.state.configOptions.types.length > 0) {
|
||||
this.state.configuration.workItemType = this.state.configOptions.types[0];
|
||||
} else {
|
||||
throw "No WorkItemTypes were found on the active project.";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
this.state.configOptions.fields = this.filterFieldsOfType(typeFields, this.state.configuration.workItemType, projectId, this.state.configOptions.metadata);
|
||||
this.updateFieldFilterControlOptions();
|
||||
return this.state;
|
||||
}
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
return this.state;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
private loadSuggestedFieldValues(projectId: string, teamId: string, workItemType: string, fieldName: string): IPromise<AnalyticsConfigState> {
|
||||
let params = {
|
||||
projectId: projectId,
|
||||
teamId: teamId,
|
||||
workItemType: workItemType,
|
||||
fieldName: fieldName
|
||||
};
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new PopularValueQuery(params)).then(results => {
|
||||
//Update Fields matching the result FieldName.
|
||||
this.state.configOptions.fieldFilter.fieldFilterRowValues.forEach(o => {
|
||||
if (o.settings.fieldReferenceName == fieldName) {
|
||||
o.suggestedValues = this.sortAllowedValues(results);
|
||||
o.settings.value = (o.suggestedValues.length > 0) ? o.suggestedValues[0] : null;
|
||||
}
|
||||
});
|
||||
return this.state;
|
||||
});
|
||||
}
|
||||
|
||||
private endChain(): IPromise<AnalyticsConfigState> {
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
let state = Q(this.state);
|
||||
return state as IPromise<AnalyticsConfigState>;
|
||||
}
|
||||
|
||||
/** Notify the contained UI to update state and signal to the parent config(which will pass a state change event to Dashboard, if the config is valid) */
|
||||
public notifyListenersOfStateChange(state: AnalyticsConfigState, notifyParentOfConfigChange: boolean = true): void {
|
||||
this.exposeLatestFilters(state);
|
||||
super.notifyListenersOfStateChange(state);
|
||||
if (notifyParentOfConfigChange) {
|
||||
this.onConfigurationChange(state.configuration);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Get Unique Work Item Types from analytics-supplied list of WorkItemTypeFields. Map out the work Item Type name, filter unique, and sort. */
|
||||
private filterUniqueTypes(typeFields: WorkItemTypeField[], projectId: string): string[] {
|
||||
return typeFields
|
||||
.filter((o) => {
|
||||
//Operate at project scope.
|
||||
return o.ProjectSK == projectId;
|
||||
})
|
||||
.map((o) => {
|
||||
return o.WorkItemType
|
||||
})
|
||||
.filter((item, i, arr) => {
|
||||
return arr.indexOf(item) === i;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Fields of specified type in the supplied project, which are interesting to use in a field picker scenario.
|
||||
*/
|
||||
private filterFieldsOfType(typeFields: WorkItemTypeField[], activeTypeName: string, projectId: string, metadata: MetadataInformation) {
|
||||
return typeFields
|
||||
.filter((o) => {
|
||||
//Ensure uniqueness at project-type scope, and filter for supported fields
|
||||
return o.WorkItemType === activeTypeName
|
||||
&& o.ProjectSK === projectId
|
||||
&& hasMetadataMapping(o.FieldReferenceName, metadata)
|
||||
|
||||
&& this.isAcceptedField(o)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.FieldName.localeCompare(b.FieldName);
|
||||
});
|
||||
}
|
||||
|
||||
private sortAllowedValues(results: PopularValueQueryResults[]): string[] {
|
||||
//Ensure the values are exposed as sorted strings
|
||||
return results.map(o => {
|
||||
return (o.Value != null) ? "" + o.Value : "";
|
||||
})
|
||||
.sort((a, b) => { return a.localeCompare(b); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters for accepted types, and discards irrelevant system fields.
|
||||
* @param field
|
||||
*/
|
||||
private isAcceptedField(field: WorkItemTypeField) {
|
||||
return ((field.FieldType === "String" ||
|
||||
field.FieldType === "Integer" ||
|
||||
field.FieldType === "Double")
|
||||
&&
|
||||
(field.FieldName !== "Rev" &&
|
||||
field.FieldName !== "ID" &&
|
||||
field.FieldName !== "Title" &&
|
||||
field.FieldName !== "Watermark"));
|
||||
}
|
||||
|
||||
|
||||
/** If a new project was selected, and load information which uses project scoping. */
|
||||
public setSelectedProject(projectId: string): IPromise<AnalyticsConfigState> {
|
||||
if (projectId != this.state.configuration.projectId) {
|
||||
this.state.configuration.projectId = projectId;
|
||||
|
||||
return this.loadTeams(projectId).then(() => {
|
||||
return this.loadTypeFields(projectId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** If a new team was selected, and load information which relies on team scoping. */
|
||||
public setSelectedTeam(teamId: string) {
|
||||
if (teamId != this.state.configuration.teamId) {
|
||||
this.state.configuration.teamId = teamId;
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
/** If a new team was selected, reset all downstream state of project-team, and load information which relies on team scoping. */
|
||||
public setSelectedWorkItemType(workItemType: string) {
|
||||
if (workItemType != this.state.configuration.workItemType) {
|
||||
this.state.configuration.workItemType = workItemType;
|
||||
|
||||
this.updateFieldFilterControlOptions();
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
public loadValues(fieldName: string) {
|
||||
let queryOptions = {
|
||||
projectId: this.state.configuration.projectId,
|
||||
teamId: this.state.configuration.teamId,
|
||||
workItemType: this.state.configuration.workItemType,
|
||||
fieldName: fieldName
|
||||
};
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new PopularValueQuery(queryOptions));
|
||||
|
||||
}
|
||||
|
||||
private getAllowedOperators() {
|
||||
return [{
|
||||
DisplayText: "=",
|
||||
value: " eq ",
|
||||
}, {
|
||||
DisplayText: "<>",
|
||||
value: " ne ",
|
||||
}];
|
||||
}
|
||||
|
||||
private updateFieldFilterControlOptions() {
|
||||
let fields = this.filterFieldsOfType(this.state.configOptions.typeFields, this.state.configuration.workItemType, this.state.configuration.projectId, this.state.configOptions.metadata);
|
||||
let allowedOperators = this.getAllowedOperators();
|
||||
|
||||
//Update the field Pickers to show options relevant to this type.
|
||||
this.state.configOptions.fieldFilter.fieldFilterRowValues.forEach((o) => {
|
||||
this.populateFilterRowOptions(o, fields, allowedOperators);
|
||||
});
|
||||
}
|
||||
|
||||
private populateFilterRowOptions(rowData: FieldFilterRowData, fields: WorkItemTypeField[], allowedOperators: AllowedOperator[]): void {
|
||||
//Update field picker to reflect current Type.
|
||||
rowData.allowedFields = fields;
|
||||
rowData.allowedOperators = allowedOperators;
|
||||
|
||||
if (!rowData.settings.fieldReferenceName || rowData.allowedFields.map(o => o.FieldName).indexOf(rowData.settings.fieldReferenceName) < 0) {
|
||||
rowData.settings.fieldReferenceName = fields[0].FieldReferenceName;
|
||||
rowData.settings.operator = null;
|
||||
rowData.settings.value = null;
|
||||
}
|
||||
|
||||
//Set up a default operator if not set
|
||||
if (rowData.settings.fieldReferenceName && rowData.settings.operator == null) {
|
||||
rowData.settings.operator = allowedOperators[0].value;
|
||||
}
|
||||
|
||||
if (rowData.settings.fieldReferenceName) {
|
||||
this.loadSuggestedFieldValues(this.state.configuration.projectId, this.state.configuration.teamId, this.state.configuration.workItemType, rowData.settings.fieldReferenceName).then((values) => {
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds additional rows on the "add row event"
|
||||
*/
|
||||
public addFieldFilterRow() {
|
||||
|
||||
let row = this.addFilterRowImpl({
|
||||
fieldType: null,
|
||||
operator: null,
|
||||
value: null,
|
||||
|
||||
fieldQueryName: null,
|
||||
fieldReferenceName: null
|
||||
|
||||
});
|
||||
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for creation implementation at load and "Add" event.
|
||||
*/
|
||||
private addFilterRowImpl(settings: FieldFilterRowSettings): FieldFilterRowData {
|
||||
let row = {
|
||||
allowedFields: this.state.configOptions.fields,
|
||||
allowedOperators: this.getAllowedOperators(),
|
||||
suggestedValues: [],
|
||||
settings: settings,
|
||||
removeRow: null
|
||||
|
||||
} as FieldFilterRowData;
|
||||
|
||||
let container = this.state.configOptions.fieldFilter.fieldFilterRowValues;
|
||||
container.push(row);
|
||||
row.removeRow = () => {
|
||||
let position = container.indexOf(row);
|
||||
this.removeFieldFilterRow(position);
|
||||
};
|
||||
|
||||
row.updateField = (field: WorkItemTypeField) => {
|
||||
let position = container.indexOf(row);
|
||||
this.updateFieldFilterFieldState(position, field);
|
||||
};
|
||||
row.updateOperator = (operator: string) => {
|
||||
let position = container.indexOf(row);
|
||||
this.updateFieldFilterOperatorState(position, operator);
|
||||
};
|
||||
row.updateValue = (value: string) => {
|
||||
let position = container.indexOf(row);
|
||||
this.updateFieldFilterValueState(position, value);
|
||||
};
|
||||
|
||||
let fields = this.filterFieldsOfType(this.state.configOptions.typeFields, this.state.configuration.workItemType, this.state.configuration.projectId, this.state.configOptions.metadata);
|
||||
let allowedOperators = this.getAllowedOperators();
|
||||
this.populateFilterRowOptions(row, fields, allowedOperators);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
public removeFieldFilterRow(rowIndex: number) {
|
||||
this.state.configOptions.fieldFilter.fieldFilterRowValues.splice(rowIndex, 1);
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual state of filters to reflect new field, and then ensure the value picker settings for the current state is up to date.
|
||||
*/
|
||||
public updateFieldFilterFieldState(rowIndex: number, field: WorkItemTypeField) {
|
||||
let row = this.state.configOptions.fieldFilter.fieldFilterRowValues[rowIndex];
|
||||
let priorState = row.settings.fieldReferenceName;
|
||||
row.settings.fieldReferenceName = field.FieldReferenceName;
|
||||
row.settings.fieldType = field.FieldType;
|
||||
row.settings.value = null;
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new MetadataQuery(this.state.configuration.projectId, MetadataQuery.WorkItemSnapshot)).then(metadata => {
|
||||
row.settings.fieldQueryName = mapReferenceNameForQuery(field.FieldReferenceName, metadata);
|
||||
|
||||
if (priorState != field.FieldReferenceName) {
|
||||
this.loadSuggestedFieldValues(this.state.configuration.projectId, this.state.configuration.teamId, this.state.configuration.workItemType, field.FieldReferenceName).then(() => {
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state to reflect new operator selection
|
||||
*/
|
||||
public updateFieldFilterOperatorState(rowIndex: number, operator: string) {
|
||||
let settings = this.state.configOptions.fieldFilter.fieldFilterRowValues[rowIndex].settings;
|
||||
let priorState = settings.operator;
|
||||
settings.operator = operator;
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state to reflect new value selection
|
||||
*/
|
||||
public updateFieldFilterValueState(rowIndex: number, value: string) {
|
||||
let settings = this.state.configOptions.fieldFilter.fieldFilterRowValues[rowIndex].settings;
|
||||
let priorState = settings.value;
|
||||
settings.value = value;
|
||||
this.notifyListenersOfStateChange(this.state);
|
||||
}
|
||||
|
||||
private exposeLatestFilters(state) {
|
||||
this.state.configuration.fields = [];
|
||||
this.state.configOptions.fieldFilter.fieldFilterRowValues.forEach((o, i) => {
|
||||
this.state.configuration.fields[i] = o.settings;
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
import { WorkItemField } from 'TFS/WorkItemTracking/Contracts';
|
||||
|
||||
import { ConfigurationContext } from 'VSS/Common/Contracts/Platform';
|
||||
import { Widget } from '../widget/Widget';
|
||||
import { AnalyticsWidgetSettings, WidgetSettings } from '../common/WidgetSettings';
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Props, State, ComponentBase, ActionsBase, ActionCreator } from "../components/FluxTypes";
|
||||
import { Project, Team, WorkItemTypeField } from "../data/AnalyticsTypes";
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
import { Picker, PickerProps } from "../components/PickerComponent";
|
||||
import { FieldFilterComponent } from "../components/FieldFilterComponent";
|
||||
|
||||
import { AnalyticsConfigState, ConfigOptions, QueryConfigProps } from "./AnalyticsConfigInterfaces";
|
||||
import { AnalyticsConfigActionCreator } from "./AnalyticsConfigActionCreator";
|
||||
import { FieldFilterConfigurationState, FieldFilterRowSettings } from "../common/FieldFilterContracts";
|
||||
|
||||
|
||||
export class AnalyticsConfigComponent extends ComponentBase<QueryConfigProps, AnalyticsConfigState> {
|
||||
protected actionCreator: AnalyticsConfigActionCreator;
|
||||
|
||||
public render(): JSX.Element {
|
||||
return <div className="bowtie widget-config-component">
|
||||
<div className="project-team-selector">
|
||||
<label>Team</label>
|
||||
{this.createProjectPicker()}
|
||||
{this.createTeamPicker()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Work Items</label>
|
||||
{this.createTypesPicker()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Filters</label>
|
||||
{this.createFieldsPicker()}
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
|
||||
}
|
||||
|
||||
private createProjectPicker(): JSX.Element {
|
||||
let itemValues = [];
|
||||
let initialSelectionId = null;
|
||||
|
||||
if (this.getStoreState().configOptions && this.getStoreState().configOptions.projects) {
|
||||
itemValues = this.getStoreState().configOptions.projects;
|
||||
initialSelectionId = this.getStoreState().configuration.projectId;
|
||||
}
|
||||
|
||||
return <Picker
|
||||
className="project-picker"
|
||||
|
||||
itemValues={itemValues}
|
||||
initialSelectionId={initialSelectionId}
|
||||
getItemId={(item: Project) => item.ProjectId}
|
||||
getItemText={(item: Project) => item.ProjectName}
|
||||
onChange={(item: Project) => {
|
||||
this.actionCreator.setSelectedProject(item.ProjectId);
|
||||
}}></Picker>;
|
||||
}
|
||||
|
||||
private createTeamPicker(): JSX.Element {
|
||||
let itemValues = [];
|
||||
let initialSelectionId = null;
|
||||
|
||||
if (this.getStoreState().configOptions && this.getStoreState().configOptions.teams) {
|
||||
itemValues = this.getStoreState().configOptions.teams;
|
||||
initialSelectionId = this.getStoreState().configuration.teamId;
|
||||
}
|
||||
|
||||
return <Picker
|
||||
className="team-picker"
|
||||
itemValues={itemValues}
|
||||
initialSelectionId={initialSelectionId}
|
||||
getItemId={(item: Team) => item.TeamId}
|
||||
getItemText={(item: Team) => item.TeamName}
|
||||
onChange={(item: Team) => {
|
||||
this.actionCreator.setSelectedTeam(item.TeamId);
|
||||
}}></Picker>;
|
||||
}
|
||||
|
||||
|
||||
private createTypesPicker(): JSX.Element {
|
||||
let itemValues = [];
|
||||
let initialSelectionId = null;
|
||||
|
||||
if (this.getStoreState().configOptions && this.getStoreState().configOptions.types) {
|
||||
|
||||
//Extract the work item types, and reduce to unique set, and sort Alphabetically by name.
|
||||
itemValues = this.getStoreState().configOptions.types;
|
||||
initialSelectionId = this.getStoreState().configuration.workItemType;
|
||||
}
|
||||
|
||||
return <Picker
|
||||
className="type-picker"
|
||||
itemValues={itemValues}
|
||||
initialSelectionId={initialSelectionId}
|
||||
getItemId={(typeName: string) => typeName}
|
||||
getItemText={(typeName: string) => typeName}
|
||||
onChange={(typeName: string) => {
|
||||
this.actionCreator.setSelectedWorkItemType(typeName);
|
||||
}}></Picker>;
|
||||
}
|
||||
|
||||
private createFieldsPicker(): JSX.Element {
|
||||
let filter = {} as FieldFilterConfigurationState;
|
||||
let actOnChanges = false;
|
||||
|
||||
if (this.getStoreState().configOptions && this.getStoreState().configOptions.fieldFilter) {
|
||||
|
||||
//Extract the work item types, and reduce to unique set, and sort Alphabetically by name.
|
||||
filter = this.getStoreState().configOptions.fieldFilter;
|
||||
actOnChanges = true;
|
||||
return <FieldFilterComponent {...filter}></FieldFilterComponent>;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected createActionCreator(): ActionCreator<AnalyticsConfigState> {
|
||||
return new AnalyticsConfigActionCreator(this.actions, this.props.initialConfiguration, this.props.onChange);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
|
||||
import { ConfigurationContext } from 'VSS/Common/Contracts/Platform';
|
||||
import { Widget } from '../widget/Widget';
|
||||
import { AnalyticsWidgetSettings, WidgetSettings } from '../common/WidgetSettings';
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Props, State, ComponentBase, ActionsBase, ActionCreator } from "../components/FluxTypes";
|
||||
import { Project, Team, WorkItemTypeField } from "../data/AnalyticsTypes";
|
||||
import {FieldFilterConfigurationState} from "../common/FieldFilterContracts"
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
import { MetadataInformation} from "../data/MetadataQuery";
|
||||
|
||||
|
||||
export interface QueryConfigProps extends Props {
|
||||
onChange: (WidgetSettings) => void;
|
||||
initialConfiguration: AnalyticsWidgetSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates allowed value information in the *current* configuration.
|
||||
*/
|
||||
export interface ConfigOptions {
|
||||
projects: Project[];
|
||||
teams: Team[];
|
||||
/**
|
||||
* Describes the set of type-fields. Intermediate object used to construct lists of types and fields.
|
||||
*/
|
||||
typeFields: WorkItemTypeField[];
|
||||
|
||||
types: string[];
|
||||
fields: WorkItemTypeField[];
|
||||
/** A list of tuples covering allow values for the rows. */
|
||||
fieldFilter: FieldFilterConfigurationState;
|
||||
|
||||
metadata: MetadataInformation;
|
||||
}
|
||||
|
||||
export interface AnalyticsConfigState extends State {
|
||||
configOptions: ConfigOptions;
|
||||
configuration: AnalyticsWidgetSettings;
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
|
||||
/// <reference types="vss-web-extension-sdk" />
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
import ReactDOM = require('react-dom');
|
||||
|
||||
|
||||
import WidgetHelpers = require('TFS/Dashboards/WidgetHelpers');
|
||||
import WidgetContracts = require('TFS/Dashboards/WidgetContracts');
|
||||
import { AnalyticsWidgetSettings, WidgetSettingsHelper, areSettingsValid } from '../common/WidgetSettings';
|
||||
import { AnalyticsConfigComponent } from "./AnalyticsConfigComponent";
|
||||
|
||||
/** Demonstrates a widget configuration which:
|
||||
* 1-Deserializes existing settings
|
||||
* 2-Renders config UI with those settings
|
||||
* 3-Notifies widget contract of changes in configuration
|
||||
*/
|
||||
class WidgetConfiguration {
|
||||
private widgetConfigurationContext: WidgetContracts.IWidgetConfigurationContext;
|
||||
private settings: AnalyticsWidgetSettings;
|
||||
|
||||
public load(widgetSettings: WidgetContracts.WidgetSettings,
|
||||
widgetConfigurationContext: WidgetContracts.IWidgetConfigurationContext): IPromise<WidgetContracts.WidgetStatus> {
|
||||
|
||||
this.widgetConfigurationContext = widgetConfigurationContext;
|
||||
this.settings = WidgetSettingsHelper.Parse<AnalyticsWidgetSettings>(widgetSettings.customSettings.data);
|
||||
|
||||
var $reactContainer = $(".react-container");
|
||||
let container = $reactContainer.eq(0).get()[0];
|
||||
try {
|
||||
ReactDOM.render(<AnalyticsConfigComponent initialConfiguration={this.settings} onChange={(settings: AnalyticsWidgetSettings) => { this.onChange(settings) }} />, container) as React.Component<any, any>;
|
||||
}
|
||||
catch (e) {
|
||||
return WidgetHelpers.WidgetStatusHelper.Failure(e);
|
||||
}
|
||||
|
||||
//After all initial loading is done, signal to framework about sizing
|
||||
VSS.resize();
|
||||
return WidgetHelpers.WidgetStatusHelper.Success();
|
||||
}
|
||||
|
||||
/** Handles config contract requests to validate the state of the configuration. */
|
||||
public onSave(): IPromise<WidgetContracts.SaveStatus> {
|
||||
return WidgetHelpers.WidgetConfigurationSave.Valid(this.getCustomSettings());
|
||||
}
|
||||
|
||||
/** Handles Config contract requests for configuration. */
|
||||
public getCustomSettings(): WidgetContracts.CustomSettings {
|
||||
return {
|
||||
data: WidgetSettingsHelper.Serialize<AnalyticsWidgetSettings>(this.settings)
|
||||
};
|
||||
}
|
||||
|
||||
// Responsible for packaging up current state for notification to the config context.
|
||||
private onChange(settings: AnalyticsWidgetSettings): void {
|
||||
//Notify parent of config resize, if the # of custom fields has changed
|
||||
if(this.settings && (this.settings.fields.length != settings.fields.length)){
|
||||
VSS.resize();
|
||||
}
|
||||
|
||||
this.settings = settings;
|
||||
|
||||
|
||||
//If Settings are valid, notify the widget to repaint.
|
||||
if (areSettingsValid(this.settings)) {
|
||||
var eventName = WidgetHelpers.WidgetEvent.ConfigurationChange;
|
||||
var eventArgs = WidgetHelpers.WidgetEvent.Args(this.getCustomSettings());
|
||||
this.widgetConfigurationContext.notify(eventName, eventArgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VSS.register("AnalyticsExampleWidget.Configuration", function () {
|
||||
let widgetConfiguration = new WidgetConfiguration();
|
||||
return widgetConfiguration;
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
export interface Team {
|
||||
TeamSK: string;
|
||||
ProjectSK: string;
|
||||
TeamId: string;
|
||||
TeamName: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
ProjectSK: string;
|
||||
ProjectId: string;
|
||||
ProjectName: string;
|
||||
}
|
||||
|
||||
export interface WorkItemTypeField {
|
||||
ProjectSK: string;
|
||||
FieldName: string;
|
||||
FieldReferenceName: string;
|
||||
FieldType: string;
|
||||
WorkItemTypeCategory: string;
|
||||
WorkItemType: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsDate {
|
||||
Date: string;
|
||||
DateSK: number;
|
||||
DayName: string;
|
||||
DayShortName: string;
|
||||
DayOfWeek: string;
|
||||
DayOfMonth: string;
|
||||
DayOfYear: string;
|
||||
WeekStartingDate: string;
|
||||
WeekEndingDate: string;
|
||||
Month: string;
|
||||
MonthName: string;
|
||||
MonthShortName: string;
|
||||
MonthOfYear:number;
|
||||
YearMonth: number;
|
||||
Year: number;
|
||||
IsLastDayOfPeriod: string;
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
Very simple batch response parser. Only processes the first operation's response.
|
||||
|
||||
Expects well formed batch responses like:
|
||||
--batchresponse_08764fd4-3946-4cd6-97cf-1ec7d9740551
|
||||
Content-Type: application/http
|
||||
Content-Transfer-Encoding: binary
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; odata.metadata=minimal; odata.streaming=true
|
||||
OData-Version: 4.0
|
||||
|
||||
{"@odata.context":"https://app.me.tfsallin.net:43526/_odata/$metadata#Projects","value":[{"ProjectSK":"ed2c5089-ffba-4ffc-a6cb-3fcdc0cef924","ProjectId":"ed2c5089-ffba-4ffc-a6cb-3fcdc0cef924","ProjectName":"ODataBatchTest"}]}
|
||||
--batchresponse_08764fd4-3946-4cd6-97cf-1ec7d9740551
|
||||
*/
|
||||
export class BatchResponseParser {
|
||||
private static ResponseStatusRegex: RegExp = /^HTTP\/1\.\d (\d{3}) (.*)/i;
|
||||
private static ResponseHeaderRegex: RegExp = /^([^()<>@,;:\\"\/[\]?={} \t]+)\s?:\s?(.*)/;
|
||||
|
||||
// instance vars
|
||||
private position : number = 0;
|
||||
private content: string;
|
||||
private batchSeparator: string;
|
||||
private responseLine: any; // {StatusCode: int, StatusText: string}
|
||||
private responseHeaders: any; // {Content-Type: "", OData-Version: ""}
|
||||
private responseContent: string;
|
||||
|
||||
// supply the raw response payload content along with the batch separator (usually a guid like 08764fd4-3946-4cd6-97cf-1ec7d9740551 in example above)
|
||||
constructor(content: string, batchSeparator: string)
|
||||
{
|
||||
this.content = content;
|
||||
this.batchSeparator = batchSeparator;
|
||||
|
||||
this.parseRawResponse();
|
||||
}
|
||||
|
||||
// read and return the content from current position until next instance of {until}
|
||||
private readTo(until: string) : string {
|
||||
var start = this.position;
|
||||
var end = this.content.indexOf(until, this.position);
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
this.position = end + until.length;
|
||||
|
||||
return this.content.substring(start, end);
|
||||
}
|
||||
|
||||
private readLine(): string {
|
||||
return this.readTo("\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw response content and sets the responseLine, responseHeader, and responseContent values
|
||||
*/
|
||||
private parseRawResponse()
|
||||
{
|
||||
var responseSeparator = `--${this.batchSeparator}`;
|
||||
// read past the start separator
|
||||
this.readTo(responseSeparator);
|
||||
|
||||
// read through the emtpy line before the response line skipping batch response headers.
|
||||
this.readTo("\r\n\r\n");
|
||||
|
||||
this.responseLine = this.readResponseLine();
|
||||
this.responseHeaders = this.readResponseHeaders();
|
||||
|
||||
// read the emtpy line before the response body
|
||||
this.readLine();
|
||||
|
||||
this.responseContent = this.readLine();
|
||||
}
|
||||
|
||||
// returns response line properties { StatusCode: int, StatusText: string } or null
|
||||
private readResponseLine(): any {
|
||||
var start = this.position;
|
||||
var resLine = this.readLine();
|
||||
var match = BatchResponseParser.ResponseStatusRegex.exec(resLine);
|
||||
if (match) {
|
||||
return { StatusCode: match[1], StatusText: match[2] };
|
||||
} else {
|
||||
this.position = start; // whatever we read was not the status line
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// returns response headers like {Content-Type: string, OData-Version: string}
|
||||
private readResponseHeaders(): any {
|
||||
var headers = {};
|
||||
var start = this.position;
|
||||
var line : string;
|
||||
var match;
|
||||
|
||||
do {
|
||||
start = this.position;
|
||||
line = this.readLine();
|
||||
match = BatchResponseParser.ResponseHeaderRegex.exec(line);
|
||||
if (match !== null) {
|
||||
headers[match[1]] = match[2];
|
||||
} else {
|
||||
this.position = start; // whatever we read was not a header line
|
||||
}
|
||||
} while (line && match);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// getters
|
||||
public getResponseLine(): any {
|
||||
return this.responseLine;
|
||||
}
|
||||
public getResponseHeaders(): any {
|
||||
return this.responseHeaders;
|
||||
}
|
||||
public getResponseContent(): string {
|
||||
return this.responseContent;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import * as Events_Action from "VSS/Events/Action";
|
||||
import Service = require("VSS/Service");
|
||||
import {ICacheableQuery} from "./ICacheableQuery";
|
||||
|
||||
export interface ICacheableQueryService {
|
||||
getCacheableQueryResult<T>(query: ICacheableQuery<T>): IPromise<T>;
|
||||
}
|
||||
/**
|
||||
* Implements a cache for queries, with a lifespan tied to the Dashboard page.
|
||||
*/
|
||||
export class CacheableQueryService extends Service.VssService implements ICacheableQueryService {
|
||||
private _cache: IDictionaryStringTo<IPromise<any>>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.resetCache();
|
||||
// Note: This aspect tightly couples consumption of this service to Dashboard. We may want to decouple this relation as part of cache responsabilty in future.
|
||||
this.clearCacheOnDashboardAutoRefresh();
|
||||
}
|
||||
|
||||
public getCacheableQueryResult<T>(query: ICacheableQuery<T>): PromiseLike<T> {
|
||||
let key = query.getKey();
|
||||
let cachedResult = this.getCachedData(key);
|
||||
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
} else {
|
||||
let resultPromise = query.runQuery();
|
||||
this.setCachedData(key, resultPromise);
|
||||
return resultPromise;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Exposes cached promise for specified key. */
|
||||
protected getCachedData(key: string): IPromise<any> {
|
||||
return this._cache[key];
|
||||
}
|
||||
|
||||
/** Passes a promise to the keyed query */
|
||||
protected setCachedData(key: string, promise: IPromise<any>) {
|
||||
this._cache[key] = promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* On Dashboard auto refresh, clear the cache and get fresh data
|
||||
*/
|
||||
private clearCacheOnDashboardAutoRefresh() {
|
||||
Events_Action.getService().registerActionWorker("refreshtimer.on.refresh",
|
||||
(args: any, next: (actionArgs: any) => any) => {
|
||||
this.resetCache();
|
||||
|
||||
// continue with the chain of responsibility
|
||||
if ($.isFunction(next)) {
|
||||
next(args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetCache(): void {
|
||||
this._cache = {};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
import { ODataClient} from "./ODataClient";
|
||||
import { Project, Team, WorkItemTypeField, AnalyticsDate } from "./AnalyticsTypes";
|
||||
import { ICacheableQuery } from "./ICacheableQuery";
|
||||
|
||||
/**
|
||||
* Implements a query of available Projects
|
||||
*/
|
||||
export class ProjectsQuery implements ICacheableQuery<Project[]> {
|
||||
public getKey():string{
|
||||
return "Projects()";
|
||||
}
|
||||
|
||||
public runQuery() :IPromise<Project[]>{
|
||||
return ODataClient.getInstance().then((client) => {
|
||||
let fullQueryUrl = client.generateAccountLink("Projects");
|
||||
|
||||
return client.runGetQuery(fullQueryUrl).then((results) => {
|
||||
return results[ODataClient.valueKey];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a query of available Teams, scoped to Team
|
||||
*/
|
||||
export class TeamsQuery implements ICacheableQuery<Team[]> {
|
||||
private projectId:string;
|
||||
constructor(projectId:string){
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
public getKey():string{
|
||||
return `Teams(${this.projectId})`;
|
||||
}
|
||||
|
||||
public runQuery() :IPromise<Team[]>{
|
||||
return ODataClient.getInstance().then((client) => {
|
||||
let fullQueryUrl = client.generateProjectLink(this.projectId, "Teams");
|
||||
return client.runGetQuery(fullQueryUrl).then((results) => {
|
||||
return results[ODataClient.valueKey];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a query of available Dates
|
||||
*/
|
||||
export class WitFieldsQuery implements ICacheableQuery<WorkItemTypeField[]> {
|
||||
private projectId:string;
|
||||
constructor(projectId:string){
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
public getKey():string{
|
||||
return `WorkItemTypeFields(${this.projectId})`;
|
||||
}
|
||||
|
||||
public runQuery() :IPromise<WorkItemTypeField[]>{
|
||||
return ODataClient.getInstance().then((client) => {
|
||||
let fullQueryUrl = client.generateAccountLink("WorkItemTypeFields");
|
||||
return client.runGetQuery(fullQueryUrl).then((results) => {
|
||||
return results[ODataClient.valueKey];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a query of available Dates
|
||||
*/
|
||||
export class DatesQuery implements ICacheableQuery<AnalyticsDate[]> {
|
||||
public getKey():string{
|
||||
return `Dates()`;
|
||||
}
|
||||
|
||||
public runQuery() :IPromise<AnalyticsDate[]>{
|
||||
return ODataClient.getInstance().then((client) => {
|
||||
let fullQueryUrl = client.generateAccountLink("Dates");
|
||||
return client.runGetQuery(fullQueryUrl).then((results) => {
|
||||
return results[ODataClient.valueKey];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Expresses behavior of a data query (Analytics, VSTS REST or otherwise) for which the configuration can be uniquely identified as a string and the results can be cached as an object.
|
||||
*/
|
||||
export interface ICacheableQuery<T> {
|
||||
// Gets a unique key for describing the configuration of the query.
|
||||
getKey(): string;
|
||||
|
||||
// Runs the query and provides the results.
|
||||
runQuery(): IPromise<T>;
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
import { ODataClient } from "./ODataClient";
|
||||
import { ICacheableQuery } from "./ICacheableQuery";
|
||||
|
||||
/**
|
||||
* Expresses mapping relationship between underlying Field Names and Analytics Queryable name, where such a relation exists.
|
||||
* Does not currently cover any custom mappings on NavigationProperty and Key fields
|
||||
*/
|
||||
export interface FieldMapping {
|
||||
referenceName: string;
|
||||
queryableName: string;
|
||||
}
|
||||
|
||||
export interface MetadataInformation {
|
||||
fieldMappings: FieldMapping[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a queryable name for the supplied reference name.
|
||||
* If no such match is found, the reference name is returned.
|
||||
* @param referenceName
|
||||
* @param mapping
|
||||
*/
|
||||
export function mapReferenceNameForQuery(referenceName: string, mapping: MetadataInformation) {
|
||||
let match = mapping.fieldMappings.find(o => o.referenceName === referenceName);
|
||||
return match != null ? match.queryableName : referenceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the specified name has a mapping available.
|
||||
*/
|
||||
export function hasMetadataMapping(referenceName: string, mapping: MetadataInformation) {
|
||||
return mapping.fieldMappings.find(o => o.referenceName === referenceName)!=null;
|
||||
}
|
||||
|
||||
/** Encapsulates minimal metadata-Load/parsing logic.
|
||||
* For more advanced/general OData scenarios - OLingo V4 library is intended to provide similar, broader metadata facilities.
|
||||
* */
|
||||
export class MetadataQuery implements ICacheableQuery<MetadataInformation> {
|
||||
public static WorkItemSnapshot = "WorkItemSnapshot";
|
||||
private entity: string;
|
||||
private projectId: string;
|
||||
|
||||
|
||||
public constructor(project: string, entity: string) {
|
||||
this.entity = entity;
|
||||
this.projectId = project;
|
||||
}
|
||||
public getKey(): string {
|
||||
return `MetadataQuery(${this.entity})`;
|
||||
}
|
||||
|
||||
public runQuery(): IPromise<MetadataInformation> {
|
||||
return ODataClient.getInstance().then((client) => {
|
||||
return client.runMetadataQuery(this.projectId, `$metadata#${this.entity}`).then((results: HTMLElement) => {
|
||||
let entities = results.getElementsByTagName("EntityType");
|
||||
var mappings = []
|
||||
|
||||
//Loop through the nodelist (Not an array)
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
let entity = entities.item(i)
|
||||
let entityName = entities.item(i).getAttribute("Name");
|
||||
if (entityName === this.entity) {
|
||||
mappings = this.extractMappingsFromEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
return { fieldMappings: mappings };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private extractMappingsFromEntity(entity: Element): FieldMapping[] {
|
||||
let entityProperties = entity.getElementsByTagName("Property");
|
||||
let fieldMappings: FieldMapping[] = [];
|
||||
|
||||
//Loop through the nodelist (Not an array)
|
||||
for (let i = 0; i < entityProperties.length; i++) {
|
||||
let property = entityProperties.item(i);
|
||||
let fieldQueryingName = property.getAttribute("Name");
|
||||
|
||||
//By default, the reference name is the same as the name
|
||||
let fieldreferenceName = fieldQueryingName;
|
||||
|
||||
let annotations = property.getElementsByTagName("Annotation");
|
||||
let fieldReferenceName = this.extractRefName(annotations);
|
||||
fieldMappings.push({
|
||||
queryableName: fieldQueryingName,
|
||||
referenceName: fieldReferenceName ? fieldReferenceName : fieldQueryingName
|
||||
});
|
||||
}
|
||||
return fieldMappings;
|
||||
}
|
||||
|
||||
private extractRefName(annotations: NodeListOf<Element>) {
|
||||
let refName = "Ref.ReferenceName";
|
||||
for (let i = 0; i < annotations.length; i++) {
|
||||
let annotation = annotations.item(i);
|
||||
let term = annotation.getAttribute("Term");
|
||||
let value = annotation.getAttribute("String");
|
||||
if (term == "Ref.ReferenceName" && value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
import * as Q from 'q';
|
||||
import { authTokenManager } from "VSS/Authentication/Services";
|
||||
|
||||
/**Responsiblities:
|
||||
* 1-Provides methods for determining Analytics Service OData endpoint address.
|
||||
* 2-Constructs OData HTTP requests via GET and POST method
|
||||
*
|
||||
* Note: VSTS Reporting team intends to provide a generated OData Client API from Analytics service, on a similar pattern to VSTS REST API's,
|
||||
* however client generation for OData is not a ready capability at the time this sample was authored.
|
||||
**/
|
||||
export class ODataClient {
|
||||
private static instance: IPromise<ODataClient> = null;
|
||||
private authToken: string;
|
||||
private static oDataVersion = "v1.0";
|
||||
public static valueKey = "value";
|
||||
|
||||
private constructor(authToken: string) {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create promise to a shared Instance of client, initialized with VSS Auth token.
|
||||
*/
|
||||
public static getInstance(): IPromise<ODataClient> {
|
||||
if (ODataClient.instance) {
|
||||
return ODataClient.instance;
|
||||
} else {
|
||||
ODataClient.instance = VSS.getAccessToken().then((token) => {
|
||||
let authToken = authTokenManager.getAuthorizationHeader(token);
|
||||
return new ODataClient(authToken);
|
||||
});
|
||||
return ODataClient.instance;
|
||||
}
|
||||
}
|
||||
|
||||
public getODataEndpoint(accountName: string, projectName: string): string {
|
||||
let projectSegment = projectName != null ? `${projectName}/` : "";
|
||||
return `https://${accountName}.analytics.visualstudio.com/${projectSegment}_odata/${ODataClient.oDataVersion}/`;
|
||||
}
|
||||
|
||||
private constructJsonRequest(authToken: string, type: string, url: string): JQuery.AjaxSettings {
|
||||
return {
|
||||
type: type,
|
||||
url: url,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
beforeSend: function (xhr) {
|
||||
xhr.setRequestHeader('Authorization', authToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private constructXmlRequest(authToken: string, type: string, url: string): JQuery.AjaxSettings {
|
||||
return {
|
||||
type: type,
|
||||
url: url,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "xml",
|
||||
beforeSend: function (xhr) {
|
||||
xhr.setRequestHeader('Authorization', authToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Generates an account scoped odata url, which spans across projects.*/
|
||||
public generateAccountLink(oDataQuery: string) {
|
||||
var accountName = VSS.getWebContext().account.name;
|
||||
return this.getODataEndpoint(accountName, null) + oDataQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an OData url scoped to the specified project name or guid
|
||||
*/
|
||||
public generateProjectLink(project: string, oDataQuery: string) {
|
||||
var accountName = VSS.getWebContext().account.name;
|
||||
return this.getODataEndpoint(accountName, project) + oDataQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an OData url scoped to the current project
|
||||
*/
|
||||
public generateCurrentProjectLink(oDataQuery: string) {
|
||||
var accountName = VSS.getWebContext().account.name;
|
||||
var project = VSS.getWebContext().project.name;
|
||||
return this.getODataEndpoint(accountName, project) + oDataQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* OData traditional OData GET Queries is fine for common/simple queries, less than ~4k long.
|
||||
*/
|
||||
public runGetQuery(fullQueryUrl: string): IPromise<any> {
|
||||
return $.ajax(this.constructJsonRequest(this.authToken, "GET", fullQueryUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* OData POST Query is neccessary for long queries, particularly user config-driven options which can entail long lists of params.
|
||||
*/
|
||||
public runPostQuery(fullQueryUrl: string): IPromise<any> {
|
||||
let contentRequest = this.constructJsonRequest(this.authToken, "POST", this.generateAccountLink("$batch"));
|
||||
|
||||
let batchIdentifier = GUIDUtil.newGuid();
|
||||
contentRequest.data = this.generateODataPostPayload(fullQueryUrl, batchIdentifier);
|
||||
contentRequest.processData = false; // payload is already a string
|
||||
contentRequest.headers = {
|
||||
"Content-Type": `multipart/mixed; boundary=batch_${batchIdentifier}`,
|
||||
"Accept": `text/plain;api-version=${ODataClient.oDataVersion}`
|
||||
};
|
||||
|
||||
return $.ajax(contentRequest);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates an OData Payload, acccording to OData POST/Batch contract. Note, this only supplies a single request
|
||||
* @param getUrl The long-form URL for the request
|
||||
* @param batchIdentifier Unique identifier of this batch request
|
||||
*/
|
||||
private generateODataPostPayload(getUrl: string, batchIdentifier: string): string {
|
||||
let newLine = "\n";
|
||||
return `--batch_${batchIdentifier}` + newLine +
|
||||
"Content-Type: application/http" + newLine +
|
||||
"Content-Transfer-Encoding: binary" + newLine + newLine +
|
||||
`GET ${getUrl} HTTP/1.1` + newLine + newLine +
|
||||
`--batch_${batchIdentifier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Requests for Metadata Queries on Entities.
|
||||
*/
|
||||
public runMetadataQuery(projectName:string, entityName: string): IPromise<any> {
|
||||
return $.ajax(this.constructXmlRequest(this.authToken, "GET", this.generateProjectLink(projectName, entityName)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class GUIDUtil {
|
||||
/**
|
||||
* Returns a GUID such as xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx.
|
||||
* @return New GUID.(UUID version 4 = xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
|
||||
* @notes Disclaimer: This implementation uses non-cryptographic random number generator so absolute uniqueness is not guaranteed.
|
||||
*/
|
||||
public static newGuid(): string {
|
||||
// c.f. rfc4122 (UUID version 4 = xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
|
||||
// "Set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively"
|
||||
var clockSequenceHi = (128 + Math.floor(Math.random() * 64)).toString(16);
|
||||
return GUIDUtil.oct(8) + "-" + GUIDUtil.oct(4) + "-4" + GUIDUtil.oct(3) + "-" + clockSequenceHi + GUIDUtil.oct(2) + "-" + GUIDUtil.oct(12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated non-zero octet sequences for use with GUID generation.
|
||||
*
|
||||
* @param length Length required.
|
||||
* @return Non-Zero hex sequences.
|
||||
*/
|
||||
private static oct(length?: number): string {
|
||||
if (!length) {
|
||||
return (Math.floor(Math.random() * 0x10)).toString(16);
|
||||
}
|
||||
|
||||
var result: string = "";
|
||||
for (var i: number = 0; i < length; i++) {
|
||||
result += GUIDUtil.oct();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { ODataClient } from "./ODataClient";
|
||||
import { Project, Team, WorkItemTypeField, AnalyticsDate } from "./AnalyticsTypes";
|
||||
import { ICacheableQuery } from "./ICacheableQuery";
|
||||
|
||||
import { getService } from "VSS/Service";
|
||||
import { CacheableQueryService } from "./CacheableQueryService";
|
||||
import { MetadataQuery, mapReferenceNameForQuery } from "./MetadataQuery";
|
||||
|
||||
|
||||
export interface PopularValueQueryOptions {
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
workItemType: string;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
/** Represents a breakdown of work "effort", grouped by Date, WorkItemType and StateCategory*/
|
||||
export interface PopularValueQueryResults {
|
||||
Value: string;
|
||||
Frequency: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collects common values for the specified field.
|
||||
*/
|
||||
export class PopularValueQuery implements ICacheableQuery<PopularValueQueryResults[]> {
|
||||
private popularValueQueryOptions: PopularValueQueryOptions;
|
||||
constructor(popularValueQueryOptions: PopularValueQueryOptions) {
|
||||
this.popularValueQueryOptions = popularValueQueryOptions;
|
||||
}
|
||||
|
||||
|
||||
public getKey(): string {
|
||||
return `PopularValues(${JSON.stringify(this.popularValueQueryOptions)})`;
|
||||
}
|
||||
|
||||
public runQuery(): IPromise<PopularValueQueryResults[]> {
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new MetadataQuery(this.popularValueQueryOptions.projectId, MetadataQuery.WorkItemSnapshot)).then(result => {
|
||||
return ODataClient.getInstance().then((client) => {
|
||||
|
||||
let entity = "WorkItemSnapshot";
|
||||
let teamFilter = `Teams/any(t:t/TeamSK eq ${this.popularValueQueryOptions.teamId})`;
|
||||
let typeFilter = `(WorkItemType eq '${this.popularValueQueryOptions.workItemType}')`;
|
||||
|
||||
let filter = `${teamFilter} and ${typeFilter}`;
|
||||
let fieldQueryingname = mapReferenceNameForQuery(this.popularValueQueryOptions.fieldName, result);
|
||||
let groupFields = `${fieldQueryingname}`;
|
||||
let aggregation = `$count as Frequency`;
|
||||
let aggregationQuery = `${entity}?$apply=filter(${filter})/groupby((${groupFields}),aggregate(${aggregation}))`;
|
||||
|
||||
let fullQueryUrl = client.generateProjectLink(this.popularValueQueryOptions.projectId, aggregationQuery);
|
||||
return client.runGetQuery(fullQueryUrl).then((results: any) => {
|
||||
let resultSet = results["value"];
|
||||
if (resultSet === undefined) {
|
||||
return [];
|
||||
} else {
|
||||
resultSet.forEach(element => {
|
||||
//Re-map the field name to be "Value" for strong type consistency
|
||||
let temp = element[fieldQueryingname];
|
||||
element.Value = temp;
|
||||
});
|
||||
return resultSet;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
import { ODataClient } from "./ODataClient";
|
||||
import { ICacheableQuery } from "./ICacheableQuery";
|
||||
|
||||
import { getService } from "VSS/Service";
|
||||
import { CacheableQueryService } from "./CacheableQueryService";
|
||||
import { mapReferenceNameForQuery, MetadataInformation, MetadataQuery } from './MetadataQuery';
|
||||
|
||||
import { FieldFilterRowSettings } from "../common/FieldFilterContracts";
|
||||
|
||||
export interface BurndownQueryOptions {
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
workItemType: string;
|
||||
|
||||
fields: FieldFilterRowSettings[];
|
||||
}
|
||||
|
||||
/** Represents a breakdown of work "effort", grouped by Date, WorkItemType and StateCategory*/
|
||||
export interface GroupedWorkItemAggregation {
|
||||
DateSK: number;
|
||||
StateCategory: string;
|
||||
AggregatedValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a custom query of Burndown data, as customized by user.
|
||||
*/
|
||||
export class BurndownResultsQuery implements ICacheableQuery<GroupedWorkItemAggregation[]> {
|
||||
private burndownQueryOptions: BurndownQueryOptions
|
||||
constructor(burndownQueryOptions: BurndownQueryOptions) {
|
||||
this.burndownQueryOptions = burndownQueryOptions;
|
||||
}
|
||||
|
||||
public getKey(): string {
|
||||
return `burndown(${JSON.stringify(this.burndownQueryOptions)})`;
|
||||
}
|
||||
|
||||
public runQuery(): IPromise<GroupedWorkItemAggregation[]> {
|
||||
return ODataClient.getInstance().then((client) => {
|
||||
|
||||
let entity = "WorkItemSnapshot";
|
||||
let teamFilter = `Teams/any(t:t/TeamSK eq ${this.burndownQueryOptions.teamId})`;
|
||||
let typeFilter = `(WorkItemType eq '${this.burndownQueryOptions.workItemType}')`;
|
||||
let timeFilter = `(DateValue ge 2017-09-01Z and DateValue le 2017-11-28Z)`;
|
||||
|
||||
let filter = `${teamFilter} and ${typeFilter} and ${timeFilter}`;
|
||||
if (this.burndownQueryOptions.fields != null && this.burndownQueryOptions.fields.length > 0) {
|
||||
filter += ` and ${this.makeFilters(this.burndownQueryOptions.fields)}`;
|
||||
}
|
||||
let groupFields = `DateSK, StateCategory`;
|
||||
let aggregation = `$count as AggregatedValue`;
|
||||
let aggregationQuery = `${entity}?$apply=filter(${filter})/groupby((${groupFields}),aggregate(${aggregation}))`;
|
||||
|
||||
|
||||
let fullQueryUrl = client.generateProjectLink(this.burndownQueryOptions.projectId, aggregationQuery);
|
||||
return client.runGetQuery(fullQueryUrl).then((results: GroupedWorkItemAggregation) => {
|
||||
return results["value"];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private makeFilters(filterRow: FieldFilterRowSettings[]) {
|
||||
return `(${filterRow.map(o => { return this.makeFilter(o); }).join(" and ")})`;
|
||||
}
|
||||
|
||||
private makeFilter(filterRow: FieldFilterRowSettings) {
|
||||
let optQuotes = filterRow.fieldType === "String" ? "'" : "";
|
||||
return `(${filterRow.fieldQueryName} ${filterRow.operator} ${optQuotes}${filterRow.value}${optQuotes})`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import { ChartComponent } from '../components/ChartComponent';
|
||||
import { AnalyticsWidgetSettings, WidgetSettings, areSettingsValid } from '../common/WidgetSettings';
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
|
||||
import { QueryExpand, WorkItemQueryResult, QueryHierarchyItem } from "TFS/WorkItemTracking/Contracts";
|
||||
import { Props, State, ComponentBase, ActionsBase, ActionCreator } from "../components/FluxTypes";
|
||||
|
||||
import { CommonChartOptions } from "Charts/Contracts";
|
||||
import { ChartsService } from "Charts/Services";
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
import { BurndownResultsQuery, BurndownQueryOptions, GroupedWorkItemAggregation } from "../data/ViewQueries";
|
||||
import { DatesQuery } from "../data/CommonQueries";
|
||||
import { CacheableQueryService } from "../data/CacheableQueryService";
|
||||
import { getService } from "VSS/Service";
|
||||
import { ViewSize, QueryViewProps, QueryViewState, WidgetMessageType } from "./AnalyticsViewContracts";
|
||||
import { ChartOptionFactory } from "./ChartOptionFactory";
|
||||
|
||||
|
||||
export class AnalyticsViewActionCreator extends ActionCreator<QueryViewState>{
|
||||
private configuration: AnalyticsWidgetSettings;
|
||||
private results: QueryViewState;
|
||||
private size: ViewSize;
|
||||
private suppressAnimation: boolean;
|
||||
|
||||
constructor(actions: ActionsBase, configuration: AnalyticsWidgetSettings, size: ViewSize, suppressAnimation: boolean) {
|
||||
super(actions);
|
||||
this.setInitialState(configuration, suppressAnimation);
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public getInitialState(): QueryViewState {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
public requestData(): IPromise<QueryViewState> {
|
||||
let context = VSS.getWebContext();
|
||||
this.results.isLoading = true;
|
||||
|
||||
if (!areSettingsValid(this.configuration)) {
|
||||
return this.packErrorMessage("This widget is not properly configured yet.");
|
||||
}
|
||||
|
||||
let querySettings = {
|
||||
projectId: this.configuration.projectId,
|
||||
teamId: this.configuration.teamId,
|
||||
workItemType: this.configuration.workItemType,
|
||||
fields: this.configuration.fields,
|
||||
} as BurndownQueryOptions;
|
||||
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new DatesQuery()).then(dates => {
|
||||
return getService(CacheableQueryService).getCacheableQueryResult(new BurndownResultsQuery(querySettings)).then(groupedWorkItemAggregation => {
|
||||
if (groupedWorkItemAggregation.length > 0) {
|
||||
this.results.chartState = new ChartOptionFactory().generateChart(this.size, groupedWorkItemAggregation, dates, this.suppressAnimation)
|
||||
} else {
|
||||
this.results.statusMessage = "0 results were found for this query.";
|
||||
this.results.messageType = WidgetMessageType.Warning;
|
||||
}
|
||||
this.results.isLoading = false;
|
||||
return this.results;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private packErrorMessage(error: string): IPromise<QueryViewState> {
|
||||
this.results = {
|
||||
isLoading: false,
|
||||
statusMessage: error,
|
||||
chartState: null
|
||||
};
|
||||
return Q(this.results) as IPromise<QueryViewState>;
|
||||
}
|
||||
|
||||
public getConfiguration() {
|
||||
return this.configuration;
|
||||
}
|
||||
|
||||
public setInitialState(configuration: AnalyticsWidgetSettings, suppressAnimation: boolean) {
|
||||
//Establish initial state;
|
||||
this.configuration = configuration;
|
||||
this.suppressAnimation = suppressAnimation;
|
||||
this.results = {
|
||||
isLoading: true,
|
||||
statusMessage: null
|
||||
} as QueryViewState;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import { ChartComponent, ChromelessChartComponent} from '../components/ChartComponent';
|
||||
import { AnalyticsWidgetSettings } from '../common/WidgetSettings';
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
|
||||
import { QueryExpand, WorkItemQueryResult, QueryHierarchyItem } from "TFS/WorkItemTracking/Contracts";
|
||||
import { Props, State, ComponentBase, ActionsBase, ActionCreator } from "../components/FluxTypes";
|
||||
|
||||
import { CommonChartOptions } from "Charts/Contracts";
|
||||
import { ChartsService } from "Charts/Services";
|
||||
|
||||
import { Store } from "VSS/Flux/Store";
|
||||
import { BurndownResultsQuery, BurndownQueryOptions, GroupedWorkItemAggregation } from "../data/ViewQueries";
|
||||
import { CacheableQueryService } from "../data/CacheableQueryService";
|
||||
import { getService } from "VSS/Service";
|
||||
import { ViewSize, QueryViewProps, QueryViewState, WidgetMessageType } from "./AnalyticsViewContracts";
|
||||
import { ChartOptionFactory } from "./ChartOptionFactory";
|
||||
import { AnalyticsViewActionCreator } from "./AnalyticsViewActionCreator";
|
||||
|
||||
export class AnalyticsViewComponent extends ComponentBase<QueryViewProps, QueryViewState> {
|
||||
private queryDataManager: AnalyticsViewActionCreator;
|
||||
|
||||
//Unlike the config scenario, the view will be repainted with new props repeatedly. Here we update the data manager with new context, and request new data on each change.
|
||||
public componentWillReceiveProps(props: QueryViewProps) {
|
||||
this.queryDataManager.setInitialState(props.widgetConfiguration, props.suppressAnimation);
|
||||
this.initiateRequest();
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
let content;
|
||||
if (!this.getStoreState().isLoading) {
|
||||
content = this.getStoreState().statusMessage ? this.renderFail() : this.renderSuccess();
|
||||
}else{
|
||||
content = this.loading();
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
public loading() {
|
||||
return <div className="empty-loading-state">Widget is running query. Please stand by.</div>;
|
||||
}
|
||||
|
||||
public renderSuccess() {
|
||||
return (
|
||||
<div className="widget-component">
|
||||
<h2 className="title">{this.props.title}</h2>
|
||||
{this.getChart()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getChart(){
|
||||
if(this.props.size.height<200 || this.props.size.width < 200){
|
||||
return <ChromelessChartComponent chartOptions={this.getStoreState().chartState} />;
|
||||
}else{
|
||||
return <ChartComponent chartOptions={this.getStoreState().chartState} />;
|
||||
}
|
||||
}
|
||||
|
||||
public renderFail() {
|
||||
return (
|
||||
<div className="widget-component">
|
||||
<div className="header-row">
|
||||
<h2 className="title">{this.getStatusHeader()}</h2>
|
||||
</div>
|
||||
<div className="error-message">{this.getStoreState().statusMessage}</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
protected createActionCreator(): ActionCreator<QueryViewState> {
|
||||
this.queryDataManager = new AnalyticsViewActionCreator(this.actions, this.props.widgetConfiguration, this.props.size, this.props.suppressAnimation);
|
||||
return this.queryDataManager;
|
||||
}
|
||||
|
||||
private getStatusHeader(): string {
|
||||
let widgetMessageType = this.getStoreState().messageType;
|
||||
if (widgetMessageType == null) { widgetMessageType = WidgetMessageType.Failed };
|
||||
return WidgetMessageType[widgetMessageType];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { CommonChartOptions } from "Charts/Contracts";
|
||||
import { AnalyticsWidgetSettings, WidgetSettings } from '../common/WidgetSettings';
|
||||
import { Props, State } from "../components/FluxTypes";
|
||||
|
||||
//Size, expressed in pixels.
|
||||
export interface ViewSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface QueryViewProps extends Props {
|
||||
title: string;
|
||||
size: ViewSize;
|
||||
widgetConfiguration: AnalyticsWidgetSettings;
|
||||
suppressAnimation: boolean;
|
||||
}
|
||||
|
||||
export interface QueryViewState extends State {
|
||||
isLoading: boolean;
|
||||
|
||||
/**
|
||||
* This is used for presenting error/status information, in lieu of a chart.
|
||||
*/
|
||||
statusMessage: string;
|
||||
|
||||
/**
|
||||
* Used for describing the type of status message. Affects the widget header.
|
||||
*/
|
||||
messageType?: WidgetMessageType;
|
||||
chartState: CommonChartOptions;
|
||||
}
|
||||
|
||||
export enum WidgetMessageType {
|
||||
Failed,
|
||||
Warning
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { ChartTypesConstants, CommonChartOptions } from 'Charts/Contracts';
|
||||
import { ViewSize, QueryViewProps, QueryViewState } from "./AnalyticsViewContracts";
|
||||
import { GroupedWorkItemAggregation } from "../data/ViewQueries";
|
||||
import { AnalyticsDate } from "../data/AnalyticsTypes";
|
||||
|
||||
/**
|
||||
* Transforms query results for presentation in a chart.
|
||||
* uses underscore.js to help with data-transformation activities.
|
||||
*/
|
||||
export class ChartOptionFactory {
|
||||
private getUniqueDates(queryResults: GroupedWorkItemAggregation[], dates: AnalyticsDate[]): AnalyticsDate[] {
|
||||
return queryResults.map((entry) => entry.DateSK)
|
||||
.filter((item, i, arr) => {
|
||||
return arr.indexOf(item) === i;
|
||||
})
|
||||
.map(dateSK => {
|
||||
return dates.find(el => dateSK == el.DateSK);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.DateSK == b.DateSK) return 0;
|
||||
return (a.DateSK < b.DateSK) ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
private getValuesOfStateCategory(stateCategory: string, queryResults: GroupedWorkItemAggregation[], dates: AnalyticsDate[]): number[] {
|
||||
let valuesInState = queryResults.filter((item, i, arr) => {
|
||||
return item.StateCategory === stateCategory;
|
||||
})
|
||||
;
|
||||
return dates.map((date) => {
|
||||
let valueAtDate = valuesInState.find(o => {
|
||||
return o.DateSK === date.DateSK && o.StateCategory === stateCategory;
|
||||
});
|
||||
return valueAtDate ? valueAtDate.AggregatedValue : 0;
|
||||
});
|
||||
}
|
||||
|
||||
private getUniqueTypes(queryResults: GroupedWorkItemAggregation[], dates: AnalyticsDate[]): string[] {
|
||||
return queryResults.map((entry) => entry.StateCategory)
|
||||
.filter((item, i, arr) => {
|
||||
return arr.indexOf(item) === i;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
public generateChart(size: ViewSize, queryResults: GroupedWorkItemAggregation[], dates: AnalyticsDate[], suppressAnimation: boolean): CommonChartOptions {
|
||||
|
||||
let uniqueDates = this.getUniqueDates(queryResults, dates);
|
||||
let uniqueTypes = this.getUniqueTypes(queryResults, dates);
|
||||
|
||||
let series = [];
|
||||
for (let i = 0; i < uniqueTypes.length; i++) {
|
||||
series.push({
|
||||
name: uniqueTypes[i],
|
||||
data: this.getValuesOfStateCategory(uniqueTypes[i], queryResults, uniqueDates)
|
||||
})
|
||||
}
|
||||
|
||||
let chartConfig = {
|
||||
chartType: ChartTypesConstants.Line,
|
||||
series: series,
|
||||
xAxis: {
|
||||
labelFormatMode: "dateTime_DayInMonth",
|
||||
labelValues: uniqueDates.map(dateString => Date.parse(dateString.Date))
|
||||
},
|
||||
suppressAnimation: suppressAnimation
|
||||
} as CommonChartOptions;
|
||||
|
||||
return chartConfig;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
|
||||
import * as Q from 'q';
|
||||
import * as React from 'react';
|
||||
import ReactDOM = require('react-dom');
|
||||
|
||||
import WidgetHelpers = require('TFS/Dashboards/WidgetHelpers');
|
||||
import WidgetContracts = require('TFS/Dashboards/WidgetContracts');
|
||||
import TFS_Wit_WebApi = require('TFS/WorkItemTracking/RestClient');
|
||||
|
||||
import { AnalyticsWidgetSettings, WidgetSettingsHelper } from '../common/WidgetSettings';
|
||||
import { ViewSize } from "./AnalyticsViewContracts";
|
||||
import { AnalyticsViewComponent } from "./AnalyticsViewComponent";
|
||||
|
||||
import { QueryExpand, QueryHierarchyItem, WorkItemQueryResult, QueryType } from "TFS/WorkItemTracking/Contracts";
|
||||
|
||||
export class Widget {
|
||||
public load(widgetSettings: WidgetContracts.WidgetSettings) {
|
||||
return this.render(widgetSettings);
|
||||
}
|
||||
|
||||
public reload(widgetSettings: WidgetContracts.WidgetSettings) {
|
||||
|
||||
return this.render(widgetSettings, true);
|
||||
}
|
||||
|
||||
private render(widgetSettings: WidgetContracts.WidgetSettings, suppressAnimation:boolean= false): IPromise<WidgetContracts.WidgetStatus> {
|
||||
let analyticsWidgetSettings = WidgetSettingsHelper.Parse<AnalyticsWidgetSettings>(widgetSettings.customSettings.data);
|
||||
let size = {
|
||||
width: widgetSettings.size.columnSpan * 160 + 10,
|
||||
height: widgetSettings.size.rowSpan * 160 + 10
|
||||
};
|
||||
|
||||
var $reactContainer = $(".react-container");
|
||||
//Ensure widget occupies full available space.
|
||||
$reactContainer
|
||||
.css("width", size.width)
|
||||
.css("height", size.height)
|
||||
|
||||
|
||||
let container = $reactContainer.eq(0).get()[0];
|
||||
ReactDOM.render(<AnalyticsViewComponent widgetConfiguration={analyticsWidgetSettings} title={widgetSettings.name} size={size} suppressAnimation={suppressAnimation} />, container) as React.Component<any, any>;
|
||||
|
||||
return WidgetHelpers.WidgetStatusHelper.Success();
|
||||
}
|
||||
}
|
||||
|
||||
WidgetHelpers.IncludeWidgetStyles();
|
||||
VSS.register("AnalyticsExampleWidget.Widget", function () {
|
||||
return new Widget();
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "amd",
|
||||
"moduleResolution": "node",
|
||||
"rootDir": "scripts/",
|
||||
"outDir": "dist/",
|
||||
"sourceMap": true,
|
||||
"baseUrl": "./dist",
|
||||
"lib": [ "es6", "dom" ],
|
||||
"jsx" :"react"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "Analytics-example-widget",
|
||||
"version": "1.0.0",
|
||||
"name": "Analytics example widget",
|
||||
"description": "An example widget demonstrating VSTS Analytics and Charting services, built with Typescript and React.",
|
||||
"publisher": "contoso",
|
||||
"targets": [
|
||||
{
|
||||
"id": "Microsoft.VisualStudio.Services"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"default": "images/logo.png"
|
||||
},
|
||||
"demands": [
|
||||
"contribution/ms.vss-dashboards-web.widget-sdk-version-2",
|
||||
"contribution/ms.vss-web.charts-service"
|
||||
],
|
||||
"contributions": [
|
||||
{
|
||||
"id": "AnalyticsExampleWidget.Widget",
|
||||
"type": "ms.vss-dashboards-web.widget",
|
||||
"targets": [
|
||||
"ms.vss-dashboards-web.widget-catalog",
|
||||
".AnalyticsExampleWidget.Configuration"
|
||||
],
|
||||
"properties": {
|
||||
"name": "Analytics example widget",
|
||||
"description": "An reusable widget example of analytics and charting service.",
|
||||
"catalogIconUrl": "images/catalogImage.png",
|
||||
"previewImageUrl": "images/previewImage.png",
|
||||
"isNameConfigurable": true,
|
||||
"uri": "content/widget.html",
|
||||
"supportedSizes": [
|
||||
{
|
||||
"rowSpan": 2,
|
||||
"columnSpan": 2
|
||||
},
|
||||
{
|
||||
"rowSpan": 1,
|
||||
"columnSpan": 1
|
||||
},
|
||||
{
|
||||
"rowSpan": 1,
|
||||
"columnSpan": 2
|
||||
},
|
||||
{
|
||||
"rowSpan": 2,
|
||||
"columnSpan": 3
|
||||
},
|
||||
{
|
||||
"rowSpan":2,
|
||||
"columnSpan": 4
|
||||
},
|
||||
{
|
||||
"rowSpan": 3,
|
||||
"columnSpan": 2
|
||||
},
|
||||
{
|
||||
"rowSpan": 3,
|
||||
"columnSpan": 3
|
||||
},
|
||||
{
|
||||
"rowSpan":3,
|
||||
"columnSpan": 4
|
||||
}
|
||||
],
|
||||
"supportedScopes": [
|
||||
"project_team"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "AnalyticsExampleWidget.Configuration",
|
||||
"type": "ms.vss-dashboards-web.widget-configuration",
|
||||
"targets": [
|
||||
"ms.vss-dashboards-web.widget-configuration"
|
||||
],
|
||||
"properties": {
|
||||
"name": "AnalyticsExampleWidget Configuration",
|
||||
"description": "Configures AnalyticsExampleWidget",
|
||||
"uri": "content/configuration.html"
|
||||
}
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "content",
|
||||
"addressable": true
|
||||
},
|
||||
{
|
||||
"path": "images",
|
||||
"addressable": true
|
||||
},
|
||||
{
|
||||
"path": "dist",
|
||||
"addressable": true
|
||||
},
|
||||
{
|
||||
"path": "node_modules/vss-web-extension-sdk/lib",
|
||||
"addressable": true,
|
||||
"packagePath": "lib"
|
||||
}
|
||||
],
|
||||
"scopes": [
|
||||
"vso.analytics"
|
||||
]
|
||||
}
|
Загрузка…
Ссылка в новой задаче