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": "