Merge pull request #78 from PeterAntal/users/pantal/analytics-example-widget

Initial add of analytics-example-widget
This commit is contained in:
Nick Kirchem 2017-11-10 14:25:10 -05:00 коммит произвёл GitHub
Родитель 60b649af60 73100c1db3
Коммит e95b90db5f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
40 изменённых файлов: 2734 добавлений и 0 удалений

1
analytics-example-widget/.gitattributes поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
*.json -crlf

12
analytics-example-widget/.gitignore поставляемый Normal file
Просмотреть файл

@ -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>

Двоичные данные
analytics-example-widget/extend-analytics-widget.PNG Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 59 KiB

Двоичные данные
analytics-example-widget/images/catalogImage.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.3 KiB

Двоичные данные
analytics-example-widget/images/logo.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 940 B

Двоичные данные
analytics-example-widget/images/previewImage.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 2.2 KiB

209
analytics-example-widget/package-lock.json сгенерированный Normal file
Просмотреть файл

@ -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"
]
}