├── .azure-devops └── azure-pipelines.yml ├── .gitignore ├── LICENSE ├── SECURITY.md ├── azure-devops-extension.json ├── configs ├── beta.json ├── dev.json ├── devHttp.json └── release.json ├── img ├── addControl.png ├── addControlOptionsPicklist.png ├── addControlOptionsString.png ├── addcustomcontrol.png ├── allowedValues.png ├── definition.png ├── fieldtypes.png ├── form.png ├── layoutCustomization.png ├── logo.png ├── multivalue-control.png ├── operatingSystem.png ├── operatingSystemCollapsed.png ├── operatingSystemExpanded.png ├── options.png ├── picklistField.png ├── picklistFieldOptions.png ├── stringField.png └── workItemType.png ├── marketplace └── overview.md ├── package-lock.json ├── package.json ├── readme.md ├── scripts ├── packageBeta.js ├── packageDev.js ├── packageDevHttp.js ├── packageRelease.js ├── publishDev.js └── publishRelease.js ├── src ├── MultiValueControl.tsx ├── MultiValueEvents.tsx ├── getSuggestedValues.ts ├── multi-selection.scss ├── multivalue.html ├── multivalue.ts ├── theme.ts └── themeManager.ts ├── tsconfig.json ├── tslint.json ├── typings.json ├── webpack.config.js └── xmldetails.md /.azure-devops/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | - name: version.MajorMinor # Manually adjust the version number as needed for semantic versioning. Patch is auto-incremented. 3 | value: '2.2' 4 | - name: extensionName 5 | value: 'vsts-extensions-multivalue-control' 6 | - name: marketplaceServiceConnection 7 | value: 'marketplaceServiceConnection' 8 | - name: publisherId 9 | value: 'ms-devlabs' 10 | - name: publicExtensionName 11 | value: "Multivalue control" 12 | 13 | 14 | name: $(version.MajorMinor)$(rev:.r) 15 | 16 | trigger: 17 | branches: 18 | include: 19 | - master 20 | 21 | pr: none 22 | 23 | resources: 24 | repositories: 25 | - repository: pipeline-templates 26 | type: git 27 | name: DevLabs Extensions/pipeline-templates 28 | ref: main 29 | 30 | stages: 31 | - stage: 'Build' 32 | jobs: 33 | - job: 'BuildPack' 34 | displayName: "Build and package" 35 | pool: 36 | vmImage: ubuntu-latest 37 | steps: 38 | 39 | - task: NodeTool@0 40 | inputs: 41 | versionSpec: '14.x' 42 | - template: build.yml@pipeline-templates 43 | - template: package.yml@pipeline-templates 44 | parameters: 45 | extensionName: $(extensionName) 46 | outputPath: 'out' 47 | rootPath: './' 48 | 49 | - stage: 'DeployDev' 50 | displayName: 'Deploy to dev' 51 | dependsOn: Build 52 | condition: succeeded() 53 | jobs: 54 | - template: deploy.yml@pipeline-templates 55 | parameters: 56 | environment: 'dev' 57 | extensionName: $(extensionName) 58 | marketplaceConnectedServiceName: $(marketplaceServiceConnection) 59 | publisherId: $(publisherId) 60 | publicExtensionName: $(publicExtensionName) 61 | updateTaskVersion: true 62 | 63 | - stage: 'DeployTest' 64 | displayName: 'Deploy to Test' 65 | dependsOn: DeployDev 66 | condition: succeeded() 67 | jobs: 68 | - template: deploy.yml@pipeline-templates 69 | parameters: 70 | environment: 'test' 71 | extensionName: $(extensionName) 72 | marketplaceConnectedServiceName: $(marketplaceServiceConnection) 73 | publisherId: $(publisherId) 74 | publicExtensionName: $(publicExtensionName) 75 | updateTaskVersion: true 76 | 77 | - stage: 'DeployRelease' 78 | displayName: 'Deploy Release' 79 | dependsOn: DeployTest 80 | condition: succeeded() 81 | jobs: 82 | - template: deploy.yml@pipeline-templates 83 | parameters: 84 | environment: 'public' 85 | extensionName: $(extensionName) 86 | marketplaceConnectedServiceName: $(marketplaceServiceConnection) 87 | publisherId: $(publisherId) 88 | publicExtensionName: $(publicExtensionName) 89 | extensionVisibility: 'public' 90 | updateTaskVersion: true 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .tscache 3 | node_modules 4 | extension-*.json 5 | settings.vset.json 6 | *.vsix 7 | typings 8 | .vs 9 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | All rights reserved. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /azure-devops-extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": 1.0, 3 | "id": "vsts-extensions-multivalue-control", 4 | "version": "2.2.29", 5 | "name": "Multivalue control", 6 | "description": "A work item form control which allows selection of multiple values.", 7 | "publisher": "ms-devlabs", 8 | "icons": { 9 | "default": "img/logo.png" 10 | }, 11 | "categories": [ 12 | "Azure Boards" 13 | ], 14 | "targets": [ 15 | { 16 | "id": "Microsoft.VisualStudio.Services" 17 | } 18 | ], 19 | "files": [ 20 | { 21 | "path": "dist", 22 | "addressable": true 23 | }, 24 | { 25 | "path": "img", 26 | "addressable": true 27 | } 28 | ], 29 | "repository": { 30 | "type": "git", 31 | "uri": "https://github.com/Microsoft/vsts-extension-multivalue-control" 32 | }, 33 | "links": { 34 | "repository": { 35 | "uri": "https://github.com/Microsoft/vsts-extension-multivalue-control" 36 | }, 37 | "issues": { 38 | "uri": "https://github.com/Microsoft/vsts-extension-multivalue-control/issues" 39 | }, 40 | "support": { 41 | "uri": "https://github.com/Microsoft/vsts-extension-multivalue-control/issues" 42 | } 43 | }, 44 | "tags": [ 45 | "Work Items", 46 | "Extensions", 47 | "Work Item Control", 48 | "Sample", 49 | "Multivalue Control" 50 | ], 51 | "content": { 52 | "details": { 53 | "path": "marketplace/overview.md" 54 | }, 55 | "license": { 56 | "path": "LICENSE" 57 | } 58 | }, 59 | "scopes": [ 60 | "vso.work" 61 | ], 62 | "contributions": [ 63 | { 64 | "id": "multivalue-form-control", 65 | "type": "ms.vss-work-web.work-item-form-control", 66 | "description": "A work item form control which allows selection of multiple values.", 67 | "targets": [ 68 | "ms.vss-work-web.work-item-form" 69 | ], 70 | "properties": { 71 | "name": "Multivalue control", 72 | "uri": "dist/multivalue.html", 73 | "height": 50, 74 | "inputs": [ 75 | { 76 | "id": "FieldName", 77 | "name": "Select the field for this control. This is the only input needed if the field is a picklist field with suggested values.", 78 | "type": "WorkItemField", 79 | "properties": { 80 | "workItemFieldTypes": [ 81 | "String", 82 | "PlainText", 83 | "HTML" 84 | ] 85 | }, 86 | "validation": { 87 | "dataType": "String", 88 | "isRequired": true 89 | } 90 | }, 91 | { 92 | "id": "Values", 93 | "name": "Choose values for the control. This is only required if you're not using a picklist field. Example: Windows; IOS; Linux", 94 | "description": "Values can be user provided or from suggested values of the backing field", 95 | "validation": { 96 | "dataType": "String", 97 | "isRequired": false 98 | } 99 | }, 100 | { 101 | "id": "AllowCustom", 102 | "name": "Allow users to enter custom values", 103 | "inputMode": "CheckBox", 104 | "validation": { 105 | "dataType": "Boolean", 106 | "isRequired": false 107 | } 108 | }, 109 | { 110 | "id": "LabelDisplayLength", 111 | "name": "Set the maximum display length for each option's label. Defaults to 35 if not set.", 112 | "validation": { 113 | "dataType": "Number", 114 | "isRequired": false 115 | } 116 | } 117 | ] 118 | } 119 | } 120 | ] 121 | } -------------------------------------------------------------------------------- /configs/beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Multivalue Control (BETA)", 3 | "galleryFlags": [ 4 | "Preview" 5 | ] 6 | } -------------------------------------------------------------------------------- /configs/dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": false, 3 | "baseUri": "https://localhost:8888" 4 | } -------------------------------------------------------------------------------- /configs/devHttp.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": false, 3 | "baseUri": "http://localhost:8888" 4 | } -------------------------------------------------------------------------------- /configs/release.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": true 3 | } -------------------------------------------------------------------------------- /img/addControl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/addControl.png -------------------------------------------------------------------------------- /img/addControlOptionsPicklist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/addControlOptionsPicklist.png -------------------------------------------------------------------------------- /img/addControlOptionsString.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/addControlOptionsString.png -------------------------------------------------------------------------------- /img/addcustomcontrol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/addcustomcontrol.png -------------------------------------------------------------------------------- /img/allowedValues.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/allowedValues.png -------------------------------------------------------------------------------- /img/definition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/definition.png -------------------------------------------------------------------------------- /img/fieldtypes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/fieldtypes.png -------------------------------------------------------------------------------- /img/form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/form.png -------------------------------------------------------------------------------- /img/layoutCustomization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/layoutCustomization.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/logo.png -------------------------------------------------------------------------------- /img/multivalue-control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/multivalue-control.png -------------------------------------------------------------------------------- /img/operatingSystem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/operatingSystem.png -------------------------------------------------------------------------------- /img/operatingSystemCollapsed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/operatingSystemCollapsed.png -------------------------------------------------------------------------------- /img/operatingSystemExpanded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/operatingSystemExpanded.png -------------------------------------------------------------------------------- /img/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/options.png -------------------------------------------------------------------------------- /img/picklistField.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/picklistField.png -------------------------------------------------------------------------------- /img/picklistFieldOptions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/picklistFieldOptions.png -------------------------------------------------------------------------------- /img/stringField.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/stringField.png -------------------------------------------------------------------------------- /img/workItemType.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vsts-extension-multivalue-control/ec62dccf6b6890f7ad3f0bffd0917d2c5093d2c4/img/workItemType.png -------------------------------------------------------------------------------- /marketplace/overview.md: -------------------------------------------------------------------------------- 1 | # Multi-value-control 2 | 3 | The Multi-Value Control Azure DevOps Extension enhances work item forms by enabling the selection of multiple values within a single field. This functionality is ideal for scenarios requiring categorization under multiple tags. 4 | 5 | # Documentation 6 | 7 | > Currently only available on TFS 2017 or later and Azure DevOps. 8 | 9 | ## How to get started 10 | To use the Multivalue control you need a work item field to store the data used by the control and then add the Multivalue control to the form, linking it to the underlying field. 11 | 12 | ### Create a work item field 13 | You can setup the control to either use a picklist or a semi-colon separate list of values as the domain values for the control. 14 | 15 | > Note: the string field in Azure DevOps can maximum store 255 characters. If you have a large number of items in your list then use an HTML field (Text (multiple lines)) instead. 16 | 17 | #### Adding a picklist field 18 | 19 | * Add a picklist(string) field and add the picklist items in the field definition. 20 | 21 | ![picklistField](img/picklistField.png) 22 | 23 | * In the option section, select "Allow users to enter their own value" 24 | 25 | ![fieldOptions](img/picklistFieldOptions.png) 26 | 27 | #### Adding a string field 28 | 29 | * Add a Text field (use multi line if you need more that 255 characters to store the selected items) 30 | 31 | ![stringField](img/stringField.png) 32 | 33 | ### Add a Multi Value control to the work item form 34 | When you have created the work item field to store the value for the Multivalue control you can add the control to the form and link it to the underlying field. 35 | 36 | * Navigate to Project Settings and select Process. From there, choose the work item type to which you would like to add a custom field 37 | 38 | ![workItemType](img/workItemType.png) 39 | 40 | * Select the customization page and add a multivalue control to the form. 41 | 42 | ![addControl](img/addControl.png) 43 | 44 | * Select the field for the control and choose the appropriate values for the control. 45 | 46 | If you use a picklist field: 47 | 48 | ![addControlOptionsPicklist](img/addControlOptionsPicklist.png) 49 | 50 | If you use a string field then also configure the values for the control as a semi-colon separated list in the control definition. 51 | 52 | ![addControlOptionsString](img/addControlOptionsString.png) 53 | 54 | ## XML process template 55 | 56 | To define the layout for a work item type using XML, you'll need to add the Multivalue control to your layout 57 | 58 | [Learn more](https://github.com/Microsoft/vsts-extension-multivalue-control/blob/master/xmldetails.md) about how to customize the multivalue control directly on XML. 59 | 60 | # How to query 61 | 62 | The selected values are stored in a semicolon separated format. To search for items that have a specific value use the "Contains Words" operator. If searching for multiple values, use multipe "Contains Words" clauses for that field. 63 | 64 | You can also learn how to build your own custom control extension for the work item form [here](https://www.visualstudio.com/en-us/docs/integrate/extensions/develop/custom-control). 65 | 66 | # Support 67 | 68 | ## How to file issues and get help 69 | 70 | This project uses [GitHub Issues](https://github.com/Microsoft/vsts-extension-multivalue-control/issues) to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue. 71 | 72 | ## Microsoft Support Policy 73 | 74 | Support for this project is limited to the resources listed above. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "webpack.config.js", 3 | "scripts": { 4 | "clean": "rimraf dist *.vsix vss-extension-release.json src/*js libs", 5 | "dev": "npm run build:dev && webpack-dev-server --hot --progress --color --https --port 8888 --static ./", 6 | "dev:http": "webpack-dev-server --hot --progress --color --no-https --port 8888 --static-public-path ./src", 7 | "package:dev": "node ./scripts/packageDev", 8 | "package:dev:http": "node ./scripts/packageDevHttp", 9 | "package:release": "node ./scripts/packageRelease", 10 | "package:beta": "node ./scripts/packageBeta", 11 | "publish:dev": "npm run package:dev && node ./scripts/publishDev", 12 | "build:styles": " node ./node_modules/sass/sass.js ./src/multi-selection.scss ./dist/multi-selection.css", 13 | "build:dev": "npm run clean && mkdir dist && npm run build:styles && webpack --progress --color --output-path ./dist", 14 | "build:release": "npm run clean && mkdir dist && npm run build:styles && webpack --progress --color --output-path ./dist --mode production", 15 | "publish:release": "npm run build:release && node ./scripts/publishRelease", 16 | "test": "karma start --single-run" 17 | }, 18 | "devDependencies": { 19 | "@types/applicationinsights-js": "^1.0.5", 20 | "@types/jquery": "^2.0.41", 21 | "@types/jsonpath": "^0.2.0", 22 | "@types/react": "^16.4.8", 23 | "@types/react-dom": "^16.0.7", 24 | "azure-devops-extension-sdk": "^4.0.2", 25 | "azure-devops-ui": "^2.251.0", 26 | "copy-webpack-plugin": "^9.0.1", 27 | "css-loader": "^6.7.1", 28 | "jsonpath": "^1.1.1", 29 | "lodash": "^4.17.21", 30 | "office-ui-fabric-react": "^6.47.1", 31 | "react": "^16.14.0", 32 | "react-dom": "^16.14.0", 33 | "rimraf": "^2.6.1", 34 | "sass": "^1.23.3", 35 | "style-loader": "^0.16.1", 36 | "tfx-cli": "^0.11.0", 37 | "ts-loader": "^9.2.6", 38 | "typescript": "^3.9.10", 39 | "vss-web-extension-sdk": "^5.141.0", 40 | "webpack": "^5.76.0", 41 | "webpack-bundle-analyzer": "^4.5.0", 42 | "webpack-cli": "^4.9.1", 43 | "webpack-dev-server": "^4.8.1" 44 | }, 45 | "dependencies": { 46 | "es6-promise": "^4.2.8" 47 | }, 48 | "name": "multivalue-control-extension", 49 | "license": "MIT", 50 | "description": "VSTS Work Item Form Multivalue Control Extension", 51 | "repository": "", 52 | "private": false, 53 | "version": "0.0.0" 54 | } 55 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Multi-value-control 2 | 3 | The Azure DevOps Extension Multi-Value Control enhances work item forms by enabling the selection of multiple values within a single field. This functionality is ideal for scenarios requiring categorization under multiple tags. 4 | 5 | # Documentation 6 | 7 | For detailed instructions on using the Multi-value-control Azure DevOps extension, please refer to the official documentation. You can access the comprehensive guide by clicking [Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-devlabs.vsts-extensions-multivalue-control). This resource provides step-by-step information to help you effectively utilize the Multi-value-control features within your Azure DevOps environment. 8 | 9 | # Support 10 | 11 | ## How to file issues and get help 12 | 13 | This project uses [GitHub Issues](https://github.com/Microsoft/vsts-extension-multivalue-control/issues) to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue. 14 | 15 | ## Microsoft Support Policy 16 | 17 | Support for this project is limited to the resources listed above. 18 | 19 | # Contributing 20 | 21 | We welcome contributions to improve the extension. If you would like to contribute, please fork the repository and create a pull request with your changes. Your 22 | contributions help enhance the functionality and usability of the extension for the entire community. 23 | 24 | **Note:** do not publish the extension as a public extension under a different publisher as this will create a clone of the extension and it will be unclear to the 25 | community which one to use. If you feel you don't want to contribute to this repository then publish a private version for your use-case. 26 | 27 | Check out https://learn.microsoft.com/en-us/azure/devops/extend/overview?view=azure-devops to learn how to develop Azure DevOps extensions 28 | 29 | ### Developing and Testing 30 | 31 | ```bash 32 | # Install node dependencies 33 | npm install 34 | 35 | # Compile the source code 36 | npm run dev 37 | 38 | # Build the extension 39 | npm run build:release 40 | ``` 41 | 42 | ## About Microsoft DevLabs 43 | 44 | Microsoft DevLabs is an outlet for experiments from Microsoft, experiments that represent some of the latest ideas around developer tools. Solutions in this 45 | category are designed for broad usage, and you are encouraged to use and provide feedback on them; however, these extensions are not supported nor are any 46 | commitments made as to their longevity. 47 | -------------------------------------------------------------------------------- /scripts/packageBeta.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var exec = require("child_process").exec; 4 | 5 | var manifest = require("../azure-devops-extension.json"); 6 | var extensionId = manifest.id; 7 | 8 | // Package extension 9 | var command = `tfx extension create --extension-id ${extensionId}-beta --overrides-file configs/beta.json --manifest-globs azure-devops-extension.json --no-prompt --json`; 10 | exec(command, (error, stdout) => { 11 | if (error) { 12 | console.error(`Could not create package: '${error}'`); 13 | return; 14 | } 15 | 16 | let output = JSON.parse(stdout); 17 | 18 | console.log(`Package created ${output.path}`); 19 | } 20 | ); -------------------------------------------------------------------------------- /scripts/packageDev.js: -------------------------------------------------------------------------------- 1 | var exec = require("child_process").exec; 2 | 3 | // Load existing publisher 4 | var manifest = require("../azure-devops-extension.json"); 5 | var extensionId = manifest.id; 6 | 7 | // Package extension 8 | var command = `tfx extension create --overrides-file configs/dev.json --manifest-globs azure-devops-extension.json --extension-id ${extensionId}-dev --no-prompt --rev-version`; 9 | exec(command, function(err, stdout, stderr) { 10 | console.log(stderr); 11 | console.log(stdout); 12 | if (err) { 13 | console.error(err); 14 | } 15 | }); -------------------------------------------------------------------------------- /scripts/packageDevHttp.js: -------------------------------------------------------------------------------- 1 | var exec = require("child_process").exec; 2 | 3 | // Load existing publisher 4 | var manifest = require("../azure-devops-extension.json"); 5 | var extensionId = manifest.id; 6 | 7 | // Package extension 8 | var command = `tfx extension create --overrides-file configs/devHttp.json --manifest-globs azure-devops-extension.json --extension-id ${extensionId}-dev --no-prompt`; 9 | exec(command, function() { 10 | console.log("Package created"); 11 | }); -------------------------------------------------------------------------------- /scripts/packageRelease.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 azure-devops-extension.json --no-prompt --json`; 7 | exec(command, function(err, stdout, stderr) { 8 | console.log(stderr); 9 | console.log(stdout); 10 | if (err) { 11 | console.error(err); 12 | } 13 | }); -------------------------------------------------------------------------------- /scripts/publishDev.js: -------------------------------------------------------------------------------- 1 | var exec = require("child_process").exec; 2 | 3 | var manifest = require("../azure-devops-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/MultiValueControl.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "office-ui-fabric-react"; 2 | import { Checkbox } from "office-ui-fabric-react/lib/components/Checkbox"; 3 | 4 | import { TextField } from "office-ui-fabric-react/lib/components/TextField"; 5 | import { 6 | FocusZone, 7 | FocusZoneDirection, 8 | } from "office-ui-fabric-react/lib/FocusZone"; 9 | import * as React from "react"; 10 | 11 | 12 | import { DelayedFunction } from "VSS/Utils/Core"; 13 | import { BrowserCheckUtils } from "VSS/Utils/UI"; 14 | 15 | interface IMultiValueControlProps { 16 | selected?: string[]; 17 | width?: number; 18 | readOnly?: boolean; 19 | placeholder?: string; 20 | noResultsFoundText?: string; 21 | searchingText?: string; 22 | onSelectionChanged?: (selection: string[]) => Promise; 23 | forceValue?: boolean; 24 | options: string[]; 25 | error: JSX.Element; 26 | onBlurred?: () => void; 27 | onResize?: () => void; 28 | } 29 | 30 | interface IMultiValueControlState { 31 | focused: boolean; 32 | filter: string; 33 | multiline: boolean; 34 | isToggled: boolean; 35 | } 36 | 37 | interface WrapperRef { 38 | wrapperRef: any; 39 | } 40 | 41 | export class MultiValueControl extends React.Component< 42 | IMultiValueControlProps, 43 | IMultiValueControlState, 44 | WrapperRef 45 | > { 46 | private readonly _unfocusedTimeout = BrowserCheckUtils.isSafari() ? 2000 : 1; 47 | private readonly _allowCustom: boolean = 48 | VSS.getConfiguration().witInputs.AllowCustom; 49 | private readonly _labelDisplayLength: number = VSS.getConfiguration() 50 | .witInputs.LabelDisplayLength 51 | ? VSS.getConfiguration().witInputs.LabelDisplayLength 52 | : 35; 53 | private _setUnfocused = new DelayedFunction( 54 | null, 55 | this._unfocusedTimeout, 56 | "", 57 | () => { 58 | this.setState({ focused: false, filter: "" }); 59 | } 60 | ); 61 | 62 | wrapperRef: React.RefObject; 63 | container: HTMLDivElement | null; 64 | onResize: any; 65 | selected: string[]; 66 | 67 | constructor(props, context) { 68 | super(props, context); 69 | this.state = { 70 | focused: false, 71 | filter: "", 72 | multiline: false, 73 | isToggled: false, 74 | }; 75 | this.wrapperRef = React.createRef(); 76 | this.toggleDropdown = this.toggleDropdown.bind(this); 77 | // this.handleClickOutside = this.handleClickOutside.bind(this) 78 | if (this.props.onResize) { 79 | this.onResize = this.props.onResize.bind(this); 80 | } 81 | this.container = null; 82 | } 83 | 84 | componentDidUpdate() { 85 | if (this.props.onResize) { 86 | this.props.onResize(); 87 | } 88 | } 89 | 90 | toggleDropdown = () => { 91 | this.setState((prevState) => ({ 92 | isToggled: !prevState.isToggled, 93 | focused: !prevState.focused, 94 | })); 95 | this._onTagsChanged 96 | }; 97 | 98 | _getOptions() { 99 | const options = this.props.options; 100 | const selected = (this.props.selected || []).slice(0); 101 | const filteredOpts = this._filteredOptions(); 102 | 103 | return ( 104 |
105 | 116 | 117 | {this.state.filter ? null : ( 118 | 128 | )} 129 | {filteredOpts.map((o) => ( 130 | = 0} 133 | inputProps={{ 134 | onBlur: this._onBlur, 135 | onFocus: this._onFocus, 136 | }} 137 | onChange={() => this._toggleOption(o)} 138 | label={this._wrapText(o)} 139 | title={o} 140 | /> 141 | ))} 142 | 143 |
144 | ); 145 | } 146 | 147 | private _wrapText(text: string) { 148 | return text.length > this._labelDisplayLength 149 | ? `${text.slice(0, this._labelDisplayLength)}...` 150 | : text; 151 | } 152 | private _onInputKeyDown = (e: React.KeyboardEvent) => { 153 | if (e.altKey || e.shiftKey || e.ctrlKey) { 154 | return; 155 | } 156 | 157 | if (e.keyCode === 13 /* enter */) { 158 | const filtered = this._filteredOptions(); 159 | 160 | e.preventDefault(); 161 | e.stopPropagation(); 162 | this._toggleOption(filtered[0]); 163 | this.setState({ filter: "" }); 164 | } 165 | if (e.keyCode === 37 /* left arrow */) { 166 | const input: HTMLInputElement = e.currentTarget; 167 | if ( 168 | input.selectionStart !== input.selectionEnd || 169 | input.selectionStart !== 0 170 | ) { 171 | return; 172 | } 173 | const tags = document.querySelectorAll( 174 | "#container .multi-value-control .tag-picker [data-selection-index]" 175 | ); 176 | if (tags.length === 0) { 177 | return; 178 | } 179 | const lastTag = tags.item(tags.length - 1) as HTMLDivElement; 180 | lastTag.focus(); 181 | e.preventDefault(); 182 | e.stopPropagation(); 183 | } 184 | }; 185 | 186 | private _toggleSelectAll = () => { 187 | const options = this.props.options; 188 | const selected = this.props.selected || []; 189 | console.log(selected.join(";"), options.join(";")); 190 | if (selected.join(";") === options.join(";")) { 191 | this._setSelected([]); 192 | } else { 193 | this._setSelected(options); 194 | } 195 | this._ifSafariCloseDropdown(); 196 | }; 197 | private _filteredOptions = (): string[] => { 198 | const filter = this.state.filter.toLocaleLowerCase(); 199 | const opts = this._mergeStrArrays([ 200 | this.props.options, 201 | this.props.selected || [], 202 | ]); 203 | 204 | const filtered = [ 205 | ...opts.filter((o) => o.toLocaleLowerCase().indexOf(filter) === 0), 206 | ...opts.filter((o) => o.toLocaleLowerCase().indexOf(filter) > 0), 207 | ]; 208 | 209 | const filterEmptyElement = this._allowCustom 210 | ? [this.state.filter, ...filtered] 211 | : filtered; 212 | 213 | return filterEmptyElement.filter((el) => el !== ""); 214 | }; 215 | 216 | private _onTagsChanged = (tags: any[]) => { 217 | const values = tags.map(({name}) => name); 218 | if (this.props.onSelectionChanged) { 219 | this.props.onSelectionChanged(values); 220 | } 221 | } 222 | private _onInputChange = ( 223 | event: React.FormEvent, 224 | newValue?: string 225 | ) => { 226 | let isMultiline = this.state.multiline; 227 | if (newValue != undefined) { 228 | const newMultiline = newValue.length > 50; 229 | if (newMultiline !== this.state.multiline) { 230 | isMultiline = newMultiline; 231 | } 232 | } 233 | this.setState({ filter: newValue || "", multiline: isMultiline }); 234 | }; 235 | 236 | private _onBlur = () => { 237 | this._setUnfocused.reset(); 238 | }; 239 | private _onFocus = () => { 240 | this._setUnfocused.cancel(); 241 | }; 242 | private _setSelected = async (selected: string[]): Promise => { 243 | if (!this.props.onSelectionChanged) { 244 | return; 245 | } 246 | await this.props.onSelectionChanged(selected); 247 | }; 248 | private _mergeStrArrays = (arrs: string[][]): string[] => { 249 | const seen: { [str: string]: boolean } = {}; 250 | const merged: string[] = []; 251 | for (const arr of arrs) { 252 | for (const ele of arr) { 253 | if (!seen[ele]) { 254 | seen[ele] = true; 255 | merged.push(ele); 256 | } 257 | } 258 | } 259 | return merged; 260 | }; 261 | private _toggleOption = (option: string): boolean => { 262 | const selectedMap: { [k: string]: boolean } = {}; 263 | for (const s of this.props.selected || []) { 264 | selectedMap[s] = true; 265 | } 266 | const change = 267 | option in selectedMap || this.props.options.indexOf(option) >= 0; 268 | selectedMap[option] = !selectedMap[option]; 269 | const selected = this._mergeStrArrays([ 270 | this.props.options, 271 | this.props.selected || [], 272 | [option], 273 | ]).filter((o) => selectedMap[o]); 274 | this._setSelected(selected); 275 | this._ifSafariCloseDropdown(); 276 | return change; 277 | }; 278 | private _ifSafariCloseDropdown() { 279 | if (BrowserCheckUtils.isSafari()) { 280 | this.setState({ filter: "", focused: false }); 281 | } 282 | } 283 | 284 | private deleteTags = (tag: string, data: string[]) => { 285 | const updatedTags = data.filter((t) => t !== tag); 286 | if (this.props.onSelectionChanged) { 287 | this.props.onSelectionChanged(updatedTags); 288 | } 289 | }; 290 | 291 | public render() { 292 | const data = (this.props.selected || []).map((text) => { 293 | return text.length > Number(this._labelDisplayLength) 294 | ? `${text.slice(0, Number(this._labelDisplayLength))}...` 295 | : text; 296 | }); 297 | 298 | 299 | return ( 300 |
304 | 310 |
320 | {data?.map((t, index) => { 321 | return ( 322 |
323 |
324 | {t.match(/.{1,50}/g)?.join("\n")} 325 |
326 |
this.deleteTags(t, data)} 329 | > 330 | x 331 |
332 |
333 | ); 334 | })} 335 | 336 | {!data.length ? ( 337 |
338 | 339 | No selection made 340 | 341 |
342 | ) : ( 343 | 350 | )} 351 |
352 | {this.state.focused ? this._getOptions() : null} 353 |
{this.props.error}
354 |
355 | ); 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/MultiValueEvents.tsx: -------------------------------------------------------------------------------- 1 | import { initializeIcons } from "office-ui-fabric-react/lib/Icons"; 2 | import * as React from "react"; 3 | import * as ReactDOM from "react-dom"; 4 | import { getClient } from "TFS/WorkItemTracking/RestClient"; 5 | import { WorkItemFormService } from "TFS/WorkItemTracking/Services"; 6 | 7 | import { getSuggestedValues } from "./getSuggestedValues"; 8 | import { MultiValueControl } from "./MultiValueControl"; 9 | 10 | initializeIcons(); 11 | const HELP_URL = "https://github.com/Microsoft/vsts-extension-multivalue-control#azure-devops-services"; 12 | 13 | export class MultiValueEvents { 14 | public readonly fieldName = VSS.getConfiguration().witInputs.FieldName; 15 | private readonly _container = document.getElementById("container") as HTMLElement; 16 | private _onRefreshed: () => void; 17 | /** Counter to avoid consuming own changed field events. */ 18 | private _fired: number = 0; 19 | 20 | public async refresh(selected?: string[]): Promise { 21 | let error = <>; 22 | if (!selected) { 23 | if (this._fired) { 24 | this._fired--; 25 | if (this._fired !== 0) { 26 | return; 27 | } 28 | error = await this._checkFieldType(); 29 | if (!error) { 30 | return; 31 | } 32 | } 33 | selected = await this._getSelected(); 34 | } 35 | 36 | ReactDOM.render(, this._container, () => { 45 | this._resize(); 46 | if (this._onRefreshed) { 47 | this._onRefreshed(); 48 | } 49 | }); 50 | } 51 | private _resize = () => { 52 | VSS.resize(this._container.scrollWidth || 200, this._container.scrollHeight || 40); 53 | } 54 | private async _getSelected(): Promise { 55 | const formService = await WorkItemFormService.getService(); 56 | const value = await formService.getFieldValue(this.fieldName); 57 | if (typeof value !== "string") { 58 | return []; 59 | } 60 | return value.split(";").filter((v) => !!v).map(s => s.trim()); 61 | } 62 | private _setSelected = async (values: string[]): Promise => { 63 | const formService = await WorkItemFormService.getService(); 64 | const fields = await formService.getFields(); 65 | 66 | const currentField = fields.filter((f) => f.referenceName === this.fieldName)[0]; 67 | 68 | if (!currentField) { 69 | console.warn(`Field ${this.fieldName} not found.`); 70 | return; 71 | } 72 | 73 | if (currentField.readOnly) { 74 | console.warn("Field is read only, cannot set value."); 75 | return; 76 | } 77 | this.refresh(values); 78 | this._fired++; 79 | await formService.setFieldValue(this.fieldName, values.join(";")); 80 | 81 | return new Promise((resolve) => { 82 | this._onRefreshed = resolve; 83 | }); 84 | } 85 | private async _checkFieldType(): Promise { 86 | const formService = await WorkItemFormService.getService(); 87 | const inv = await formService.getInvalidFields(); 88 | if (inv.length > 0 && inv.some((f) => f.referenceName === this.fieldName)) { 89 | const field = await getClient().getField(this.fieldName); 90 | if (field.isPicklist) { 91 | return
92 | {`Set the field ${field.name} to use suggested values rather than allowed values. `} 93 | {"See documentation"} 94 |
; 95 | } 96 | } 97 | return <>; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/getSuggestedValues.ts: -------------------------------------------------------------------------------- 1 | import { WorkItemFormService } from "TFS/WorkItemTracking/Services"; 2 | 3 | export async function getSuggestedValues(): Promise { 4 | const inputs: IDictionaryStringTo = VSS.getConfiguration().witInputs; 5 | const valuesString: string = inputs.Values; 6 | if (valuesString) { 7 | return valuesString.split(";").filter((v) => !!v).map(s => s.trim()); 8 | } 9 | // if the values input were not specified as an input, get the suggested values for the field. 10 | const service = await WorkItemFormService.getService(); 11 | const allowedValues = await service.getAllowedFieldValues(VSS.getConfiguration().witInputs.FieldName) as string[]; 12 | return allowedValues.filter((value) => value.indexOf(";") === -1); 13 | } 14 | -------------------------------------------------------------------------------- /src/multi-selection.scss: -------------------------------------------------------------------------------- 1 | @mixin border-color($varName, $defaultR, $defaultG, $defaultB) { 2 | border-color: rgb($defaultR, $defaultG, $defaultB); 3 | border-color: rgb(var($varName, $defaultR, $defaultG, $defaultB)); 4 | } 5 | @mixin background-color($varName, $defaultR, $defaultG, $defaultB) { 6 | background-color: rgb($defaultR, $defaultG, $defaultB); 7 | background-color: rgb(var($varName, $defaultR, $defaultG, $defaultB)); 8 | } 9 | @mixin color($varName, $defaultR, $defaultG, $defaultB) { 10 | color: rgb($defaultR, $defaultG, $defaultB); 11 | color: rgb(var($varName, $defaultR, $defaultG, $defaultB)); 12 | } 13 | 14 | #container { 15 | padding-bottom: 3px; 16 | padding-right: 3px; 17 | .multi-value-control { 18 | .NoSlectionBtn { 19 | @include background-color(--palette-neutral-8, 234, 234, 234); 20 | @include color(--palette-neutral-100, 0, 0, 0); 21 | 22 | border: none; 23 | border-radius: 4px; 24 | padding: 3px 5px; 25 | font-size: 14px; 26 | cursor: pointer; 27 | text-align: center; 28 | display: inline-block; 29 | } 30 | option { 31 | @include color(--palette-neutral-100, 0, 0, 0); 32 | } 33 | } 34 | .text { 35 | @include color(--palette-neutral-100, 0, 0, 0); 36 | } 37 | .customTagPicker { 38 | display: flex; 39 | align-items: center; 40 | @include background-color(--palette-neutral-8, 234, 234, 234); 41 | @include color(--palette-neutral-100, 0, 0, 0); 42 | border-radius: 12px; 43 | padding: 2px 8px; 44 | font-size: 12px; 45 | white-space: nowrap; 46 | max-width: 100%; 47 | margin: 3px; 48 | height: 25px; 49 | } 50 | .hoverEffect:hover { 51 | @include border-color(--palette-neutral-100, 0, 0, 0); 52 | border: 1px solid; 53 | } 54 | .tag-picker input { 55 | @include color(--palette-neutral-100, 0, 0, 0); 56 | } 57 | .tag-picker [role="list"] { 58 | @include border-color(--palette-neutral-8, 234, 234, 234); 59 | &:not(:hover) { 60 | border-color: transparent; 61 | } 62 | } 63 | .AddBtn { 64 | @include background-color(--palette-neutral-4, 244, 244, 244); 65 | @include color(--palette-neutral-100, 0, 0, 0); 66 | font-size: 11px; 67 | 68 | height: 24px; 69 | 70 | margin: 3px; 71 | } 72 | .tag-picker .ms-TagItem { 73 | @include background-color(--palette-neutral-4, 244, 244, 244); 74 | &:hover { 75 | @include background-color(--palette-neutral-8, 234, 234, 234); 76 | } 77 | &.is-selected { 78 | @include background-color(--palette-neutral-20, 200, 200, 200); 79 | } 80 | .ms-TagItem-close { 81 | @include color(--palette-neutral-60, 102, 102, 102); 82 | } 83 | } 84 | &.focused .tag-picker input { 85 | display: none; 86 | } 87 | &.focused .tag-picker .ms-BasePicker-text:hover { 88 | @include border-color(--palette-neutral-8, 234, 234, 234); 89 | } 90 | .options { 91 | button { 92 | width: 100%; 93 | &:hover { 94 | @include background-color(--palette-primary-tint-40, 239, 246, 252); 95 | } 96 | .ms-Checkbox-text { 97 | @include color(--palette-neutral-100, 0, 0, 0); 98 | } 99 | } 100 | .checkboxes { 101 | padding: 3px; 102 | } 103 | input { 104 | @include color(--palette-neutral-100, 0, 0, 0); 105 | @include background-color(--palette-neutral-0, 255, 255, 255); 106 | } 107 | } 108 | .error { 109 | @include color(--palette-accent1, 218, 10, 0); 110 | } 111 | } 112 | 113 | .closeIconcustom { 114 | @include color(--palette-neutral-100, 0, 0, 0); 115 | cursor: pointer; 116 | } 117 | 118 | .ms-Fabric--isFocusVisible .ms-TagItem:focus:after { 119 | @include border-color(--palette-neutral-100, 0, 0, 0); 120 | } 121 | 122 | .customTagPicker .tag-text { 123 | font-size: 11px; 124 | line-height: 1.2; 125 | word-break: break-word; 126 | margin-right: 10px; /* Adds space between text and close button */ 127 | } 128 | 129 | .options { 130 | width: 100%; 131 | } 132 | 133 | .header { 134 | display: flex; 135 | flex-direction: row; 136 | justify-content: space-between; 137 | width: 100%; 138 | } 139 | -------------------------------------------------------------------------------- /src/multivalue.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | Multi Values Control 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 36 |
37 | 38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /src/multivalue.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import * as WitExtensionContracts from "TFS/WorkItemTracking/ExtensionContracts"; 4 | import { WorkItemFormService } from "TFS/WorkItemTracking/Services"; 5 | import { MultiValueEvents } from "./MultiValueEvents"; 6 | 7 | // save on ctr + s 8 | $(window).bind("keydown", (event: JQueryEventObject) => { 9 | if (event.ctrlKey || event.metaKey) { 10 | if (String.fromCharCode(event.which) === "S") { 11 | event.preventDefault(); 12 | WorkItemFormService.getService().then((service) => service.beginSaveWorkItem($.noop, $.noop)); 13 | } 14 | } 15 | }); 16 | 17 | const provider = () => { 18 | let control: MultiValueEvents; 19 | 20 | const ensureControl = () => { 21 | if (!control) { 22 | control = new MultiValueEvents(); 23 | } 24 | control.refresh(); 25 | }; 26 | 27 | return { 28 | onLoaded: (args: WitExtensionContracts.IWorkItemLoadedArgs) => { 29 | ensureControl(); 30 | }, 31 | onFieldChanged: (args: WitExtensionContracts.IWorkItemFieldChangedArgs) => { 32 | if (control && args.changedFields[control.fieldName] !== undefined && 33 | args.changedFields[control.fieldName] !== null 34 | ) { 35 | control.refresh(); 36 | } 37 | }, 38 | }; 39 | }; 40 | 41 | VSS.register(VSS.getContribution().id, provider); 42 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | // themeManager.ts 2 | import * as SDK from 'azure-devops-extension-sdk'; 3 | 4 | export const initializeTheme = () => { 5 | SDK.init().then(() => { 6 | // Assuming there's a global VSS object available 7 | VSS.require(["TFS/Dashboards/WidgetHelpers"], (WidgetHelpers) => { 8 | WidgetHelpers.ThemeService.getService().then((themeService) => { 9 | themeService.getTheme().then((theme) => { 10 | // Apply your theme logic here 11 | // This is a hypothetical example; actual implementation may vary 12 | 13 | }); 14 | }); 15 | }); 16 | }); 17 | }; -------------------------------------------------------------------------------- /src/themeManager.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | 3 | import { createTheme, loadTheme } from 'office-ui-fabric-react'; 4 | 5 | // Define your dark and light themes here 6 | export const darkTheme = createTheme({ 7 | palette: { 8 | themePrimary: '#1a1a1a', 9 | neutralPrimary: '#f4f4f4', 10 | neutralLighter: '#262626', 11 | neutralLight: '#333333', 12 | neutralQuaternary: '#444444', 13 | white: '#121212', 14 | neutralTertiaryAlt: '#e1e1e1' 15 | }, 16 | // Add more theme settings if needed 17 | }); 18 | 19 | export const lightTheme = createTheme({ 20 | palette: { 21 | themePrimary: '#0078d4', 22 | neutralPrimary: '#333333', 23 | neutralLighter: '#f4f4f4', 24 | neutralLight: '#eaeaea', 25 | neutralQuaternary: '#dcdcdc', 26 | white: '#ffffff', 27 | neutralTertiaryAlt: '#e1e1e1', 28 | }, 29 | }); 30 | 31 | // Function to apply the theme 32 | export const applyTheme = (theme: string) => { 33 | if (theme === 'dark') { 34 | loadTheme(darkTheme); 35 | } else { 36 | loadTheme(lightTheme); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "amd", 4 | "moduleResolution": "node", 5 | "sourceMap": true, 6 | "target": "es5", 7 | "strictNullChecks": true, 8 | "noUnusedLocals": true, 9 | "jsx": "react", 10 | "types": [ 11 | "knockout", 12 | "requirejs", 13 | "jquery", 14 | "react", 15 | "react-dom" 16 | ], 17 | "lib": [ 18 | "es2015.promise", 19 | "dom", 20 | "es5" 21 | ] 22 | } 23 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "member-ordering": false, 9 | "object-literal-sort-keys": false, 10 | "variable-name": false, 11 | "max-line-length": [true, 180] 12 | }, 13 | "rulesDirectory": [] 14 | } -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multivalue-control-extension", 3 | "globalDependencies": { 4 | "tfs": "npm:vss-web-extension-sdk/typings/tfs.d.ts", 5 | "vss": "npm:vss-web-extension-sdk/typings/vss.d.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var webpack = require("webpack"); 3 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 4 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | 6 | module.exports = { 7 | target: "web", 8 | entry: { 9 | multivalue: "./src/multivalue.ts" 10 | }, 11 | output: { 12 | filename: "src/[name].js", 13 | libraryTarget: "amd" 14 | }, 15 | externals: [ 16 | { 17 | }, 18 | /^VSS\/.*/, /^TFS\/.*/, /^q$/ 19 | ], 20 | resolve: { 21 | extensions: [".webpack.js", ".web.js", ".ts", ".tsx", ".js"] 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.tsx?$/, 27 | use: "ts-loader" 28 | }, 29 | { 30 | test: /\.s?css$/, 31 | use: ["style-loader", "css-loader", "sass-loader"] 32 | } 33 | ] 34 | }, 35 | devtool: 'inline-source-map', 36 | mode: "development", 37 | plugins: [ 38 | new BundleAnalyzerPlugin({ 39 | openAnalyzer: false, 40 | reportFilename: "bundle-analysis.html", 41 | analyzerMode: "static" 42 | }), 43 | new CopyWebpackPlugin({ 44 | patterns: [ 45 | { from: "./node_modules/es6-promise/dist/es6-promise.min.js", to: "libs/es6-promise.min.js" }, 46 | { from: "./node_modules/vss-web-extension-sdk/lib/VSS.SDK.min.js", to: "libs/VSS.SDK.min.js" }, 47 | { from: "./src/multivalue.html", to: "./" }, 48 | { from: "./img", to: "img" }, 49 | { from: "./readme.md", to: "readme.md" } 50 | ]}) 51 | ] 52 | } -------------------------------------------------------------------------------- /xmldetails.md: -------------------------------------------------------------------------------- 1 | In TFS, the layout for a work item type is defined via XML. Therefore, you will have to add the Multivalue control to your layout. Here's the series of steps that tell you how to do it. 2 | 3 | Learn more about WebLayout XML [here](https://www.visualstudio.com/docs/work/reference/weblayout-xml-elements). 4 | 5 | # How to get started 6 | 1. Open the `Developer Command Prompt`. Export the XML file to your desktop with the following command. 7 | ``` 8 | witadmin exportwitd /collection:CollectionURL /p:Project /n:TypeName /f:FileName 9 | ``` 10 | 11 | 2. This will create a file in the directory that you specified. Open this file and search for "Work Item Extensions". 12 | 13 | ```xml 14 | 44 | ``` 45 | 46 | 3. Add an Extension tag to make the control available to the work item form. 47 | 48 | ```xml 49 | 55 | 56 | 57 | 58 | 59 | ``` 60 | 61 | You can find your extension ID within the commented blob for "Work Item Extensions": 62 | 63 | ```XML 64 |