diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9ffa8f4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+scripts/*.js
+typings
+dist
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..88dddcb
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,18 @@
+// A task runner configuration.
+{
+ "version": "0.1.0",
+ "command": "grunt",
+ "isShellCommand": true,
+ "tasks": [
+ {
+ "taskName": "build",
+ "isBuildCommand": true,
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "taskName": "publish",
+ "isBuildCommand": false,
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/configs/dev.json b/configs/dev.json
new file mode 100644
index 0000000..6084776
--- /dev/null
+++ b/configs/dev.json
@@ -0,0 +1,5 @@
+{
+ "id": "hitcount-control-dev",
+ "name": "Hitcount Control (dev)",
+ "public": false
+}
\ No newline at end of file
diff --git a/configs/release.json b/configs/release.json
new file mode 100644
index 0000000..95e22b6
--- /dev/null
+++ b/configs/release.json
@@ -0,0 +1,3 @@
+{
+ "public": true
+}
\ No newline at end of file
diff --git a/details.md b/details.md
new file mode 100644
index 0000000..1f290ce
--- /dev/null
+++ b/details.md
@@ -0,0 +1,3 @@
+# vsts-extension-ts-seed-simple #
+
+Describe your extension here. This description will be shown in the marketplace. You can use *Markdown*.
\ No newline at end of file
diff --git a/gruntfile.js b/gruntfile.js
new file mode 100644
index 0000000..36a05dc
--- /dev/null
+++ b/gruntfile.js
@@ -0,0 +1,84 @@
+module.exports = function (grunt) {
+ grunt.initConfig({
+ ts: {
+ build: {
+ tsconfig: true,
+ "outDir": "./dist/scripts"
+ },
+ buildTest: {
+ tsconfig: true,
+ "outDir": "./test/scripts",
+ src: ["./scripts/**/*.tests.ts"]
+ },
+ options: {
+ fast: 'never'
+ }
+ },
+ exec: {
+ package_dev: {
+ command: "tfx extension create --root dist --manifest-globs vss-extension.json --overrides-file configs/dev.json",
+ stdout: true,
+ stderr: true
+ },
+ package_release: {
+ command: "tfx extension create --root dist --manifest-globs vss-extension.json --overrides-file configs/release.json",
+ stdout: true,
+ stderr: true
+ },
+ publish_dev: {
+ command: "tfx extension publish --service-url https://marketplace.visualstudio.com --root dist --manifest-globs vss-extension.json --overrides-file configs/dev.json",
+ stdout: true,
+ stderr: true
+ },
+ publish_release: {
+ command: "tfx extension publish --service-url https://marketplace.visualstudio.com --root dist --manifest-globs vss-extension.json --overrides-file configs/release.json",
+ stdout: true,
+ stderr: true
+ }
+ },
+ copy: {
+ scripts: {
+ files: [{
+ expand: true,
+ flatten: true,
+ src: ["node_modules/vss-web-extension-sdk/lib/VSS.SDK.min.js"],
+ dest: "dist/scripts",
+ filter: "isFile"
+ },
+ {
+ expand: true,
+ flatten: false,
+ src: ["styles/**", "img/**", "*.html", "vss-extension.json", "*.md"],
+ dest: "dist"
+ }]
+ }
+ },
+
+ clean: ["scripts/**/*.js", "*.vsix", "dist", "test"],
+
+ karma: {
+ unit: {
+ configFile: 'karma.conf.js',
+ singleRun: true,
+ browsers: ["PhantomJS"]
+ }
+ }
+ });
+
+ grunt.loadNpmTasks("grunt-ts");
+ grunt.loadNpmTasks("grunt-exec");
+ grunt.loadNpmTasks("grunt-contrib-copy");
+ grunt.loadNpmTasks('grunt-contrib-clean');
+ grunt.loadNpmTasks('grunt-karma');
+
+ grunt.registerTask("build", ["ts:build", "copy:scripts"]);
+
+ grunt.registerTask("test", ["ts:buildTest", "karma:unit"]);
+
+ grunt.registerTask("package-dev", ["build", "exec:package_dev"]);
+ grunt.registerTask("package-release", ["build", "exec:package_release"]);
+ grunt.registerTask("publish-dev", ["package-dev", "exec:publish_dev"]);
+ grunt.registerTask("publish-release", ["package-release", "exec:publish_release"]);
+
+ grunt.registerTask("default", ["package-dev"]);
+};
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..e194ae5
--- /dev/null
+++ b/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+ Color Control
+
+
+
+
+
+
+
diff --git a/karma.conf.js b/karma.conf.js
new file mode 100644
index 0000000..cc63ae2
--- /dev/null
+++ b/karma.conf.js
@@ -0,0 +1,58 @@
+// Karma configuration
+// Generated on Thu Jun 30 2016 14:46:40 GMT-0700 (Pacific Daylight Time)
+
+module.exports = function (config) {
+ config.set({
+ // base path that will be used to resolve all patterns (eg. files, exclude)
+ basePath: '',
+
+ // frameworks to use
+ // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+ frameworks: ['mocha', 'requirejs', 'chai'],
+
+ // list of files / patterns to load in the browser
+ files: [
+ { pattern: 'test/**/*.js', included: false },
+ 'test-main.js'
+ ],
+
+ // list of files to exclude
+ exclude: [
+ ],
+
+ // preprocess matching files before serving them to the browser
+ // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
+ preprocessors: {
+ },
+
+ // test results reporter to use
+ // possible values: 'dots', 'progress'
+ // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+ reporters: ['progress'],
+
+ // web server port
+ port: 9876,
+
+ // enable / disable colors in the output (reporters and logs)
+ colors: true,
+
+ // level of logging
+ // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+ logLevel: config.LOG_INFO,
+
+ // enable / disable watching file and executing tests whenever any file changes
+ autoWatch: false,
+
+ // start these browsers
+ // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+ browsers: ['PhantomJS'],
+
+ // Continuous Integration mode
+ // if true, Karma captures browsers, runs the tests and exits
+ singleRun: true,
+
+ // Concurrency level
+ // how many browser should be started simultaneous
+ concurrency: Infinity
+ })
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6d4093a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+ "devDependencies": {
+ "chai": "^3.5.0",
+ "grunt": "~1.0.1",
+ "grunt-cli": "^1.2.0",
+ "grunt-contrib-clean": "^1.0.0",
+ "grunt-contrib-copy": "~1.0.0",
+ "grunt-exec": "~0.4.7",
+ "grunt-karma": "^2.0.0",
+ "grunt-ts": "^5.5.1",
+ "karma": "^1.1.0",
+ "karma-chai": "^0.1.0",
+ "karma-mocha": "^1.1.1",
+ "karma-phantomjs-launcher": "^1.0.1",
+ "karma-requirejs": "^1.0.0",
+ "mocha": "^2.5.3",
+ "requirejs": "^2.2.0",
+ "tfx-cli": "^0.3.13",
+ "typescript": "^1.8.10",
+ "typings": "^1.0.4",
+ "vss-web-extension-sdk": "^1.100.0"
+ },
+ "name": "vsts-extension-ts-seed-simple",
+ "private": true,
+ "version": "0.5.0",
+ "scripts": {
+ "postinstall": "typings install"
+ }
+}
diff --git a/scripts/app.ts b/scripts/app.ts
new file mode 100644
index 0000000..fac9f07
--- /dev/null
+++ b/scripts/app.ts
@@ -0,0 +1,21 @@
+///
+import { Controller } from "./control";
+import * as ExtensionContracts from "TFS/WorkItemTracking/ExtensionContracts";
+
+var control: Controller;
+
+var provider = () => {
+ return {
+ onLoaded: (workItemLoadedArgs: ExtensionContracts.IWorkItemLoadedArgs) => {
+ control = new Controller();
+ },
+ onFieldChanged: (fieldChangedArgs: ExtensionContracts.IWorkItemFieldChangedArgs) => {
+ var changedValue = fieldChangedArgs.changedFields[control.getFieldName()];
+ if (changedValue !== undefined) {
+ control.updateExternal(changedValue);
+ }
+ }
+ }
+};
+
+VSS.register("mariamclaughlin.hitcount-control-dev.hitcount-control-contribution", provider);
\ No newline at end of file
diff --git a/scripts/control.ts b/scripts/control.ts
new file mode 100644
index 0000000..b568c7a
--- /dev/null
+++ b/scripts/control.ts
@@ -0,0 +1,83 @@
+
+/** The class control.ts will orchestrate the classes of InputParser, Model and View
+ * in order to perform the required actions of the extensions.
+ */
+import * as VSSService from "VSS/Service";
+import * as WitService from "TFS/WorkItemTracking/Services";
+import * as ExtensionContracts from "TFS/WorkItemTracking/ExtensionContracts";
+import { Model } from "./model";
+import { View } from "./view";
+import { ErrorView } from "./errorView";
+import * as Q from "q";
+
+export class Controller {
+
+ private _fieldName: string = "";
+
+ private _inputs: IDictionaryStringTo;
+
+ private _model: Model;
+
+ private _view: View;
+
+ constructor() {
+ this._initialize();
+ }
+
+ private _initialize(): void {
+
+ this._inputs = VSS.getConfiguration().witInputs;
+ this._fieldName = this._inputs["FieldName"];
+
+ WitService.WorkItemFormService.getService().then(
+ (service) => {
+ Q.spread(
+ [service.getFieldValue(this._fieldName)],
+ (currentValue: number) => {
+ // Dependent on view, model, and inputParser refactoring
+ this._model = new Model(Number(currentValue));
+ this._view = new View(this._model, (val) => {
+ this._updateInternal(val);
+ }, () => {
+ this._model.incrementValue();
+ this._updateInternal(this._model.getCurrentValue());
+ }, () => {
+ this._model.decrementValue();
+ this._updateInternal(this._model.getCurrentValue());
+ });
+ }, this._handleError
+ ).then(null, this._handleError);
+ },
+ this._handleError);
+ }
+
+ private _handleError(error: string): void {
+ let errorView = new ErrorView(error);
+ }
+
+ private _updateInternal(value: number): void {
+ WitService.WorkItemFormService.getService().then(
+ (service) => {
+ service.setFieldValue(this._fieldName, value).then(
+ () => {
+ this._update(value);
+ }, this._handleError)
+ },
+ this._handleError
+ );
+ }
+
+ private _update(value: number): void {
+ this._model.setCurrentValue(Number(value));
+ this._view.update(value);
+ }
+
+ public updateExternal(value: number): void {
+ this._update(Number(value));
+ }
+
+ public getFieldName(): string {
+ return this._fieldName;
+ }
+}
+
diff --git a/scripts/errorView.ts b/scripts/errorView.ts
new file mode 100644
index 0000000..c46d932
--- /dev/null
+++ b/scripts/errorView.ts
@@ -0,0 +1,37 @@
+/***************************************************************************
+Purpose: This class is being used to get errors from an input parser and
+ a model. It takes all the errors and put them in an array in
+ order to be sent to a view to display them.
+***************************************************************************/
+
+
+// shows the errors in the control container rather than the control.
+export class ErrorView {
+
+ constructor(error: string) {
+ // container div
+ var container = $("");
+ container.addClass("container");
+
+ // create an icon and text for the error
+ var warning = $("");
+ warning.text(error);
+ warning.attr("title", error);
+ container.append(warning);
+
+
+ // include documentation link for help.
+ var help = $("");
+ help.text("See ");
+
+ var a = $(" ");
+ a.attr("href", "https://www.visualstudio.com/en-us/products/visual-studio-team-services-vs.aspx");
+ a.attr("target", "_blank");
+ a.text("Documentation.");
+
+ help.append(a);
+ container.append(help);
+
+ $('body').empty().append(container);
+ }
+}
\ No newline at end of file
diff --git a/scripts/model.tests.ts b/scripts/model.tests.ts
new file mode 100644
index 0000000..e33f8c4
--- /dev/null
+++ b/scripts/model.tests.ts
@@ -0,0 +1,32 @@
+import { expect } from 'chai';
+import { Model } from './model';
+
+describe("Model", () => {
+ let model: Model;
+
+ beforeEach(() => {
+ model = new Model(0);
+ });
+
+ it("current value of 0", () => {
+ expect(model.getCurrentValue()).to.be.deep.equal(0);
+ });
+
+ it("next value from 0", () => {
+ model.incrementValue();
+ expect(model.getCurrentValue()).to.be.deep.equal(1);
+ });
+
+ it("previous value of 0", () => {
+ model.decrementValue();
+ expect(model.getCurrentValue()).to.be.deep.equal(0);
+ });
+
+ it("previous and previous value of 20 is 18", () => {
+ model.setCurrentValue(20);
+ model.decrementValue();
+ model.decrementValue();
+ expect(model.getCurrentValue()).to.be.deep.equal(18);
+ });
+
+});
\ No newline at end of file
diff --git a/scripts/model.ts b/scripts/model.ts
new file mode 100644
index 0000000..b18c36d
--- /dev/null
+++ b/scripts/model.ts
@@ -0,0 +1,35 @@
+export class Model {
+
+ /**
+ * Model takes the initial value from Control and sets it to the current value
+ * selected in the Hit Count custom control. This will be updated in View and
+ * changes as the user increments and decrements the value.
+ */
+
+ constructor(initialValue: number) {
+ this._currentValue = initialValue;
+ }
+
+ private _currentValue: number;
+
+ public setCurrentValue(value: number) {
+ if (value === undefined) {
+ throw "Undefined value";
+ }
+ this._currentValue = value;
+ }
+
+ public decrementValue() {
+ if (this._currentValue > 0) {
+ this.setCurrentValue(this._currentValue - 1);
+ }
+ }
+
+ public incrementValue() {
+ this.setCurrentValue(this._currentValue + 1);
+ }
+
+ public getCurrentValue(): number {
+ return this._currentValue;
+ }
+}
\ No newline at end of file
diff --git a/scripts/view.ts b/scripts/view.ts
new file mode 100644
index 0000000..b70671e
--- /dev/null
+++ b/scripts/view.ts
@@ -0,0 +1,81 @@
+///
+
+import { Model } from "./model";
+
+/**
+ * Class view returns the view of a the control rendered to allow
+ * the user to change the value.
+ */
+
+export class View {
+
+ private currentValue: string = "";
+
+ constructor(private model: Model, private onInputChanged: Function, private onUpTick: Function, private onDownTick: Function) {
+ this._init();
+ }
+
+ private _init(): void {
+
+ var container = $("");
+ container.addClass("container combo input-text-box emptyBorder");
+
+ var hitcount = $("").attr("type", "string");
+ hitcount.addClass("wrap");
+ container.append(hitcount);
+
+ this.currentValue = String(this.model.getCurrentValue());
+
+ hitcount.val(this.currentValue);
+ hitcount.attr("aria-valuenow", this.currentValue);
+ hitcount.change( () => {
+ this._inputChanged();
+ }).bind('keydown', (evt: JQueryKeyEventObject) => {
+ if (evt.keyCode == 38) {
+ if (this.onUpTick) {
+ this.onUpTick();
+ evt.preventDefault();
+ }
+ }
+ else if (evt.keyCode == 40) {
+ if (this.onDownTick) {
+ this.onDownTick();
+ evt.preventDefault();
+ }
+ }
+ });
+
+ var uptick = $("");
+ uptick.click( () => {
+ this.onUpTick();
+ });
+
+ uptick.addClass("bowtie-icon bowtie-arrow-up");
+
+
+ var downtick = $("");
+ downtick.click( () => {
+ this.onDownTick();
+ });
+
+ downtick.addClass("bowtie-icon bowtie-arrow-down");
+ container.append(downtick);
+ container.append(uptick);
+
+ $("body").append(container);
+ }
+
+ private _inputChanged(): void {
+ let newValue = $(".wrap").val();
+ if (this.onInputChanged) {
+ this.onInputChanged(newValue);
+ }
+ }
+
+ public update(value: number) {
+ this.currentValue = String(value);
+ $(".wrap").val("");
+ $(".wrap").val(this.currentValue);
+ }
+}
+
diff --git a/styles/style.css b/styles/style.css
new file mode 100644
index 0000000..a5cf378
--- /dev/null
+++ b/styles/style.css
@@ -0,0 +1,29 @@
+body {
+ font-size: 14px;
+}
+
+input:focus {
+ outline: none;
+}
+
+.bowtie-icon {
+ margin: 0 2px 2px 0;
+ padding: 5px;
+ float: right;
+ line-height: 1.5;
+ box-shadow: none;
+}
+
+input {
+ line-height: 1.8;
+ font-size: 16px;
+ border: none;
+}
+
+.wrap {
+ width: 60%;
+}
+
+.container:hover {
+ border: 1px solid lightgray;
+}
diff --git a/test-main.js b/test-main.js
new file mode 100644
index 0000000..4e01a19
--- /dev/null
+++ b/test-main.js
@@ -0,0 +1,27 @@
+var allTestFiles = [];
+var TEST_REGEXP = /(spec|tests)\.js$/i;
+
+// Get a list of all the test files to include
+Object.keys(window.__karma__.files).forEach(function (file) {
+ if (TEST_REGEXP.test(file)) {
+ // Normalize paths to RequireJS module names.
+ // If you require sub-dependencies of test files to be loaded as-is (requiring file extension)
+ // then do not normalize the paths
+ var normalizedTestModule = file.replace(/^\/base\/|\.js$/g, '');
+ allTestFiles.push(normalizedTestModule);
+ }
+})
+
+require.config({
+ // Karma serves files under /base, which is the basePath from your config file
+ baseUrl: '/base',
+
+ paths: {
+ "chai": "node_modules/chai/chai"
+ },
+
+ // dynamically load all test files
+ deps: allTestFiles,
+
+ callback: window.__karma__.start
+});
\ No newline at end of file
diff --git a/test/scripts/control.js b/test/scripts/control.js
new file mode 100644
index 0000000..e69de29
diff --git a/test/scripts/errorView.js b/test/scripts/errorView.js
new file mode 100644
index 0000000..bcf67b5
--- /dev/null
+++ b/test/scripts/errorView.js
@@ -0,0 +1,24 @@
+define(["require", "exports"], function (require, exports) {
+ "use strict";
+ var ErrorView = (function () {
+ function ErrorView(error) {
+ var container = $("");
+ container.addClass("container");
+ var warning = $("");
+ warning.text(error);
+ warning.attr("title", error);
+ container.append(warning);
+ var help = $("");
+ help.text("See ");
+ var a = $(" ");
+ a.attr("href", "https://www.visualstudio.com/en-us/products/visual-studio-team-services-vs.aspx");
+ a.attr("target", "_blank");
+ a.text("Documentation.");
+ help.append(a);
+ container.append(help);
+ $('body').empty().append(container);
+ }
+ return ErrorView;
+ }());
+ exports.ErrorView = ErrorView;
+});
diff --git a/test/scripts/model.js b/test/scripts/model.js
new file mode 100644
index 0000000..2c56125
--- /dev/null
+++ b/test/scripts/model.js
@@ -0,0 +1,30 @@
+define(["require", "exports"], function (require, exports) {
+ "use strict";
+ var Model = (function () {
+ function Model(initialValue) {
+ this._currentValue = initialValue;
+ }
+ Model.prototype.setCurrentValue = function (value) {
+ if (value === undefined) {
+ throw "Undefined value";
+ }
+ this._currentValue = value;
+ };
+ Model.prototype.selectPreviousOption = function () {
+ if (this._currentValue > 0 && this._currentValue !== -1) {
+ this.setCurrentValue(this._currentValue - 1);
+ }
+ else {
+ this.setCurrentValue(0);
+ }
+ };
+ Model.prototype.selectNextOption = function () {
+ this.setCurrentValue(this._currentValue + 1);
+ };
+ Model.prototype.getCurrentValue = function () {
+ return this._currentValue;
+ };
+ return Model;
+ }());
+ exports.Model = Model;
+});
diff --git a/test/scripts/model.tests.js b/test/scripts/model.tests.js
new file mode 100644
index 0000000..c759360
--- /dev/null
+++ b/test/scripts/model.tests.js
@@ -0,0 +1,20 @@
+define(["require", "exports", 'chai', './model'], function (require, exports, chai_1, model_1) {
+ "use strict";
+ describe("Model", function () {
+ var model;
+ beforeEach(function () {
+ model = new model_1.Model(0);
+ });
+ it("current value of 0", function () {
+ chai_1.expect(model.getCurrentValue()).to.be.deep.equal(0);
+ });
+ it("next value from 0", function () {
+ model.selectNextOption();
+ chai_1.expect(model.getCurrentValue()).to.be.deep.equal(1);
+ });
+ it("previous value of 0", function () {
+ model.selectPreviousOption();
+ chai_1.expect(model.getCurrentValue()).to.be.deep.equal(0);
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e240e4d
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "module": "amd",
+ "sourceMap": false
+ },
+ "exclude": [
+ "node_modules"
+ ]
+}
\ No newline at end of file
diff --git a/typings.json b/typings.json
new file mode 100644
index 0000000..2428cde
--- /dev/null
+++ b/typings.json
@@ -0,0 +1,11 @@
+{
+ "globalDependencies": {
+ "chai": "registry:dt/chai#3.4.0+20160601211834",
+ "jquery": "registry:dt/jquery#1.10.0+20160628074423",
+ "knockout": "registry:dt/knockout#0.0.0+20160512130947",
+ "mocha": "registry:dt/mocha#2.2.5+20160619032855",
+ "q": "registry:dt/q#0.0.0+20160613154756",
+ "tfs": "npm:vss-web-extension-sdk/typings/tfs.d.ts",
+ "vss": "npm:vss-web-extension-sdk/typings/vss.d.ts"
+ }
+}
diff --git a/vss-extension.json b/vss-extension.json
new file mode 100644
index 0000000..ffd66a5
--- /dev/null
+++ b/vss-extension.json
@@ -0,0 +1,97 @@
+{
+ "manifestVersion": 1,
+ "id": "hitcount-control",
+ "version": "0.1.0",
+ "name": "HitCount Control",
+ "scopes": [
+ "vso.work",
+ "vso.work_write"
+ ],
+ "description": "Describe your extension.",
+ "publisher": "