├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── configs ├── dev.json └── release.json ├── karma.conf.js ├── marketplace ├── details.md ├── logo.png ├── overview.png └── quick-decompose.gif ├── package.json ├── readme.md ├── scripts ├── packageDev.js ├── publishDev.js └── publishRelease.js ├── src ├── actions.ts ├── actionsCreator.ts ├── app.ts ├── components │ ├── addItemsComponent.tsx │ ├── errorComponent.tsx │ ├── errorItemComponent.tsx │ ├── genericErrorComponent.tsx │ ├── items.scss │ ├── mainComponent.tsx │ └── parentWorkItemComponent.tsx ├── dialog.html ├── dialog.scss ├── dialog.tsx ├── index.html ├── interfaces.ts ├── model │ ├── workItemTree.tests.ts │ └── workItemTree.ts ├── services │ ├── workItemCreator.ts │ └── workItemTypeService.ts ├── store.ts └── style.scss ├── tsconfig.json ├── vss-extension.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | typings 3 | dist 4 | *.vsix -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vsicons.presets.angular": false 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Christopher Schleiden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /configs/dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": false, 3 | "baseUri": "https://localhost:9085" 4 | } -------------------------------------------------------------------------------- /configs/release.json: -------------------------------------------------------------------------------- 1 | { 2 | "galleryFlags": [ 3 | "Public", 4 | "Preview" 5 | ], 6 | "files": [ 7 | { 8 | "path": "../dist/src", 9 | "addressable": true 10 | }, 11 | { 12 | "path": "../dist/libs", 13 | "addressable": true 14 | }, 15 | { 16 | "path": "../dist/marketplace", 17 | "addressable": true 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config'); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | basePath: '', 6 | frameworks: ['mocha', 'chai', 'sinon'], 7 | files: [ 8 | 'src/**/*.tests.ts' 9 | ], 10 | exclude: [ 11 | ], 12 | preprocessors: { 13 | 'src/**/*.tests.ts': ['webpack'] 14 | }, 15 | webpack: { 16 | module: webpackConfig.module, 17 | resolve: webpackConfig.resolve 18 | }, 19 | reporters: ['mocha'], 20 | port: 9876, 21 | colors: true, 22 | logLevel: config.LOG_INFO, 23 | autoWatch: true, 24 | browsers: ['PhantomJS'], 25 | singleRun: false, 26 | concurrency: Infinity 27 | }) 28 | } -------------------------------------------------------------------------------- /marketplace/details.md: -------------------------------------------------------------------------------- 1 | # Easy decomposition of work items # 2 | 3 | **Decompose** allows you to quickly break down work items into sub-hierarchies. 4 | 5 | - Create hierarchies of work items without waiting 6 | - Should it be a Task or is it big enough for a Story? Promote/Demote work items easily between different hierarchy levels 7 | - Full support for keyboard shortcuts. Just like your favorite editor (VS Code), you never have to leave the keyboard 8 | - Iterate on titles before commiting 9 | 10 | ![Overview](marketplace/overview.png) 11 | 12 | ## Example: Starting a Sprint ## 13 | When you start to work on a feature and you want to quickly break it down into User Stories and Tasks you can use Excel, manually create work items, or the *New Item* panel on the Backlog. This all works, but isn't as convenient as it could be. 14 | 15 | With **Decompose** you can easily define hierarchies, change work items between levels, and finally create actual work items with a single click. 16 | 17 | ![Breaking down of an Epic](marketplace/quick-decompose.gif) 18 | 19 | # Version History # 20 | 21 | * **1.1.1** - Decompose now correctly works with customized backlogs and work item types 22 | * **1.0.1** - Added support for TFS "15" RC2 and higher (note, it will **not** work on any version prior to that, even if it might let you install) 23 | * **0.0.5** - Fixes a bug that prevented Internet Explorer users from saving 24 | 25 | # On Premise/Team Foundation Server # 26 | 27 | Many people have asked why this extension is only available for Team Services: it relies on APIs that will only be available in the next **major** version of Team Foundation Server. After release, I will provide a package of this extension that can be installed on an on premise Team Foundation Server, until then I can only support Team Services. 28 | 29 | # Code # 30 | The code is available at https://github.com/cschleiden/vsts-quick-decompose. -------------------------------------------------------------------------------- /marketplace/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cschleiden/azure-boards-decompose/92d662e3391b4a2ead8088956e3fa1a2f885cfaa/marketplace/logo.png -------------------------------------------------------------------------------- /marketplace/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cschleiden/azure-boards-decompose/92d662e3391b4a2ead8088956e3fa1a2f885cfaa/marketplace/overview.png -------------------------------------------------------------------------------- /marketplace/quick-decompose.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cschleiden/azure-boards-decompose/92d662e3391b4a2ead8088956e3fa1a2f885cfaa/marketplace/quick-decompose.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "", 3 | "main": "webpack.config.js", 4 | "scripts": { 5 | "clean": "rimraf dist *.vsix", 6 | "dev": "webpack-dev-server --hot --progress --colors --content-base ./src --https --port 9085", 7 | "package:dev": "node ./scripts/packageDev", 8 | "publish:dev": "npm run package:dev && node ./scripts/publishDev", 9 | "build:release": "npm run clean && mkdir dist && webpack --progress --colors --output-path ./dist -p", 10 | "publish:release": "npm run build:release && node ./scripts/publishRelease", 11 | "dev:test": "karma start", 12 | "test": "karma start --single-run", 13 | "postinstall": "npm run test" 14 | }, 15 | "author": "Christopher Schleiden", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@types/chai": "^3.4.35", 19 | "@types/jquery": "^2.0.40", 20 | "@types/knockout": "^3.4.39", 21 | "@types/mocha": "^2.2.39", 22 | "@types/q": "0.0.32", 23 | "@types/react": "0.14.57", 24 | "@types/react-dom": "^0.14.23", 25 | "@types/react-spinkit": "^1.1.30", 26 | "@types/requirejs": "^2.1.28", 27 | "chai": "^3.5.0", 28 | "copy-webpack-plugin": "^3.0.0", 29 | "css-loader": "^0.23.1", 30 | "karma": "^0.13.22", 31 | "karma-chai": "^0.1.0", 32 | "karma-mocha": "^1.0.1", 33 | "karma-mocha-reporter": "^2.0.3", 34 | "karma-phantomjs-launcher": "^1.0.0", 35 | "karma-sinon": "^1.0.5", 36 | "karma-webpack": "^1.7.0", 37 | "mocha": "^2.5.3", 38 | "node-sass": "^3.7.0", 39 | "phantomjs-prebuilt": "^2.1.7", 40 | "react": "^15.1.0", 41 | "react-dom": "^15.1.0", 42 | "react-spinkit": "^1.1.7", 43 | "rimraf": "^2.5.2", 44 | "sass-loader": "^3.2.0", 45 | "sinon": "^1.17.4", 46 | "style-loader": "^0.13.1", 47 | "ts-loader": "2.0.3", 48 | "typescript": "2.2.2", 49 | "webpack": "^1.13.1", 50 | "webpack-dev-server": "^1.14.1" 51 | }, 52 | "dependencies": { 53 | "vss-web-extension-sdk": "2.115.0" 54 | } 55 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | **2021/4/1 - I have stopped support for all my Azure DevOps extensions and unpublished them from the marketplace** 2 | 3 | # VSTS Quick Decompose # 4 | 5 | This extension allows you to quickly decompose a work item into a valid hierarchy. Example: 6 | 7 | ![Decompose item into hierarchy](marketplace/quick-decompose.gif?raw=true) 8 | 9 | 10 | This is an example for using relatively modern web dev technologies to build a VSTS (https://www.visualstudio.com) extension. In contrast to my other seed project and example (https://github.com/cschleiden/vsts-extension-ts-seed-simple and https://github.com/cschleiden/vsts-extension-tags-mru) which focused on simplicity, this sample aims to be more complete. It supports: 11 | 12 | - Code written in Typescript/Styling defined using SASS 13 | - Publishing a dev version of an extension and a production one, without changing the manifest 14 | - Webpack for watching and building files during development, and for building optimized bundles for production 15 | - Unit tests of the core logic using mocha/chai 16 | - React for rendering a complex UI with user interation 17 | 18 | ## Building ## 19 | 20 | This extension uses *webpack* for bundling, *webpack-dev-server* for watching files and serving bundles during development, *mocha*, *chai* for writing unit tests, and *karma* as a test runner. 21 | 22 | Two bundles are defined for webpack, one for the main dialog, one for the extension context menu registration. 23 | 24 | All actions can be triggered using npm scripts (`npm run `), no additional task runner required. 25 | 26 | ### General setup ### 27 | 28 | You need 29 | 30 | * node/npm 31 | 32 | then just clone and execute `npm install`. 33 | 34 | ### Development ### 35 | 36 | 1. Run `npm run publish:dev` to publish the current extension manifest to the marketplace as a private extension with a suffix of `-dev` added to the extension id. This package will use a baseUri of `https://localhost:8080`. 37 | 38 | 2. Run `npm run dev` to start a webpack developmen server that watches all source files. Tests live next to product code and use a `.tests.ts` suffix instead of only `.ts`. 39 | 40 | 3. To run a single test pass execute `npm run test`, to keep watching tests and build/execute as you develop execute `npm run dev:test`. 41 | 42 | ### Production ### 43 | 44 | 1. Run `npm run publish:release` to compile all modules into bundles, package them into a .vsix, and publish as a *public* extension to the VSTS marketplace. 45 | -------------------------------------------------------------------------------- /scripts/packageDev.js: -------------------------------------------------------------------------------- 1 | var exec = require("child_process").exec; 2 | 3 | // Load existing publisher 4 | var manifest = require("../vss-extension.json"); 5 | var extensionId = manifest.id; 6 | 7 | // Package extension 8 | var command = `tfx extension create --overrides-file configs/dev.json --manifest-globs vss-extension.json --extension-id ${extensionId}-dev --no-prompt`; 9 | exec(command, function() { 10 | console.log("Package created"); 11 | }); -------------------------------------------------------------------------------- /scripts/publishDev.js: -------------------------------------------------------------------------------- 1 | var exec = require("child_process").exec; 2 | 3 | var manifest = require("../vss-extension.json"); 4 | var extensionId = manifest.id; 5 | var extensionPublisher = manifest.publisher; 6 | var extensionVersion = manifest.version; 7 | 8 | // Package extension 9 | var command = `tfx extension publish --vsix ${extensionPublisher}.${extensionId}-dev-${extensionVersion}.vsix --no-prompt`; 10 | exec(command, function() { 11 | console.log("Package published."); 12 | }); -------------------------------------------------------------------------------- /scripts/publishRelease.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var exec = require("child_process").exec; 4 | 5 | // Package extension 6 | var command = `tfx extension create --overrides-file ../configs/release.json --manifest-globs ./vss-extension-release.json --no-prompt --json`; 7 | exec(command, { 8 | "cwd": "./dist" 9 | }, (error, stdout) => { 10 | if (error) { 11 | console.error(`Could not create package: '${error}'`); 12 | return; 13 | } 14 | 15 | let output = JSON.parse(stdout); 16 | 17 | console.log(`Package created ${output.path}`); 18 | 19 | var command = `tfx extension publish --vsix ${output.path} --no-prompt`; 20 | exec(command, (error, stdout) => { 21 | if (error) { 22 | console.error(`Could not create package: '${error}'`); 23 | return; 24 | } 25 | 26 | console.log("Package published."); 27 | }); 28 | }); -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | /** Interface for a simple listener to an action firing */ 2 | export interface IActionHandler { 3 | (payload: TPayload): void; 4 | } 5 | 6 | let executing = false; 7 | 8 | /** Base class for a self dispatched action */ 9 | export class Action { 10 | private _handlers: IActionHandler[] = []; 11 | 12 | public addListener(handler: IActionHandler) { 13 | this._handlers.push(handler); 14 | } 15 | 16 | public invoke(payload: TPayload) { 17 | if (executing) { 18 | throw new Error("Cycle!"); 19 | } 20 | 21 | executing = true; 22 | 23 | for (let handler of this._handlers) { 24 | handler(payload); 25 | } 26 | 27 | executing = false; 28 | } 29 | } 30 | 31 | export interface IChangeWorkItemLevelPayload { 32 | id: number; 33 | indentLevelChange: number; 34 | } 35 | 36 | /** Action to be called when the indent level of a work item should be changed */ 37 | export var changeWorkItemLevel = new Action(); 38 | 39 | export interface IInsertItemPayload { 40 | afterId: number; 41 | } 42 | 43 | /** Action to be called when a new work item should be inserted at a given index */ 44 | export var insertItem = new Action(); 45 | 46 | export interface IChangeWorkItemTitle { 47 | id: number; 48 | title: string; 49 | } 50 | 51 | /** Action to be called when work item title changes */ 52 | export var changeTitle = new Action(); 53 | 54 | export interface IDeleteItemPayload { 55 | id: number; 56 | } 57 | 58 | /** Action to be called when work item should be deleted */ 59 | export var deleteItem = new Action(); 60 | 61 | 62 | export interface IChangeTypePayload { 63 | id: number; 64 | } 65 | 66 | /** Action to be called when the work item type should be changed */ 67 | export var changeType = new Action(); -------------------------------------------------------------------------------- /src/actionsCreator.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "./store"; 2 | 3 | import * as Actions from "./actions"; 4 | 5 | export class ActionsCreator { 6 | constructor(private store: Store) { 7 | } 8 | 9 | /** 10 | * Change the title of the given item 11 | * @param id Id of work item to change title for 12 | * @param title New title of work item 13 | */ 14 | public changeTitle(id: number, title: string) { 15 | Actions.changeTitle.invoke({ 16 | id: id, 17 | title: title 18 | }); 19 | } 20 | 21 | /** 22 | * Change the indentation level of a work item 23 | * @param id Id of work item to change 24 | * @param indentLevelChange Desired indent level change of the work item 25 | */ 26 | public changeItemIndentLevel(id: number, indentLevelChange: number) { 27 | Actions.changeWorkItemLevel.invoke({ 28 | id: id, 29 | indentLevelChange: indentLevelChange 30 | }); 31 | } 32 | 33 | /** 34 | * Add a new item after the given index 35 | * @param insertIndex New item will be added after this item 36 | */ 37 | public insertItem(afterId: number) { 38 | Actions.insertItem.invoke({ 39 | afterId: afterId 40 | }); 41 | } 42 | 43 | /** 44 | * Delete the given work item 45 | * @param id Work item id to delete 46 | */ 47 | public deleteItem(id: number) { 48 | Actions.deleteItem.invoke({ 49 | id: id 50 | }); 51 | } 52 | 53 | /** 54 | * Change type of given work item 55 | * @param id Work item id to change 56 | */ 57 | public changeType(id: number) { 58 | Actions.changeType.invoke({ 59 | id: id 60 | }); 61 | } 62 | } -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { IDialogInputData } from "./interfaces"; 2 | 3 | const extensionContext = VSS.getExtensionContext(); 4 | 5 | VSS.register(`${extensionContext.publisherId}.${extensionContext.extensionId}.contextMenu`, { 6 | execute: (actionContext) => { 7 | let workItemId = actionContext.id 8 | || actionContext.workItemId 9 | || (actionContext.ids && actionContext.ids.length > 0 && actionContext.ids[0]) 10 | || (actionContext.workItemIds && actionContext.workItemIds.length > 0 && actionContext.workItemIds[0]); 11 | 12 | if (workItemId) { 13 | let dialog: IExternalDialog; 14 | let onSaveHandler: () => IPromise; 15 | 16 | VSS.getService(VSS.ServiceIds.Dialog).then((hostDialogService: IHostDialogService) => { 17 | hostDialogService.openDialog(`${extensionContext.publisherId}.${extensionContext.extensionId}.addItemsDialog`, { 18 | title: "Decompose", 19 | width: 500, 20 | height: 400, 21 | modal: true, 22 | buttons: { 23 | "ok": { 24 | id: "ok", 25 | text: "Create", 26 | click: () => { 27 | if (onSaveHandler) { 28 | dialog.updateOkButton(false); 29 | 30 | return onSaveHandler().then(() => { 31 | dialog.close(); 32 | 33 | return VSS.getService(VSS.ServiceIds.Navigation).then((navigationService: IHostNavigationService) => { 34 | // Refresh backlog 35 | navigationService.reload(); 36 | }); 37 | }, (error: Error | string) => { 38 | if (typeof error === "string") { 39 | dialog.setTitle(error); 40 | } else { 41 | dialog.setTitle(error.message); 42 | } 43 | }); 44 | } 45 | }, 46 | class: "cta", 47 | disabled: "disabled" 48 | } 49 | } 50 | }, { 51 | workItemId: workItemId, 52 | setSaveHandler: (onSave: () => IPromise) => { 53 | onSaveHandler = onSave; 54 | }, 55 | onUpdate: (isValid: boolean) => { 56 | if (dialog) { 57 | dialog.updateOkButton(isValid); 58 | } 59 | }, 60 | }).then(d => { 61 | dialog = d; 62 | }); 63 | }); 64 | } 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /src/components/addItemsComponent.tsx: -------------------------------------------------------------------------------- 1 | import "./items.scss"; 2 | 3 | import * as React from "react"; 4 | import * as ReactDOM from "react-dom"; 5 | 6 | import { IWorkItem } from "../interfaces"; 7 | import { WorkItemTypeService } from "../services/workItemTypeService"; 8 | import { ActionsCreator } from "../actionsCreator"; 9 | 10 | export interface IAddItemsProps extends React.Props { 11 | actionsCreator: ActionsCreator; 12 | 13 | item: IWorkItem; 14 | } 15 | 16 | const INDENT_WIDTH = 16; 17 | 18 | export class AddItemsComponent extends React.Component { 19 | private _inputElement: HTMLInputElement; 20 | 21 | public focus() { 22 | this._inputElement.focus(); 23 | } 24 | 25 | public hasFocus(): boolean { 26 | return this._inputElement === document.activeElement; 27 | } 28 | 29 | public render(): JSX.Element { 30 | let workItemType = WorkItemTypeService.getInstance().getBacklogForLevel(this.props.item.level).types[this.props.item.typeIndex]; 31 | 32 | let inputClasses = "work-item-edit"; 33 | if (this.props.item.title.trim() === "") { 34 | inputClasses += " invalid"; 35 | } 36 | 37 | return
38 | {workItemType.name} 39 | 40 | 41 | 42 | 43 | 44 | 45 | this._inputElement = e} /> 52 | 53 |
; 54 | } 55 | 56 | private _onKeyDown(evt: KeyboardEvent) { 57 | if (evt.key === "Tab" 58 | || evt.key === "ArrowUp" 59 | || evt.key === "ArrowDown" 60 | || evt.key === "Enter") { 61 | evt.preventDefault(); 62 | } 63 | } 64 | 65 | private _onChange(event: React.FormEvent) { 66 | let newTitle = (event.target as any).value; 67 | 68 | this.props.actionsCreator.changeTitle(this.props.item.id, newTitle); 69 | } 70 | 71 | private _outdent() { 72 | this.props.actionsCreator.changeItemIndentLevel(this.props.item.id, -1); 73 | } 74 | 75 | private _indent() { 76 | this.props.actionsCreator.changeItemIndentLevel(this.props.item.id, 1); 77 | } 78 | 79 | private _delete() { 80 | this.props.actionsCreator.deleteItem(this.props.item.id); 81 | } 82 | 83 | private _changeType() { 84 | this.props.actionsCreator.changeType(this.props.item.id); 85 | } 86 | } -------------------------------------------------------------------------------- /src/components/errorComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { ErrorItemComponent } from "../components/errorItemComponent"; 4 | 5 | import { IResultWorkItem } from "../interfaces"; 6 | 7 | export interface IErrorProps extends React.Props { 8 | workItems: IResultWorkItem[]; 9 | result: IDictionaryNumberTo; 10 | } 11 | 12 | export class ErrorComponent extends React.Component { 13 | constructor(props: IErrorProps) { 14 | super(props); 15 | } 16 | 17 | public render(): JSX.Element { 18 | let errorItems = this.props.workItems.map(wi => { 19 | if (!this.props.result[wi.id]) { 20 | // no error 21 | return null; 22 | } 23 | 24 | return 25 | }).filter(e => !!e); 26 | 27 | return (
28 |

Unfortunately these work items could not be saved:

29 | {errorItems} 30 |
); 31 | } 32 | } -------------------------------------------------------------------------------- /src/components/errorItemComponent.tsx: -------------------------------------------------------------------------------- 1 | import "./items.scss"; 2 | 3 | import * as React from "react"; 4 | 5 | import { IResultWorkItem } from "../interfaces"; 6 | import { WorkItemTypeService } from "../services/workItemTypeService"; 7 | 8 | export interface IErrorItemProps extends React.Props { 9 | item: IResultWorkItem; 10 | 11 | error: string; 12 | } 13 | 14 | const INDENT_WIDTH = 16; 15 | 16 | export class ErrorItemComponent extends React.Component { 17 | public render(): JSX.Element { 18 | let workItemType = WorkItemTypeService.getInstance().getBacklogForLevel(this.props.item.level).types[this.props.item.typeIndex]; 19 | 20 | return
21 | { workItemType.name } 22 | { this.props.item.title } 23 | 24 |
25 |  {this.props.error} 26 |
27 |
; 28 | } 29 | } -------------------------------------------------------------------------------- /src/components/genericErrorComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface IGenericErrorProps extends React.Props { 4 | message: string; 5 | } 6 | 7 | export class GenericErrorComponent extends React.Component { 8 | public render(): JSX.Element { 9 | return
10 |

11 | An error has occured: 12 |

13 |

14 | {this.props.message} 15 |

16 |
; 17 | } 18 | } -------------------------------------------------------------------------------- /src/components/items.scss: -------------------------------------------------------------------------------- 1 | $lineHeight: 25px; 2 | 3 | .work-item { 4 | height: $lineHeight; 5 | line-height: $lineHeight; 6 | 7 | &.save-error { 8 | height: auto; 9 | } 10 | 11 | & .error-message { 12 | i { 13 | display: inline-block; 14 | vertical-align: top; 15 | } 16 | 17 | margin-left: 16px; 18 | line-height: 16px; 19 | } 20 | 21 | margin: 2px 0px; 22 | 23 | overflow: hidden; 24 | 25 | span { 26 | display: block; 27 | 28 | -webkit-user-select: none; 29 | -moz-user-select: none; 30 | -ms-user-select: none; 31 | user-select: none; 32 | } 33 | 34 | .type { 35 | padding-left: 4px; 36 | border-left: 6px solid; 37 | 38 | float: left; 39 | 40 | &.editable { 41 | cursor: pointer; 42 | 43 | &:hover { 44 | background: aliceblue; 45 | } 46 | } 47 | } 48 | 49 | .title { 50 | padding-left: 10px; 51 | padding-right: 2px; 52 | 53 | input { 54 | width: 100%; 55 | height: $lineHeight - 2px; 56 | 57 | padding: 0px 4px; 58 | } 59 | 60 | overflow: hidden; 61 | } 62 | 63 | span.edit { 64 | width: 55px; 65 | height: 16px; 66 | float: right; 67 | 68 | cursor: pointer; 69 | 70 | i { 71 | color: grey; 72 | 73 | &:hover { 74 | color: blue; 75 | font-weight: bold; 76 | } 77 | 78 | &.bowtie-edit-delete:hover { 79 | color: red; 80 | } 81 | } 82 | } 83 | } 84 | 85 | input.work-item-edit { 86 | border: none; 87 | 88 | .invalid { 89 | background-color: #ffc; 90 | } 91 | } 92 | 93 | .parent-work-item { 94 | font-size: large; 95 | font-weight: bold; 96 | margin-bottom: 10px; 97 | } -------------------------------------------------------------------------------- /src/components/mainComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { AddItemsComponent } from "../components/addItemsComponent"; 4 | import { ParentWorkItemComponent } from "../components/parentWorkItemComponent"; 5 | 6 | import { IWorkItem } from "../interfaces"; 7 | import { Store } from "../store"; 8 | import { ActionsCreator } from "../actionsCreator"; 9 | 10 | export interface IMainProps extends React.Props { 11 | store: Store; 12 | actionsCreator: ActionsCreator; 13 | } 14 | 15 | export interface IMainState { 16 | focusedItemIdx: number; 17 | 18 | parentWorkItem: IWorkItem; 19 | items: IWorkItem[]; 20 | } 21 | 22 | export class MainComponent extends React.Component { 23 | constructor(props: IMainProps) { 24 | super(props); 25 | 26 | this.state = this._getStateFromStore(); 27 | 28 | this.props.store.addListener(this._onDataChange.bind(this)); 29 | } 30 | 31 | public render(): JSX.Element { 32 | return (
33 | 34 | 35 | { this.state.items.map((item, idx) => 36 | ) } 42 |
); 43 | } 44 | 45 | public componentDidUpdate() { 46 | this._setFocus(); 47 | } 48 | 49 | private _setFocus() { 50 | let element = this.refs[this.state.focusedItemIdx.toString()] as AddItemsComponent; 51 | 52 | if (element) { 53 | element.focus(); 54 | } 55 | } 56 | 57 | private _getStateFromStore(): IMainState { 58 | let items = this.props.store.getItems(); 59 | 60 | let focusedItemIdx = this.state && this.state.focusedItemIdx || 0; 61 | if (focusedItemIdx >= items.length) { 62 | focusedItemIdx = items.length - 1; 63 | } 64 | 65 | return { 66 | focusedItemIdx: focusedItemIdx, 67 | parentWorkItem: this.props.store.getParentItem(), 68 | items: items.slice(0) 69 | }; 70 | } 71 | 72 | private _onDataChange() { 73 | this.setState(() => this._getStateFromStore()); 74 | } 75 | 76 | private _onKeyUp(evt: KeyboardEvent) { 77 | // We can only perform actions if we have a focused item 78 | let focusedItem = this._getFocusedItem(); 79 | if (!focusedItem) { 80 | return; 81 | } 82 | 83 | switch (evt.key) { 84 | case "ArrowUp": 85 | this._updateFocusedItem(-1); 86 | break; 87 | 88 | case "ArrowDown": 89 | this._updateFocusedItem(1); 90 | break; 91 | 92 | case "Tab": 93 | this.props.actionsCreator.changeItemIndentLevel(focusedItem.id, evt.shiftKey ? -1 : 1); 94 | break; 95 | 96 | case "Enter": { 97 | if (evt.altKey) { 98 | this.props.actionsCreator.changeType(focusedItem.id); 99 | } else { 100 | this.props.actionsCreator.insertItem(focusedItem.id); 101 | this._forceUpdateFocusedItem(this.state.focusedItemIdx + 1); 102 | } 103 | break; 104 | } 105 | 106 | case "Delete": { 107 | if (evt.shiftKey) { 108 | this.props.actionsCreator.deleteItem(focusedItem.id); 109 | break; 110 | } 111 | } 112 | 113 | default: 114 | return; 115 | } 116 | 117 | // Prevent default browser action when we have handled it 118 | evt.preventDefault(); 119 | return false; 120 | } 121 | 122 | private _onFocus(evt: FocusEvent) { 123 | // Focus has changed, update out element idx 124 | for (let i = 0; i < this.state.items.length; ++i) { 125 | let comp = this.refs[i] as AddItemsComponent; 126 | if (comp.hasFocus()) { 127 | this._forceUpdateFocusedItem(i); 128 | break; 129 | } 130 | } 131 | } 132 | 133 | private _forceUpdateFocusedItem(focusedItemIdx: number) { 134 | this.setState({ 135 | focusedItemIdx: focusedItemIdx 136 | } as any, () => { 137 | this._setFocus(); 138 | }); 139 | } 140 | 141 | private _updateFocusedItem(direction: number) { 142 | let focusedItemIdx = this.state.focusedItemIdx; 143 | 144 | if (direction < 0) { 145 | if (focusedItemIdx > 0) { 146 | focusedItemIdx--; 147 | } 148 | } else { 149 | if (focusedItemIdx < this.state.items.length - 1) { 150 | focusedItemIdx++; 151 | } 152 | } 153 | 154 | this._forceUpdateFocusedItem(focusedItemIdx); 155 | } 156 | 157 | private _getFocusedItem(): IWorkItem { 158 | if (this.state.focusedItemIdx >= 0 && this.state.focusedItemIdx < this.state.items.length) { 159 | return this.state.items[this.state.focusedItemIdx]; 160 | } 161 | 162 | return null; 163 | } 164 | } -------------------------------------------------------------------------------- /src/components/parentWorkItemComponent.tsx: -------------------------------------------------------------------------------- 1 | import "./items.scss"; 2 | 3 | import * as React from "react"; 4 | 5 | import { IWorkItem } from "../interfaces"; 6 | import { WorkItemTypeService } from "../services/workItemTypeService"; 7 | 8 | export interface IParentWorkItemProps extends React.Props { 9 | item: IWorkItem; 10 | } 11 | 12 | export class ParentWorkItemComponent extends React.Component { 13 | public render(): JSX.Element { 14 | let workItemType = WorkItemTypeService.getInstance().getBacklogForLevel(this.props.item.level).types[this.props.item.typeIndex]; 15 | 16 | return
17 | { workItemType.name } 18 | 19 | { this.props.item.title } 20 | 21 |
; 22 | } 23 | } -------------------------------------------------------------------------------- /src/dialog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 22 | 23 |
24 | 25 | -------------------------------------------------------------------------------- /src/dialog.scss: -------------------------------------------------------------------------------- 1 | body { 2 | overflow: auto; 3 | } 4 | 5 | .saving-indicator { 6 | position: fixed; 7 | left: 50%; 8 | top: 50%; 9 | text-align: center; 10 | 11 | width: 100px; 12 | margin-left: -50px; 13 | } 14 | 15 | .saving-indicator .spinner .rotating-plane { 16 | background-color: #007acc; 17 | margin-left: 35px; 18 | } -------------------------------------------------------------------------------- /src/dialog.tsx: -------------------------------------------------------------------------------- 1 | import "dialog.scss"; 2 | 3 | import * as React from "react"; 4 | import * as ReactDOM from "react-dom"; 5 | 6 | import Spinner = require("react-spinkit"); 7 | 8 | import WIT_Client = require("TFS/WorkItemTracking/RestClient"); 9 | import WIT_Contracts = require("TFS/WorkItemTracking/Contracts"); 10 | 11 | import Q = require("q"); 12 | 13 | import { IWorkItem, IDialogInputData, IResultWorkItem } from "./interfaces"; 14 | 15 | import { WorkItemTypeService } from "./services/workItemTypeService"; 16 | import { WorkItemCreator } from "./services/workItemCreator"; 17 | 18 | import { MainComponent } from "./components/mainComponent"; 19 | import { ErrorComponent } from "./components/errorComponent"; 20 | import { GenericErrorComponent } from "./components/genericErrorComponent"; 21 | 22 | import { Store } from "./store"; 23 | import { ActionsCreator } from "./actionsCreator"; 24 | 25 | let inputData: IDialogInputData = VSS.getConfiguration(); 26 | 27 | let typeServiceInitPromise = WorkItemTypeService.getInstance().init(); 28 | let parentWorkItemPromise = WIT_Client.getClient().getWorkItem( 29 | inputData.workItemId, ["System.Id", "System.WorkItemType", "System.Title", "System.IterationPath", "System.AreaPath"]); 30 | 31 | // Polyfill Object.Assign for Internet Explorer 32 | if (typeof Object["assign"] != 'function') { 33 | Object["assign"] = function (target) { 34 | 'use strict'; 35 | if (target == null) { 36 | throw new TypeError('Cannot convert undefined or null to object'); 37 | } 38 | 39 | target = Object(target); 40 | for (var index = 1; index < arguments.length; index++) { 41 | var source = arguments[index]; 42 | if (source != null) { 43 | for (var key in source) { 44 | if (Object.prototype.hasOwnProperty.call(source, key)) { 45 | target[key] = source[key]; 46 | } 47 | } 48 | } 49 | } 50 | return target; 51 | }; 52 | } 53 | 54 | Q.all([typeServiceInitPromise, parentWorkItemPromise]).then(values => { 55 | let workItem: WIT_Contracts.WorkItem = values[1]; 56 | 57 | let parentLevel = WorkItemTypeService.getInstance().getLevelForTypeName(workItem.fields["System.WorkItemType"]); 58 | let types = WorkItemTypeService.getInstance().getBacklogForLevel(parentLevel).types; 59 | let typeIndex: number = null; 60 | types.forEach((type, idx) => { 61 | if (type.name === workItem.fields["System.WorkItemType"]) { 62 | typeIndex = idx; 63 | } 64 | }); 65 | 66 | let parentWorkItem: IWorkItem = { 67 | id: workItem.fields["System.Id"], 68 | typeIndex: typeIndex, 69 | title: workItem.fields["System.Title"], 70 | level: parentLevel || 1, 71 | relativeLevel: 0 72 | }; 73 | 74 | let parentIterationPath = workItem.fields["System.IterationPath"]; 75 | let parentAreaPath = workItem.fields["System.AreaPath"]; 76 | 77 | let store = new Store(parentWorkItem); 78 | let actionsCreator = new ActionsCreator(store); 79 | 80 | ReactDOM.render(, document.getElementById("content")); 83 | 84 | store.addListener(() => { 85 | let isValid = store.getIsValid(); 86 | inputData.onUpdate(isValid); 87 | }); 88 | 89 | inputData.setSaveHandler(() => { 90 | // react-spinkit typings are not correct, work around by casting to any 91 | let spinner = React.createElement(Spinner as any, { spinnerName: "rotating-plane", noFadeIn: true }); 92 | 93 | ReactDOM.render(
94 | {spinner} 95 |
Saving
96 |
, document.getElementById("content")); 97 | 98 | let resultTree = store.getResult(); 99 | let creator = new WorkItemCreator(store.getParentItem().id, parentIterationPath, parentAreaPath); 100 | return creator.createWorkItems(resultTree).then(failedWorkItems => { 101 | if (Object.keys(failedWorkItems).length > 0) { 102 | // Some work items have failed to save, show error result 103 | ReactDOM.render(, document.getElementById("content")); 106 | 107 | throw "Decompose - Error"; 108 | } else { 109 | // Saved successfully, close dialog 110 | return; 111 | } 112 | }); 113 | }); 114 | }, (reason: Error) => { 115 | ReactDOM.render(, document.getElementById("content")); 118 | }); -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IWorkItem { 2 | id: number; 3 | 4 | typeIndex: number; 5 | 6 | title: string; 7 | 8 | level?: number; 9 | 10 | relativeLevel?: number; 11 | } 12 | 13 | export interface IResultWorkItem extends IWorkItem { 14 | parentId: number; 15 | 16 | typeName: string; 17 | } 18 | 19 | export interface IDialogInputData { 20 | workItemId: number; 21 | setSaveHandler: (onSave: () => IPromise) => void; 22 | onUpdate: (isValid: boolean) => void; 23 | } 24 | 25 | export interface IWorkItemType { 26 | name: string; 27 | color?: string; 28 | } 29 | 30 | export interface IBacklogLevel { 31 | types: IWorkItemType[]; 32 | defaultType: IWorkItemType; 33 | level: number; 34 | } -------------------------------------------------------------------------------- /src/model/workItemTree.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { WorkItemTree, WorkItemNode, IWorkItemTypeAdapter } from "./workItemTree"; 4 | import { IWorkItem, IBacklogLevel } from "../interfaces"; 5 | 6 | 7 | class TestableWorkItemTypeService implements IWorkItemTypeAdapter { 8 | public getMaxLevel(): number { 9 | return 4; 10 | } 11 | 12 | public getBacklogForLevel(level: number): IBacklogLevel { 13 | return { 14 | level: level, 15 | types: [{ 16 | name: `type1-${level}`, 17 | color: "rgb(255, 0, 0)" 18 | }, { 19 | name: `type2-${level}`, 20 | color: "rgb(0, 255, 0)" 21 | }] 22 | }; 23 | } 24 | } 25 | 26 | class TestableWorkItemTree extends WorkItemTree { 27 | constructor(parentWorkItem: IWorkItem, buildEmptyTree: boolean = false) { 28 | super(parentWorkItem, new TestableWorkItemTypeService()); 29 | 30 | /* Build tree with hierarchy: 31 | parent (1) 32 | 0 (2) 33 | 1 (3) 34 | 2 (3) 35 | 3 (4) 36 | 4 (2) 37 | 5 (3) 38 | 6 (3) 39 | */ 40 | 41 | if (!buildEmptyTree) { 42 | this.root.add(new WorkItemNode(this._getWorkItem(0))); 43 | this.root.children[0].add(new WorkItemNode(this._getWorkItem(1))); 44 | this.root.children[0].add(new WorkItemNode(this._getWorkItem(2))); 45 | this.root.children[0].children[1].add(new WorkItemNode(this._getWorkItem(3))); 46 | 47 | this.root.add(new WorkItemNode(this._getWorkItem(4))); 48 | this.root.children[1].add(new WorkItemNode(this._getWorkItem(5))); 49 | this.root.children[1].add(new WorkItemNode(this._getWorkItem(6))); 50 | } 51 | } 52 | 53 | private _getWorkItem(id: number): IWorkItem { 54 | return { 55 | title: "", 56 | id: id, 57 | typeIndex: 0 58 | }; 59 | } 60 | } 61 | 62 | describe("WorkItemTree", () => { 63 | const parentId = 42; 64 | const parentWorkItem: IWorkItem = { 65 | id: parentId, 66 | title: "Parent-Epic", 67 | level: 1, 68 | typeIndex: 0 69 | }; 70 | 71 | let tree: WorkItemTree; 72 | 73 | describe("getItem", () => { 74 | beforeEach(() => { 75 | tree = new TestableWorkItemTree(parentWorkItem); 76 | }); 77 | 78 | it("should throw for invalid id", () => { 79 | expect(() => tree.getItem(99)).to.throw(Error); 80 | }); 81 | 82 | it("should get item for valid id", () => { 83 | expect(tree.getItem(2).id).to.be.equal(2); 84 | }); 85 | }); 86 | 87 | describe("insert", () => { 88 | beforeEach(() => { 89 | tree = new TestableWorkItemTree(parentWorkItem, true); 90 | }); 91 | 92 | it("should allow to insert item after root", () => { 93 | expect(tree.insert(parentId)).to.deep.equal({ 94 | id: -1, 95 | title: "", 96 | level: 2, 97 | typeIndex: 0 98 | }); 99 | }); 100 | 101 | it("should allow to insert sibling item", () => { 102 | let afterId = tree.insert(parentId).id; 103 | 104 | expect(tree.insert(afterId)).to.deep.equal({ 105 | id: -2, 106 | title: "", 107 | level: 2, 108 | typeIndex: 0 109 | }); 110 | }); 111 | 112 | it("should allow to insert item after reference element with children", () => { 113 | tree = new TestableWorkItemTree(parentWorkItem); 114 | let newId = tree.insert(2).id; 115 | 116 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 3, 4, 4, 2, 3, 3]); 117 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, newId, 3, 4, 5, 6]); 118 | }); 119 | 120 | it("should allow to insert item after reference element on same level", () => { 121 | tree = new TestableWorkItemTree(parentWorkItem); 122 | let newId = tree.insert(5).id; 123 | 124 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 3, 4, 2, 3, 3, 3]); 125 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 3, 4, 5, newId, 6]); 126 | }); 127 | 128 | it("should not allow to insert after invalid element", () => { 129 | expect(() => tree.insert(99)).to.throw(Error); 130 | }); 131 | }); 132 | 133 | describe("indent/outdent", () => { 134 | beforeEach(() => { 135 | /* Build tree with hierarchy: 136 | parent (1) 137 | 0 (2) 138 | 1 (3) 139 | 2 (3) 140 | 3 (4) 141 | 4 (2) 142 | 5 (3) 143 | 6 (3) 144 | */ 145 | tree = new TestableWorkItemTree(parentWorkItem); 146 | }); 147 | 148 | it("should not allow indenting items without previous sibling", () => { 149 | expect(tree.indent(0)).to.be.false; 150 | expect(tree.indent(1)).to.be.false; 151 | expect(tree.indent(3)).to.be.false; 152 | expect(tree.indent(5)).to.be.false; 153 | }); 154 | 155 | it("should not allow indenting items after max level", () => { 156 | expect(tree.indent(3)).to.be.false; 157 | }); 158 | 159 | it("should allow indenting items and change children if max level would be reached", () => { 160 | expect(tree.indent(2)).to.be.true; 161 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 4, 4, 2, 3, 3]); 162 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 3, 4, 5, 6]); 163 | }); 164 | 165 | it("should allow indenting items without subtree", () => { 166 | expect(tree.indent(6)).to.be.true; 167 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 3, 4, 2, 3, 4]); 168 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 3, 4, 5, 6]); 169 | }); 170 | 171 | it("should allow indenting items with subtree", () => { 172 | expect(tree.indent(4)).to.be.true; 173 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 3, 4, 3, 4, 4]); 174 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 3, 4, 5, 6]); 175 | }); 176 | 177 | 178 | it("should not allow outdenting root items", () => { 179 | expect(tree.outdent(0)).to.be.false; 180 | expect(tree.outdent(4)).to.be.false; 181 | }); 182 | 183 | it("should allow outdenting items without subtree", () => { 184 | expect(tree.outdent(3)).to.be.true; 185 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 3, 3, 2, 3, 3]); 186 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 3, 4, 5, 6]); 187 | }); 188 | 189 | it("should allow outdenting items with subtree", () => { 190 | expect(tree.outdent(2)).to.be.true; 191 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 2, 3, 2, 3, 3]); 192 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 3, 4, 5, 6]); 193 | }); 194 | 195 | it("should allow reparent children when outdenting items", () => { 196 | expect(tree.outdent(5)).to.be.true; 197 | expect(getTreeLevels()).to.be.deep.equal([2, 3, 3, 4, 2, 2, 3]); 198 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 3, 4, 5, 6]); 199 | }); 200 | 201 | it("should reset typeindex", () => { 202 | tree.getItem(2).typeIndex = 3; 203 | expect(tree.outdent(2)).to.be.true; 204 | expect(tree.getItem(2).typeIndex).to.be.equal(0); 205 | 206 | tree.getItem(2).typeIndex = 3; 207 | expect(tree.indent(2)).to.be.true; 208 | expect(tree.getItem(2).typeIndex).to.be.equal(0); 209 | }); 210 | }); 211 | 212 | describe("resultTree", () => { 213 | it("should generate tree", () => { 214 | tree = new TestableWorkItemTree(parentWorkItem); 215 | let resultTree = tree.resultTree(); 216 | 217 | expect(resultTree.map(wi => wi.parentId)).to.be.deep.equal([null, 42, 0, 0, 2, 42, 4, 4]); 218 | }); 219 | }); 220 | 221 | describe("displayTree", () => { 222 | it("should not include parent item", () => { 223 | tree = new TestableWorkItemTree(parentWorkItem); 224 | let resultTree = tree.displayTree(); 225 | 226 | expect(resultTree.some(e => e.id === parentId)).to.be.false; 227 | }); 228 | }); 229 | 230 | describe("delete", () => { 231 | beforeEach(() => { 232 | /* Build tree with hierarchy: 233 | parent (1) 234 | 0 (2) 235 | 1 (3) 236 | 2 (3) 237 | 3 (4) 238 | 4 (2) 239 | 5 (3) 240 | 6 (3) 241 | */ 242 | tree = new TestableWorkItemTree(parentWorkItem); 243 | }); 244 | 245 | it("should not delete root", () => { 246 | expect(() => tree.deleteItem(parentId)).to.throw(Error); 247 | }); 248 | 249 | it("should not delete last item", () => { 250 | tree = new TestableWorkItemTree(parentWorkItem, true); 251 | let id = tree.insert(parentId).id; 252 | 253 | expect(tree.deleteItem(id)).to.be.false; 254 | }); 255 | 256 | it("should delete item", () => { 257 | tree.deleteItem(3); 258 | expect(getTreeIds()).to.be.deep.equal([0, 1, 2, 4, 5, 6]); 259 | }); 260 | 261 | it("should delete item and subtree", () => { 262 | tree.deleteItem(2); 263 | expect(getTreeIds()).to.be.deep.equal([0, 1, 4, 5, 6]); 264 | }); 265 | }); 266 | 267 | describe("changeType", () => { 268 | beforeEach(() => { 269 | /* Build tree with hierarchy: 270 | parent (1) 271 | 0 (2) 272 | 1 (3) 273 | 2 (3) 274 | 3 (4) 275 | 4 (2) 276 | 5 (3) 277 | 6 (3) 278 | */ 279 | tree = new TestableWorkItemTree(parentWorkItem); 280 | }); 281 | 282 | it("should cycle through types per level", () => { 283 | expect(getTypeNameForItem(2)).to.be.equal("type1-3"); 284 | 285 | tree.changeType(2); 286 | expect(getTypeNameForItem(2)).to.be.equal("type2-3"); 287 | 288 | tree.changeType(2); 289 | expect(getTypeNameForItem(2)).to.be.equal("type1-3"); 290 | }); 291 | 292 | let getTypeNameForItem = (id: number): string => { 293 | return tree.resultTree().filter(item => item.id === id)[0].typeName; 294 | } 295 | }); 296 | 297 | 298 | let getTreeLevels = (): number[] => { 299 | return tree.displayTree().map(wi => wi.level); 300 | }; 301 | 302 | let getTreeIds = (): number[] => { 303 | return tree.displayTree().map(wi => wi.id); 304 | }; 305 | }); -------------------------------------------------------------------------------- /src/model/workItemTree.ts: -------------------------------------------------------------------------------- 1 | import { IWorkItem, IResultWorkItem, IBacklogLevel } from "../interfaces"; 2 | 3 | export class WorkItemNode { 4 | public parent: WorkItemNode; 5 | public children: WorkItemNode[] = []; 6 | 7 | constructor(public workItem: IWorkItem) { 8 | } 9 | 10 | public add(node: WorkItemNode) { 11 | node.parent = this; 12 | this.children.push(node); 13 | } 14 | 15 | /** Insert node before the given index */ 16 | public insert(node: WorkItemNode, idx: number) { 17 | node.parent = this; 18 | this.children.splice(idx, 0, node); 19 | } 20 | 21 | /** Insert node after given index */ 22 | public insertAfter(node: WorkItemNode, idx: number) { 23 | this.insert(node, idx + 1); 24 | } 25 | 26 | public remove(node: WorkItemNode) { 27 | let idx = this.children.indexOf(node); 28 | if (idx === -1) { 29 | throw new Error("Node to be removed is not in children"); 30 | } 31 | 32 | node.parent = null; 33 | this.children.splice(idx, 1); 34 | } 35 | } 36 | 37 | export interface IWorkItemTypeAdapter { 38 | getMaxLevel(): number; 39 | 40 | getBacklogForLevel(level: number): IBacklogLevel; 41 | } 42 | 43 | export class WorkItemTree { 44 | protected root: WorkItemNode; 45 | private insertId = -1; 46 | 47 | constructor(private parentWorkItem: IWorkItem, private workItemTypeService: IWorkItemTypeAdapter) { 48 | this.root = new WorkItemNode(this.parentWorkItem); 49 | } 50 | 51 | public getItem(id: number): IWorkItem { 52 | let node = this._find(id); 53 | return node.workItem; 54 | } 55 | 56 | /** Indent and all subtrees of the given work item */ 57 | public indent(id: number): boolean { 58 | let node = this._find(id); 59 | if (!node.parent) { 60 | // Cannot indent root 61 | return false; 62 | } 63 | 64 | let nodeLevel = this._getLevelForNode(node); 65 | 66 | let nodeParentIdx = node.parent.children.indexOf(node); 67 | if (nodeParentIdx === 0) { 68 | // Cannot indent without sibling before us that would become new parent 69 | return false; 70 | } 71 | 72 | let wouldReachMaxLevel: number = null; 73 | this._traverse(node, n => { 74 | let level = this._getLevelForNode(n); 75 | if (level + 1 > this.workItemTypeService.getMaxLevel()) { 76 | wouldReachMaxLevel = level; 77 | return false; 78 | } 79 | }); 80 | 81 | let previousSibling = node.parent.children[nodeParentIdx - 1]; 82 | let newParent = previousSibling; 83 | 84 | let moveChildrenToNewParent = false; 85 | 86 | if (wouldReachMaxLevel !== null) { 87 | if (wouldReachMaxLevel === nodeLevel) { 88 | // Current node would reach max level, indent not possible 89 | return false; 90 | } 91 | 92 | moveChildrenToNewParent = true; 93 | } 94 | 95 | // Detach from current parent 96 | node.parent.remove(node); 97 | 98 | // Add to new parent 99 | newParent.add(node); 100 | 101 | if (moveChildrenToNewParent) { 102 | // Move children 103 | for (let child of node.children) { 104 | node.remove(child); 105 | newParent.add(child); 106 | } 107 | } 108 | 109 | this._fixTypeIndex(node); 110 | 111 | return true; 112 | } 113 | 114 | public outdent(id: number) { 115 | let node = this._find(id); 116 | if (!node.parent) { 117 | // Cannot outdent root 118 | return false; 119 | } 120 | 121 | if (node.parent === this.root) { 122 | // Cannot outdent top level items 123 | return false; 124 | } 125 | 126 | let newParent = node.parent.parent; 127 | let oldParent = node.parent; 128 | 129 | // Find position to insert in new parent 130 | let nodeParentIdx = newParent.children.indexOf(node.parent); 131 | 132 | // Determine whether there are siblings after current item 133 | let nodeIdx = oldParent.children.indexOf(node); 134 | if (nodeIdx + 1 < oldParent.children.length) { 135 | // There are siblings which need to be moved 136 | let itemsToMove = oldParent.children.slice(nodeIdx + 1); 137 | for (let itemToMove of itemsToMove) { 138 | oldParent.remove(itemToMove); 139 | node.add(itemToMove); 140 | } 141 | } 142 | 143 | // Detach from current parent 144 | oldParent.remove(node); 145 | 146 | // Add to new parent 147 | newParent.insert(node, nodeParentIdx + 1); 148 | 149 | this._fixTypeIndex(node); 150 | 151 | return true; 152 | } 153 | 154 | private _fixTypeIndex(node: WorkItemNode) { 155 | let level = this._getLevelForNode(node); 156 | let backlog = this.workItemTypeService.getBacklogForLevel(level); 157 | if (node.workItem.typeIndex >= backlog.types.length) { 158 | node.workItem.typeIndex = 0; 159 | } 160 | } 161 | 162 | /** Insert sibling after the given work item */ 163 | public insert(afterId: number): IWorkItem { 164 | let node = this._find(afterId); 165 | 166 | let newNode = new WorkItemNode({ 167 | id: this.insertId--, 168 | title: "", 169 | typeIndex: 0 170 | }); 171 | 172 | if (node === this.root) { 173 | node.add(newNode); 174 | } else { 175 | if (node.children.length > 0) { 176 | // Node has children, add one in first place 177 | node.insert(newNode, 0); 178 | } else { 179 | // Add sibling item after current node 180 | let idx = node.parent.children.indexOf(node); 181 | node.parent.insertAfter(newNode, idx); 182 | } 183 | } 184 | 185 | newNode.workItem.level = this._getLevelForNode(newNode); 186 | 187 | return newNode.workItem; 188 | } 189 | 190 | /** Delete item with given id from tree */ 191 | public deleteItem(id: number): boolean { 192 | let node = this._find(id); 193 | 194 | if (node === this.root) { 195 | throw new Error("Cannot delete root"); 196 | } 197 | 198 | if (node.parent === this.root && this.root.children.length === 1) { 199 | // Cannot delete last item before root 200 | return false; 201 | } 202 | 203 | node.parent.remove(node); 204 | 205 | return true; 206 | } 207 | 208 | public changeType(id: number): void { 209 | let node = this._find(id); 210 | let level = this._getLevelForNode(node); 211 | let typesForLevel = this.workItemTypeService.getBacklogForLevel(level).types; 212 | 213 | if (++node.workItem.typeIndex >= typesForLevel.length) { 214 | node.workItem.typeIndex = 0; 215 | } 216 | } 217 | 218 | /** Flatten tree */ 219 | public displayTree(): IWorkItem[] { 220 | let result: IWorkItem[] = []; 221 | 222 | this._traverse(this.root, node => { 223 | if (node !== this.root) { 224 | let level = this._getLevelForNode(node); 225 | 226 | result.push({ 227 | id: node.workItem.id, 228 | title: node.workItem.title, 229 | level: level, 230 | relativeLevel: level - this.parentWorkItem.level, 231 | typeIndex: node.workItem.typeIndex 232 | }); 233 | } 234 | }); 235 | 236 | return result; 237 | } 238 | 239 | /** Flatten tree */ 240 | public resultTree(): IResultWorkItem[] { 241 | let result: IResultWorkItem[] = []; 242 | 243 | this._traverse(this.root, (node, parent) => { 244 | let level = this._getLevelForNode(node); 245 | 246 | result.push({ 247 | id: node.workItem.id, 248 | title: node.workItem.title, 249 | level: level, 250 | relativeLevel: level - this.parentWorkItem.level, 251 | parentId: parent ? parent.workItem && parent.workItem.id : null, 252 | typeIndex: node.workItem.typeIndex, 253 | typeName: this.workItemTypeService.getBacklogForLevel(level).types[node.workItem.typeIndex].name 254 | }); 255 | }); 256 | 257 | return result; 258 | } 259 | 260 | private _getLevelForNode(node: WorkItemNode): number { 261 | let level = 0; 262 | 263 | let parent = node.parent; 264 | while (!!parent) { 265 | ++level; 266 | 267 | parent = parent.parent; 268 | } 269 | 270 | return level + this.parentWorkItem.level; 271 | } 272 | 273 | private _find(id: number): WorkItemNode { 274 | let result: WorkItemNode; 275 | 276 | this._traverse(this.root, node => { 277 | if (node.workItem.id === id) { 278 | result = node; 279 | 280 | return false; 281 | } 282 | }); 283 | 284 | if (!result) { 285 | throw new Error(`Could not find node with id '${id}'`); 286 | } 287 | 288 | return result; 289 | } 290 | 291 | private _traverse(root: WorkItemNode, cb: (node: WorkItemNode, parent?: WorkItemNode) => boolean | void) { 292 | let itemToParent: IDictionaryNumberTo = {}; 293 | 294 | let stack = [root]; 295 | while (stack.length > 0) { 296 | let node = stack.pop(); 297 | 298 | if (cb(node, itemToParent[node.workItem.id] || null) === false) { 299 | // Abort traversal 300 | return; 301 | } 302 | 303 | let reverseChildren = node.children.slice(0).reverse(); 304 | for (let child of reverseChildren) { 305 | itemToParent[child.workItem.id] = node; 306 | 307 | stack.push(child); 308 | } 309 | } 310 | } 311 | } -------------------------------------------------------------------------------- /src/services/workItemCreator.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import WIT_Client = require("TFS/WorkItemTracking/RestClient"); 4 | import WIT_Contracts = require("TFS/WorkItemTracking/Contracts"); 5 | 6 | import Q = require("q"); 7 | 8 | import { IResultWorkItem } from "../interfaces"; 9 | 10 | import { WorkItemTypeService } from "./workItemTypeService"; 11 | 12 | interface ISaveResult { 13 | tempId: number; 14 | id?: number; 15 | reason?: string; 16 | } 17 | 18 | export class WorkItemCreator { 19 | private _tempToRealParentIdMap: IDictionaryNumberTo; 20 | private _failedWorkItems: IDictionaryNumberTo; 21 | 22 | // In order to preserve the order of child items we'll set a stack rank matching the order in which they 23 | // were created. Once a user reorders them, this will trigger resparsification. 24 | private _order = 0; 25 | 26 | constructor(private _parentId: number, private _iterationPath: string, private _areaPath: string) { 27 | this._tempToRealParentIdMap = {}; 28 | this._failedWorkItems = {}; 29 | 30 | this._tempToRealParentIdMap[this._parentId] = this._parentId; 31 | } 32 | 33 | /** Create work items, return ids of failed operations */ 34 | public createWorkItems(result: IResultWorkItem[]): IPromise> { 35 | return this._createWorkItemsWorker(result.slice(0)).then(() => { 36 | return this._failedWorkItems; 37 | }); 38 | } 39 | 40 | private _createWorkItemsWorker(workItems: IResultWorkItem[]): IPromise { 41 | if (!workItems || !workItems.length) { 42 | return Q(null); 43 | } 44 | 45 | let workItemsToCreate: IResultWorkItem[] = []; 46 | let promises = []; 47 | 48 | for (let workItem of workItems) { 49 | if (workItem.id !== this._parentId) { 50 | if (workItem.parentId === this._parentId || this._tempToRealParentIdMap[workItem.parentId]) { 51 | promises.push(this._getCreateWorkItemPromise(workItem)); 52 | } else { 53 | if (this._failedWorkItems[workItem.parentId]) { 54 | // Parent has failed to save earlier, mark as failed and do not try again 55 | this._failedWorkItems[workItem.id] = "Parent could not be saved"; 56 | } else if (!this._failedWorkItems[workItem.id]) { 57 | // Work item still needs to be created 58 | workItemsToCreate.push(workItem); 59 | } 60 | } 61 | } 62 | } 63 | 64 | return Q.all(promises).then((results: ISaveResult[]) => { 65 | for (let result of results) { 66 | if (!!result.reason) { 67 | // Work item failed to save 68 | this._failedWorkItems[result.tempId] = result.reason || "Work item failed to save"; 69 | } else { 70 | // Work item was saved successfully 71 | } 72 | } 73 | 74 | // Start next batch 75 | return this._createWorkItemsWorker(workItemsToCreate); 76 | }); 77 | } 78 | 79 | private _getCreateWorkItemPromise(workItem: IResultWorkItem): IPromise { 80 | let context = VSS.getWebContext(); 81 | let client = WIT_Client.getClient(); 82 | 83 | let workItemType = workItem.typeName; 84 | let parentId = this._tempToRealParentIdMap[workItem.parentId]; 85 | 86 | let patchDocument: any[] = []; 87 | 88 | patchDocument.push(this._getAddFieldOp("System.Title", workItem.title)); 89 | patchDocument.push(this._getAddFieldOp("System.IterationPath", this._iterationPath)); 90 | patchDocument.push(this._getAddFieldOp("System.AreaPath", this._areaPath)); 91 | patchDocument.push(this._getAddFieldOp(WorkItemTypeService.getInstance().getOrderFieldRefName(), (++this._order).toString(10))); 92 | patchDocument.push({ 93 | "op": "add", 94 | "path": "/relations/-", 95 | "value": { 96 | "rel": "System.LinkTypes.Hierarchy-Reverse", 97 | "url": `${context.collection.uri}/_apis/wit/workItems/${parentId}` 98 | } 99 | }); 100 | 101 | return client.createWorkItem(patchDocument, context.project.id, workItemType).then((createdWorkItem: WIT_Contracts.WorkItem) => { 102 | this._tempToRealParentIdMap[workItem.id] = createdWorkItem.id; 103 | 104 | return { 105 | tempId: workItem.id, 106 | id: createdWorkItem.id 107 | }; 108 | }, (error) => { 109 | return { 110 | tempId: workItem.id, 111 | reason: error.message 112 | }; 113 | }); 114 | } 115 | 116 | private _getAddFieldOp(fieldName: string, value: string): any { 117 | return { 118 | "op": "add", 119 | "path": `/fields/${fieldName}`, 120 | "value": value 121 | }; 122 | } 123 | } -------------------------------------------------------------------------------- /src/services/workItemTypeService.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import Work_Client = require("TFS/Work/RestClient"); 4 | import Work_Contracts = require("TFS/Work/Contracts"); 5 | 6 | import WorkItemTracking_Client = require("TFS/WorkItemTracking/RestClient"); 7 | import WorkItemTracking_Contracts = require("TFS/WorkItemTracking/Contracts"); 8 | 9 | import Q = require("q"); 10 | 11 | import { IBacklogLevel, IWorkItemType } from "../interfaces"; 12 | import { IWorkItemTypeAdapter } from "../model/workItemTree"; 13 | 14 | export class WorkItemTypeService implements IWorkItemTypeAdapter { 15 | private static _instance: WorkItemTypeService; 16 | public static getInstance(): WorkItemTypeService { 17 | if (!WorkItemTypeService._instance) { 18 | WorkItemTypeService._instance = new WorkItemTypeService(); 19 | } 20 | 21 | return WorkItemTypeService._instance; 22 | } 23 | 24 | private _backlogs: IBacklogLevel[] = []; 25 | private _level = 0; 26 | private _orderFieldRefName: string; 27 | 28 | private _mapBacklog(backlog: Work_Contracts.BacklogLevelConfiguration): IBacklogLevel { 29 | return { 30 | types: backlog.workItemTypes.map(wit => this._mapWorkItemType(wit)), 31 | defaultType: this._mapWorkItemType(backlog.defaultWorkItemType), 32 | level: ++this._level 33 | }; 34 | } 35 | 36 | private _mapWorkItemType(type: WorkItemTracking_Contracts.WorkItemTypeReference) { 37 | return { 38 | name: type.name 39 | }; 40 | } 41 | 42 | public init(): IPromise { 43 | const webContext = VSS.getWebContext(); 44 | 45 | const client = Work_Client.getClient(); 46 | const teamContext = { 47 | project: webContext.project.name, 48 | projectId: webContext.project.id, 49 | team: webContext.team.name, 50 | teamId: webContext.team.id 51 | }; 52 | 53 | const witClient = WorkItemTracking_Client.getClient(); 54 | 55 | return Q.all([ 56 | client.getBacklogConfigurations(teamContext), 57 | client.getProcessConfiguration(webContext.project.id), 58 | witClient.getWorkItemTypes(webContext.project.id) 59 | ]) 60 | .spread(( 61 | backlogConfiguration: Work_Contracts.BacklogConfiguration, processConfiguration: Work_Contracts.ProcessConfiguration, workItemTypes: WorkItemTracking_Contracts.WorkItemType[]) => { 62 | // Sort by rank desc 63 | backlogConfiguration.portfolioBacklogs.sort((a, b) => b.rank - a.rank); 64 | 65 | for (let portfolioBacklog of backlogConfiguration.portfolioBacklogs) { 66 | this._backlogs.push(this._mapBacklog(portfolioBacklog)); 67 | } 68 | 69 | const requirementBacklog = this._mapBacklog(backlogConfiguration.requirementBacklog); 70 | this._backlogs.push(requirementBacklog); 71 | 72 | const taskBacklog = this._mapBacklog(backlogConfiguration.taskBacklog); 73 | this._backlogs.push(taskBacklog); 74 | 75 | this._orderFieldRefName = processConfiguration.typeFields["Order"].referenceName; 76 | 77 | // Ugly-ness ahead.. do no try this at home! 78 | const workItemTypeProbePromises: IPromise[] = []; 79 | 80 | for (let backlog of this._backlogs) { 81 | if (backlog.types.length > 1) { 82 | for (let workItemType of backlog.types) { 83 | // Some type might be disabled, check every type that's not default 84 | workItemTypeProbePromises.push(witClient.createWorkItem([{ 85 | op: "add", 86 | path: "/fields/System.Title", 87 | value: "" 88 | }], webContext.project.id, workItemType.name, true).then(null, (error) => { 89 | const { serverError } = error; 90 | 91 | if (serverError 92 | && serverError.typeKey !== "RuleValidationException" 93 | && serverError.typeKey !== "WorkItemTrackingRuleValidationAggregateException") { 94 | // Creation disabled, remove from backlog 95 | backlog.types.splice(backlog.types.indexOf(workItemType), 1); 96 | } 97 | })); 98 | } 99 | } 100 | } 101 | 102 | return Q.all(workItemTypeProbePromises).then(() => { 103 | // Add work item type colors 104 | for (let workItemType of workItemTypes) { 105 | for (let backlog of this._backlogs) { 106 | for (let backlogWorkItemType of backlog.types) { 107 | if (backlogWorkItemType.name.toUpperCase() === workItemType.name.toUpperCase()) { 108 | backlogWorkItemType.color = `#${workItemType.color}`; 109 | } 110 | } 111 | } 112 | } 113 | }); 114 | }); 115 | } 116 | 117 | public getOrderFieldRefName(): string { 118 | return this._orderFieldRefName; 119 | } 120 | 121 | public getMinLevel(): number { 122 | return this._backlogs.reduce((maxLevel, wit) => Math.min(wit.level, maxLevel), Number.MAX_VALUE); 123 | } 124 | 125 | public getMaxLevel(): number { 126 | return this._backlogs.reduce((maxLevel, wit) => Math.max(wit.level, maxLevel), 0); 127 | } 128 | 129 | public getType(typeName: string): IWorkItemType { 130 | for (let backlog of this._backlogs) { 131 | for (let type of backlog.types) { 132 | if (type.name.toLocaleLowerCase() === typeName.toLocaleLowerCase()) { 133 | return type; 134 | } 135 | } 136 | } 137 | 138 | throw new Error("Unknown type"); 139 | } 140 | 141 | public getBacklogForLevel(level: number): IBacklogLevel { 142 | let matchingType = this._backlogs.filter(wit => wit.level === level); 143 | if (matchingType && matchingType.length > 0) { 144 | return matchingType[0]; 145 | } 146 | 147 | return null; 148 | } 149 | 150 | public getLevelForTypeName(typeName: string): number { 151 | for (let backlog of this._backlogs) { 152 | for (let type of backlog.types) { 153 | if (type.name.toLocaleLowerCase() === typeName.toLocaleLowerCase()) { 154 | return backlog.level; 155 | } 156 | } 157 | } 158 | 159 | return null; 160 | } 161 | 162 | /** Returns default type name for given level */ 163 | public getTypeNameForLevel(level: number): string { 164 | let matchingBacklog = this._backlogs.filter(wit => wit.level === level); 165 | if (matchingBacklog && matchingBacklog.length > 0) { 166 | return matchingBacklog[0].defaultType.name; 167 | } 168 | 169 | return null; 170 | } 171 | } -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { IWorkItem, IResultWorkItem } from "./interfaces"; 2 | import { WorkItemTypeService } from "./services/workItemTypeService"; 3 | import { WorkItemTree } from "./model/workItemTree"; 4 | 5 | import * as Actions from "./actions"; 6 | 7 | export interface StoreListener { 8 | (): void; 9 | } 10 | 11 | class BaseStore { 12 | private _handlers: StoreListener[] = []; 13 | 14 | public addListener(handler: StoreListener) { 15 | this._handlers.push(handler); 16 | } 17 | 18 | protected _emitChanged() { 19 | for (let handler of this._handlers) { 20 | handler(); 21 | } 22 | } 23 | } 24 | 25 | export class Store extends BaseStore { 26 | private _parentWorkItem: IWorkItem; 27 | private _tree: WorkItemTree; 28 | 29 | constructor(parentWorkItem: IWorkItem) { 30 | super(); 31 | 32 | this._parentWorkItem = parentWorkItem; 33 | this._tree = new WorkItemTree(this._parentWorkItem, WorkItemTypeService.getInstance()); 34 | 35 | Actions.changeWorkItemLevel.addListener(this._changeIndentLevel.bind(this)); 36 | Actions.insertItem.addListener(this._insertItem.bind(this)); 37 | Actions.changeTitle.addListener(this._changeTitle.bind(this)); 38 | Actions.deleteItem.addListener(this._deleteItem.bind(this)); 39 | Actions.changeType.addListener(this._changeType.bind(this)); 40 | 41 | // Add initial new item 42 | this._insertItem({ 43 | afterId: this._parentWorkItem.id 44 | }); 45 | } 46 | 47 | public getParentItem(): IWorkItem { 48 | return this._parentWorkItem; 49 | } 50 | 51 | public getItems(): IWorkItem[] { 52 | let displayTree = this._tree.displayTree(); 53 | 54 | for (let entry of displayTree) { 55 | entry.relativeLevel = entry.level - this._parentWorkItem.level; 56 | } 57 | 58 | return displayTree; 59 | } 60 | 61 | public getResult(): IResultWorkItem[] { 62 | return this._tree.resultTree(); 63 | } 64 | 65 | public getIsValid(): boolean { 66 | let currentResult = this.getResult(); 67 | 68 | return currentResult && currentResult.length > 1 && currentResult.every(n => n.title.trim() !== ""); 69 | } 70 | 71 | private _deleteItem(payload: Actions.IDeleteItemPayload) { 72 | this._tree.deleteItem(payload.id); 73 | this._emitChanged(); 74 | } 75 | 76 | private _changeIndentLevel(payload: Actions.IChangeWorkItemLevelPayload) { 77 | let result: boolean; 78 | 79 | if (payload.indentLevelChange < 0) { 80 | result = this._tree.outdent(payload.id); 81 | } else { 82 | result = this._tree.indent(payload.id); 83 | } 84 | 85 | if (result) { 86 | this._emitChanged(); 87 | } 88 | } 89 | 90 | private _changeTitle(payload: Actions.IChangeWorkItemTitle) { 91 | let workItem = this._getItem(payload.id); 92 | 93 | workItem.title = payload.title; 94 | 95 | this._emitChanged(); 96 | } 97 | 98 | private _changeType(payload: Actions.IChangeTypePayload) { 99 | this._tree.changeType(payload.id); 100 | this._emitChanged(); 101 | } 102 | 103 | private _insertItem(payload: Actions.IInsertItemPayload) { 104 | if (this._tree.insert(payload.afterId) !== null) { 105 | this._emitChanged(); 106 | } 107 | } 108 | 109 | private _getItem(id: number): IWorkItem { 110 | return this._tree.getItem(id); 111 | } 112 | } -------------------------------------------------------------------------------- /src/style.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cschleiden/azure-boards-decompose/92d662e3391b4a2ead8088956e3fa1a2f885cfaa/src/style.scss -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "amd", 4 | "sourceMap": false, 5 | "target": "es5", 6 | "jsx": "react", 7 | "moduleResolution": "node" 8 | }, 9 | "filesGlob": [ 10 | "src/**/*.ts", 11 | "src/**/*.tsx" 12 | ] 13 | } -------------------------------------------------------------------------------- /vss-extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": 1, 3 | "version": "1.1.2", 4 | "name": "Decompose", 5 | "scopes": [ 6 | "vso.work", 7 | "vso.work_write" 8 | ], 9 | "description": "Quickly decompose work item into sub-hierarchies", 10 | "publisher": "cschleiden", 11 | "id": "decompose", 12 | "icons": { 13 | "default": "marketplace/logo.png" 14 | }, 15 | "targets": [ 16 | { 17 | "id": "Microsoft.VisualStudio.Services" 18 | } 19 | ], 20 | "demands": [ 21 | "api-version/3.0" 22 | ], 23 | "tags": [ 24 | "Work Items", 25 | "Decompose" 26 | ], 27 | "content": { 28 | "details": { 29 | "path": "marketplace/details.md" 30 | } 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "uri": "https://github.com/cschleiden/vsts-quick-decompose" 35 | }, 36 | "links": { 37 | "support": { 38 | "uri": "mailto:christopher.schleiden@microsoft.com" 39 | } 40 | }, 41 | "branding": { 42 | "color": "rgb(220, 235, 252)", 43 | "theme": "light" 44 | }, 45 | "categories": [ 46 | "Plan and track" 47 | ], 48 | "contributions": [ 49 | { 50 | "id": "contextMenu", 51 | "type": "ms.vss-web.action", 52 | "targets": [ 53 | "ms.vss-work-web.work-item-context-menu" 54 | ], 55 | "properties": { 56 | "group": "contributed", 57 | "uri": "src/index.html", 58 | "text": "Decompose work item" 59 | } 60 | }, 61 | { 62 | "id": "addItemsDialog", 63 | "type": "ms.vss-web.control", 64 | "targets": [], 65 | "properties": { 66 | "uri": "src/dialog.html" 67 | } 68 | } 69 | ] 70 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var webpack = require("webpack"); 3 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | 5 | module.exports = { 6 | target: "web", 7 | entry: { 8 | registration: "./src/app.ts", 9 | dialog: "./src/dialog.tsx" 10 | }, 11 | output: { 12 | filename: "src/[name].js", 13 | libraryTarget: "amd" 14 | }, 15 | externals: [ 16 | /^VSS\/.*/, /^TFS\/.*/, /^q$/ 17 | ], 18 | resolve: { 19 | extensions: [ 20 | "", 21 | ".webpack.js", 22 | ".web.js", 23 | ".ts", 24 | ".tsx", 25 | ".js"], 26 | root: [ 27 | path.resolve("./src") 28 | ] 29 | }, 30 | module: { 31 | loaders: [ 32 | { 33 | test: /\.tsx?$/, 34 | loader: "ts-loader" 35 | }, 36 | { 37 | test: /\.s?css$/, 38 | loaders: ["style", "css", "sass"] 39 | } 40 | ] 41 | }, 42 | plugins: [ 43 | new CopyWebpackPlugin([ 44 | { from: "./node_modules/vss-web-extension-sdk/lib/VSS.SDK.min.js", to: "libs/VSS.SDK.min.js" }, 45 | { from: "./src/*.html", to: "./" }, 46 | { from: "./marketplace", to: "marketplace" }, 47 | { from: "./vss-extension.json", to: "vss-extension-release.json" } 48 | ]) 49 | ] 50 | } --------------------------------------------------------------------------------