├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── dependency-review.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── azure-devops-extension.json ├── azure-pipelines.yml ├── configs ├── dev.json └── release.json ├── images ├── icon-default.png ├── icon-large.png ├── picklist-child.png ├── picklist-demo.gif └── settings-hub-1.png ├── marketplace ├── details.md └── license.md ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── common │ ├── cascading.service.ts │ ├── manifest.service.ts │ ├── storage.service.ts │ └── types.ts ├── confighub │ ├── app.tsx │ ├── components │ │ ├── FieldsTable.tsx │ │ ├── Header.tsx │ │ └── Status.tsx │ ├── hooks │ │ ├── configstorage.ts │ │ ├── projectfieldlist.ts │ │ └── toast.ts │ ├── index.tsx │ └── views │ │ └── ConfigView.tsx └── observer │ └── index.ts ├── static └── confighub.html ├── tsconfig.json ├── webpack.config.js └── webpack.dev.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = crlf 6 | insert_final_newline = true 7 | 8 | # Matches multiple files with brace expansion notation 9 | # Set default charset 10 | [*.{js,ts,tsx,json}] 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaFeatures: { 5 | jsx: true, 6 | }, 7 | }, 8 | settings: { 9 | react: { 10 | version: 'detect', 11 | }, 12 | }, 13 | rules: { 14 | // Indentation rule 15 | indent: 0, 16 | '@typescript-eslint/indent': ['error', 2], 17 | 18 | // Force single quotes 19 | quotes: ['error', 'single'], 20 | 21 | // Allow logs 22 | 'no-console': 1, 23 | 24 | // Force no ununsed variables 25 | 'no-unused-vars': 2, 26 | 27 | // Allow object type 28 | '@typescript-eslint/ban-types': 0, 29 | 30 | // Force windows linebreak styles 31 | 'linebreak-style': [2, 'unix'], 32 | 33 | // Force semicolons 34 | semi: [2, 'always'], 35 | 36 | // Turn off explicit return type 37 | '@typescript-eslint/explicit-function-return-type': 0, 38 | 39 | // Turn off interface name prefixing 40 | '@typescript-eslint/interface-name-prefix': 0, 41 | 42 | // React rules 43 | 'react/display-name': 0, 44 | 'react/forbid-prop-types': 0, 45 | 'react/jsx-closing-bracket-location': 1, 46 | 'react/jsx-curly-spacing': 1, 47 | 'react/jsx-handler-names': 1, 48 | 'react/jsx-indent': ['warn', 2], 49 | 'react/jsx-key': 1, 50 | 'react/jsx-max-props-per-line': 0, 51 | 'react/jsx-no-bind': 0, 52 | 'react/jsx-no-duplicate-props': 1, 53 | 'react/jsx-no-literals': 0, 54 | 'react/jsx-no-undef': 1, 55 | 'react/jsx-pascal-case': 1, 56 | 'react/jsx-sort-prop-types': 0, 57 | 'react/jsx-sort-props': 0, 58 | 'react/jsx-uses-react': 1, 59 | 'react/jsx-uses-vars': 1, 60 | 'react/no-danger': 1, 61 | 'react/no-deprecated': 1, 62 | 'react/no-did-mount-set-state': 1, 63 | 'react/no-did-update-set-state': 1, 64 | 'react/no-direct-mutation-state': 1, 65 | 'react/no-is-mounted': 1, 66 | 'react/no-multi-comp': 0, 67 | 'react/no-set-state': 1, 68 | 'react/no-string-refs': 0, 69 | 'react/no-unknown-property': 1, 70 | 'react/prefer-es6-class': 1, 71 | 'react/react-in-jsx-scope': 1, 72 | 'react/self-closing-comp': 1, 73 | 'react/sort-comp': 1, 74 | 75 | // React Hooks rules 76 | 'react-hooks/rules-of-hooks': 'error', 77 | 'react-hooks/exhaustive-deps': 'warn', 78 | }, 79 | env: { 80 | browser: true, 81 | es6: true, 82 | node: true, 83 | }, 84 | plugins: ['react', 'react-hooks', '@typescript-eslint'], 85 | extends: ['plugin:react/recommended', 'plugin:@typescript-eslint/recommended'], 86 | }; 87 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v2 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Bower dependency directory 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | .env.test 61 | 62 | # Ignore Visual Studio Code folder 63 | .vscode/ 64 | 65 | # Ignore VSTS extension builds 66 | *.vsix 67 | 68 | # Ignore lib necessary only for debugging 69 | lib/ 70 | 71 | # Ignore SSL private keys and certificates 72 | *.crt 73 | *.key 74 | 75 | # Ignore openssl config for generating self-signed SSL certificate for debug 76 | openssl.conf 77 | 78 | # Ignore webpack build 79 | dist/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cascading Picklists Extension 2 | 3 | Have you ever wanted to have a picklist show only subset of values depending on the value of another field? For example maybe you two fields to track a release, major and minor release. The minor release values are tied to the major release values. In the example below, if the major release is "Blue" then only show the Blue minor releases. And when the major release of "Red" is selected, then only show the Red minor releases. 4 | 5 | # Documentation 6 | 7 | For detailed instructions on using the Cascading Picklists Extension 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.cascading-picklists-extension). This resource provides step-by-step information to help you effectively utilize the Cascading Picklists Extension features within your Azure DevOps environment. 8 | 9 | > Note that the extension is only supported on Azure DevOps Service. Is it is currently not supported on-prem yet due to a missing API. 10 | 11 | # Support 12 | 13 | ## How to file issues and get help 14 | 15 | This project uses [GitHub Issues](https://github.com/microsoft/azure-devops-extension-cascading-picklist/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. 16 | 17 | ## Microsoft Support Policy 18 | Support for this project is limited to the resources listed above. 19 | 20 | # Contributing 21 | 22 | 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 contributions help enhance the functionality and usability of the extension for the entire community. 23 | 24 | This extension uses the `ms.vss-work-web.work-item-form` contribution point that enables you to build a cascading picklist on the work item form. See https://learn.microsoft.com/en-us/azure/devops/extend/develop/add-workitem-extension?view=azure-devops for more information about how work item form extensibility works. 25 | 26 | **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 27 | 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. 28 | 29 | Check out https://learn.microsoft.com/en-us/azure/devops/extend/get-started to learn how to develop Azure DevOps extensions 30 | 31 | ### Developing and Testing 32 | 33 | ```bash 34 | # Install node dependencies 35 | npm install 36 | 37 | # Compile the source code 38 | npm run start 39 | 40 | # Build the extension 41 | npm run build-dev 42 | ``` 43 | ## About Microsoft DevLabs 44 | 45 | Microsoft DevLabs is an outlet for experiments from Microsoft, experiments that represent some of the latest ideas around developer tools. Solutions in this 46 | 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 47 | commitments made as to their longevity. -------------------------------------------------------------------------------- /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": "cascading-picklists-extension", 4 | "publisher": "ms-devlabs", 5 | "version": "0.1.16", 6 | "name": "Cascading Lists", 7 | "description": "Extension allows to define cascading behavior for picklists in work item form.", 8 | "categories": [ 9 | "Azure Boards" 10 | ], 11 | "tags": [ 12 | "Cascading Picklists" 13 | ], 14 | "icons": { 15 | "default": "images/icon-default.png", 16 | "large": "images/icon-large.png" 17 | }, 18 | "content": { 19 | "details": { 20 | "path": "marketplace/details.md" 21 | }, 22 | "license": { 23 | "path": "marketplace/license.md" 24 | } 25 | }, 26 | "targets": [ 27 | { 28 | "id": "Microsoft.VisualStudio.Services.Cloud" 29 | } 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/microsoft/azure-devops-extension-cascading-picklist" 34 | }, 35 | "scopes": [ 36 | "vso.work", 37 | "vso.work_write" 38 | ], 39 | "contributions": [ 40 | { 41 | "id": "cascading-lists-wit-observer", 42 | "type": "ms.vss-work-web.work-item-notifications", 43 | "description": "Observer modifies behavior of a work item form to support cascading picklists.", 44 | "targets": [ 45 | "ms.vss-work-web.work-item-form" 46 | ], 47 | "properties": { 48 | "name": "Cascading Lists Observer", 49 | "uri": "/dist/observer.html" 50 | } 51 | }, 52 | { 53 | "id": "cascading-lists-config-hub", 54 | "type": "ms.vss-web.hub", 55 | "description": "Configuration hub for a cascading lists", 56 | "targets": [ 57 | "ms.vss-web.project-admin-hub-group" 58 | ], 59 | "properties": { 60 | "name": "Cascading Lists", 61 | "order": 1, 62 | "uri": "/dist/confighub.html" 63 | } 64 | } 65 | ], 66 | "files": [ 67 | { 68 | "path": "images", 69 | "addressable": true 70 | }, 71 | { 72 | "path": "marketplace", 73 | "addressable": true 74 | }, 75 | { 76 | "path": "dist", 77 | "addressable": true 78 | } 79 | ] 80 | } -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | - name: major 3 | value: '0' 4 | - name: minor 5 | value: '1' 6 | - name: extensionName 7 | value: 'cascading-picklists-extension' 8 | - name: marketplaceServiceConnection 9 | value: 'marketplaceServiceConnection' 10 | - name: publisherId 11 | value: 'ms-devlabs' 12 | - name: publicExtensionName 13 | value: "Cascading Lists" 14 | 15 | name: $(major).$(minor)$(rev:.r) 16 | 17 | trigger: 18 | branches: 19 | include: 20 | - master 21 | 22 | pr: none 23 | 24 | resources: 25 | repositories: 26 | - repository: pipeline-templates 27 | type: git 28 | name: DevLabs Extensions/pipeline-templates 29 | ref: main 30 | 31 | stages: 32 | - stage: 'Build' 33 | jobs: 34 | - job: 'BuildPack' 35 | displayName: "Build and package" 36 | pool: 37 | vmImage: ubuntu-latest 38 | steps: 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 | 48 | - stage: 'DeployDev' 49 | displayName: 'Deploy to dev' 50 | dependsOn: Build 51 | condition: succeeded() 52 | jobs: 53 | - template: deploy.yml@pipeline-templates 54 | parameters: 55 | environment: 'dev' 56 | extensionName: $(extensionName) 57 | marketplaceConnectedServiceName: $(marketplaceServiceConnection) 58 | publisherId: $(publisherId) 59 | publicExtensionName: $(publicExtensionName) 60 | updateTaskVersion: true 61 | 62 | - stage: 'DeployTest' 63 | displayName: 'Deploy to Test' 64 | dependsOn: DeployDev 65 | condition: succeeded() 66 | jobs: 67 | - template: deploy.yml@pipeline-templates 68 | parameters: 69 | environment: 'test' 70 | extensionName: $(extensionName) 71 | marketplaceConnectedServiceName: $(marketplaceServiceConnection) 72 | publisherId: $(publisherId) 73 | publicExtensionName: $(publicExtensionName) 74 | updateTaskVersion: true 75 | 76 | - stage: 'DeployRelease' 77 | displayName: 'Deploy Release' 78 | dependsOn: DeployTest 79 | condition: succeeded() 80 | jobs: 81 | - template: deploy.yml@pipeline-templates 82 | parameters: 83 | environment: 'public' 84 | extensionName: $(extensionName) 85 | marketplaceConnectedServiceName: $(marketplaceServiceConnection) 86 | publisherId: $(publisherId) 87 | publicExtensionName: $(publicExtensionName) 88 | extensionVisibility: 'public' 89 | updateTaskVersion: true 90 | -------------------------------------------------------------------------------- /configs/dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "cascading-picklists-extension-dev", 3 | "name": "Cascading Lists (dev)", 4 | "publisher": "ms-devlabs", 5 | "public": false, 6 | "baseUri": "https://localhost:44300" 7 | } -------------------------------------------------------------------------------- /configs/release.json: -------------------------------------------------------------------------------- 1 | { 2 | "galleryFlags": [ 3 | "Public" 4 | ], 5 | "public": true 6 | } -------------------------------------------------------------------------------- /images/icon-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-devops-extension-cascading-picklist/96eb85035c6ad4b10b415fafa7a4582f7e20d693/images/icon-default.png -------------------------------------------------------------------------------- /images/icon-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-devops-extension-cascading-picklist/96eb85035c6ad4b10b415fafa7a4582f7e20d693/images/icon-large.png -------------------------------------------------------------------------------- /images/picklist-child.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-devops-extension-cascading-picklist/96eb85035c6ad4b10b415fafa7a4582f7e20d693/images/picklist-child.png -------------------------------------------------------------------------------- /images/picklist-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-devops-extension-cascading-picklist/96eb85035c6ad4b10b415fafa7a4582f7e20d693/images/picklist-demo.gif -------------------------------------------------------------------------------- /images/settings-hub-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-devops-extension-cascading-picklist/96eb85035c6ad4b10b415fafa7a4582f7e20d693/images/settings-hub-1.png -------------------------------------------------------------------------------- /marketplace/details.md: -------------------------------------------------------------------------------- 1 | # Cascading Picklists 2 | 3 | Have you ever wanted to have a picklist show only subset of values depending on the value of another field? For example maybe you two fields to track a release, major and minor release. The minor release values are tied to the major release values. In the example below, if the major release is "Blue" then only show the Blue minor releases. And when the major release of "Red" is selected, then only show the Red minor releases. 4 | 5 | # Documentation 6 | 7 | ## Cascading Picklists 8 | 9 | Cascading picklists are made up of two seperate fields. The parent field and a child field. The parent picklist will contain a list of values, that when a value is selected, will display the values in the child list 10 | 11 | **Release Blue** 12 | 13 | - Blue.1 14 | - Blue.2 15 | - Blue.3 16 | 17 | **Release Red** 18 | 19 | - Red.A 20 | - Red.B 21 | - Red.C 22 | 23 | ## How does it work? 24 | 25 | 1. Create the custom fields for both the parent (major release) and child (minor release) picklists 26 | 2. Add all the possible values for the minor release. This should include values for both Blue and Red releases 27 | 28 | ![minor release picklist](images/picklist-child.png 'Configure Picklist') 29 | 30 | 3. Once both picklists have been created and configured, you can configure what child picklist values will be displayed. You do this by going to the "Cascading Lists" Hub in project settings. From here, configure the value for the parent picklist, so that when selected, the child values will be displayed. 31 | 32 | ![image](images/settings-hub-1.png) 33 | 34 | From here you can configure the JSON rules that drive how the cascading picklist would work. Below is the sample for Major and Minor releases. 35 | 36 | ```json 37 | { 38 | "version": "1.0", 39 | "cascades": { 40 | "Custom.MajorRelease": { 41 | "Release Blue": { 42 | "Custom.MinorRelease": [ 43 | "Blue.1", 44 | "Blue.2", 45 | "Blue.3" 46 | ] 47 | }, 48 | "Release Red": { 49 | "Custom.MinorRelease": [ 50 | "Red.A", 51 | "Red.B", 52 | "Red.C" 53 | ] 54 | } 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | 4. Go create a new Feature work item to see it in action. Select "Release Blue" and notice how only the blue values are displayed in the Minor Release field. Select "Release Red" and you will only see the Red minor release items. 61 | 62 | ![picklist demo](images/picklist-demo.gif) 63 | 64 | ## Supported Features 65 | 66 | - Setup cascading picklist between two fields 67 | 68 | ## Known issues 69 | 70 | - Work item forms with many extensions installed can delay the loading of the cascading picklist extension. Therefore the child/parent relationship may not be visible for several seconds. If that happens, we recommend that you remove any non-essential extensions from the form. 71 | 72 | ## Tips 73 | 74 | 1. You must know the refname of the custom picklist fields. You can use [List Fields REST API](https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/fields/list?view=azure-devops-rest-5.0) if you need help finding the value. 75 | 76 | 2. The values setup in the picklist and the values in the configuration must be an exact match. There is no validation to check or correct spelling mistakes in the values. 77 | 78 | # Support 79 | 80 | ## How to file issues and get help 81 | 82 | This project uses [GitHub Issues](https://github.com/microsoft/azure-devops-extension-cascading-picklist/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. 83 | 84 | ## Microsoft Support Policy 85 | 86 | Support for this project is limited to the resources listed above. -------------------------------------------------------------------------------- /marketplace/license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cascading-picklists-extenstion", 3 | "version": "1.0.0", 4 | "author": "Anton Kovalyov", 5 | "license": "", 6 | "description": "Extenstion that allows to customize cascading picklists for Azure DevOps.", 7 | "private": true, 8 | "scripts": { 9 | "start": "webpack-dev-server --hot --progress --static ./ --port 44300 --https", 10 | "build-dev": "webpack --progress --config webpack.dev.config.js", 11 | "package-dev": "tfx extension create --manifest-globs azure-devops-extension.json --overrides-file configs/dev.json", 12 | "package-release": "tfx extension create --manifest-globs azure-devops-extension.json --overrides-file configs/release.json", 13 | "build:release": "webpack --progress", 14 | "postbuild": "npm run package", 15 | "package": "tfx extension create --manifest-globs azure-devops-extension.json", 16 | "gallery-publish": "tfx extension publish --rev-version", 17 | "clean": "rimraf ./dist && rimraf ./*.vsix", 18 | "lint": "eslint ." 19 | }, 20 | "dependencies": { 21 | "azure-devops-extension-api": "^1.152.3", 22 | "azure-devops-extension-sdk": "^2.0.10", 23 | "azure-devops-ui": "^1.154.1", 24 | "lodash": "^4.17.21", 25 | "react": "^16.8.6", 26 | "react-dom": "^16.8.6", 27 | "react-monaco-editor": "^0.36.0", 28 | "styled-components": "^4.3.1" 29 | }, 30 | "devDependencies": { 31 | "@types/lodash": "^4.14.135", 32 | "@types/react": "^16.8.20", 33 | "@types/react-dom": "^16.8.4", 34 | "@types/styled-components": "^4.1.16", 35 | "@typescript-eslint/eslint-plugin": "^1.10.2", 36 | "@typescript-eslint/parser": "^1.10.2", 37 | "css-loader": "^6.7.3", 38 | "eslint": "^5.16.0", 39 | "eslint-config-prettier": "^5.1.0", 40 | "eslint-plugin-prettier": "^3.1.0", 41 | "eslint-plugin-react": "^7.13.0", 42 | "eslint-plugin-react-hooks": "^1.6.0", 43 | "file-loader": "^6.2.0", 44 | "html-webpack-plugin": "^5.5.0", 45 | "lodash-webpack-plugin": "^0.11.6", 46 | "monaco-editor-webpack-plugin": "^7.0.1", 47 | "prettier": "^1.18.2", 48 | "source-map-loader": "^4.0.1", 49 | "style-loader": "^3.3.1", 50 | "ts-loader": "^9.4.2", 51 | "typescript": "^4.9.4", 52 | "typescript-styled-plugin": "^0.14.0", 53 | "webpack": "^5.72.1", 54 | "webpack-cli": "^4.9.2", 55 | "webpack-dev-server": "^4.11.1" 56 | } 57 | } -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | printWidth: 100, 4 | tabWidth: 2, 5 | semi: true, 6 | singleQuote: true, 7 | jsxSingleQuote: true, 8 | }; 9 | -------------------------------------------------------------------------------- /src/common/cascading.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommonServiceIds, 3 | getClient, 4 | IProjectPageService, 5 | } from 'azure-devops-extension-api/Common'; 6 | import { WorkItemField } from 'azure-devops-extension-api/WorkItemTracking/WorkItemTracking'; 7 | import { WorkItemTrackingRestClient } from 'azure-devops-extension-api/WorkItemTracking/WorkItemTrackingClient'; 8 | import { IWorkItemFormService } from 'azure-devops-extension-api/WorkItemTracking/WorkItemTrackingServices'; 9 | import * as SDK from 'azure-devops-extension-sdk'; 10 | import flatten from 'lodash/flatten'; 11 | import intersection from 'lodash/intersection'; 12 | import uniq from 'lodash/uniq'; 13 | import { 14 | CascadeConfiguration, 15 | CascadeMap, 16 | FieldOptions, 17 | FieldOptionsFlags, 18 | ICascade, 19 | } from './types'; 20 | 21 | type InvalidField = string; 22 | 23 | class CascadingFieldsService { 24 | private workItemService: IWorkItemFormService; 25 | private cascadeMap: CascadeMap; 26 | 27 | public constructor( 28 | workItemService: IWorkItemFormService, 29 | cascadeConfiguration: CascadeConfiguration 30 | ) { 31 | this.workItemService = workItemService; 32 | this.cascadeMap = this.createCascadingMap(cascadeConfiguration); 33 | } 34 | 35 | private createCascadingMap(cascadeConfiguration: CascadeConfiguration): CascadeMap { 36 | const cascadeMap: CascadeMap = {}; 37 | if (typeof cascadeConfiguration === 'undefined') { 38 | return cascadeMap; 39 | } 40 | 41 | Object.entries(cascadeConfiguration).map(([fieldName, fieldValues]) => { 42 | let alters: string[] = []; 43 | Object.values(fieldValues).map(cascadeDefinitions => { 44 | Object.keys(cascadeDefinitions).map(field => alters.push(field)); 45 | }); 46 | 47 | alters = uniq(alters); 48 | 49 | const cascade: ICascade = { 50 | alters, 51 | cascades: fieldValues, 52 | }; 53 | 54 | cascadeMap[fieldName] = cascade; 55 | }); 56 | return cascadeMap; 57 | } 58 | 59 | private getAffectedFields(fieldReferenceName: string, fieldValue: string): string[] { 60 | if (!this.cascadeMap[fieldReferenceName].cascades.hasOwnProperty(fieldValue)) { 61 | return []; 62 | } 63 | return Object.keys(this.cascadeMap[fieldReferenceName].cascades[fieldValue]); 64 | } 65 | 66 | private async validateFilterOrClean(fieldReferenceName: string): Promise { 67 | const allowedValues: string[] = await (this 68 | .workItemService as any).getFilteredAllowedFieldValues(fieldReferenceName); 69 | const fieldValue = (await this.workItemService.getFieldValue(fieldReferenceName)) as string; 70 | if (!allowedValues.includes(fieldValue)) { 71 | return this.workItemService.setFieldValue(fieldReferenceName, ''); 72 | } 73 | } 74 | 75 | public async resetAllCascades(): Promise { 76 | const fields = flatten(Object.values(this.cascadeMap).map(value => value.alters)); 77 | const fieldsToReset = new Set(fields); 78 | return Promise.all( 79 | Array.from(fieldsToReset).map(async fieldName => { 80 | const values = await this.workItemService.getAllowedFieldValues(fieldName); 81 | await (this.workItemService as any).filterAllowedFieldValues(fieldName, values); 82 | }) 83 | ); 84 | } 85 | 86 | private async prepareCascadeOptions(affectedFields: string[]): Promise { 87 | const fieldValues: FieldOptions = {}; 88 | 89 | await Promise.all( 90 | flatten( 91 | affectedFields.map(field => { 92 | return Object.entries(this.cascadeMap).map(async ([alterField, cascade]) => { 93 | if (cascade.alters.includes(field)) { 94 | const fieldValue = (await this.workItemService.getFieldValue(alterField)) as string; 95 | let cascadeOptions: string[]; 96 | if ( 97 | typeof cascade.cascades[fieldValue][field] === 'string' && 98 | cascade.cascades[fieldValue][field] === FieldOptionsFlags.All 99 | ) { 100 | cascadeOptions = (await this.workItemService.getAllowedFieldValues(field)).map( 101 | value => value.toString() 102 | ); 103 | } else { 104 | cascadeOptions = cascade.cascades[fieldValue][field] as string[]; 105 | } 106 | if (fieldValues.hasOwnProperty(field)) { 107 | fieldValues[field] = intersection(fieldValues[field], cascadeOptions); 108 | } else { 109 | fieldValues[field] = cascadeOptions; 110 | } 111 | } 112 | }); 113 | }) 114 | ) 115 | ); 116 | return fieldValues; 117 | } 118 | 119 | public async cascadeAll(): Promise { 120 | return Promise.all( 121 | Object.keys(this.cascadeMap).map(async field => this.performCascading(field)) 122 | ); 123 | } 124 | 125 | public async performCascading(changedFieldReferenceName: string): Promise { 126 | const changedFieldValue = (await this.workItemService.getFieldValue( 127 | changedFieldReferenceName 128 | )) as string; 129 | if (!this.cascadeMap.hasOwnProperty(changedFieldReferenceName)) { 130 | return; 131 | } 132 | 133 | const affectedFields = this.getAffectedFields(changedFieldReferenceName, changedFieldValue); 134 | const fieldValues = await this.prepareCascadeOptions(affectedFields); 135 | 136 | Object.entries(fieldValues).map(async ([fieldName, fieldValues]) => { 137 | await (this.workItemService as any).filterAllowedFieldValues(fieldName, fieldValues); 138 | await this.validateFilterOrClean(fieldName); 139 | }); 140 | } 141 | } 142 | 143 | interface ICascadeValidatorError { 144 | description: string; 145 | } 146 | 147 | class CascadeValidationService { 148 | private cachedFields: WorkItemField[]; 149 | 150 | public async validateCascades(cascades: CascadeConfiguration): Promise { 151 | const projectInfoService = await SDK.getService( 152 | CommonServiceIds.ProjectPageService 153 | ); 154 | const project = await projectInfoService.getProject(); 155 | 156 | if (this.cachedFields == null) { 157 | const witRestClient = await getClient(WorkItemTrackingRestClient); 158 | const fields = await witRestClient.getFields(project.id); 159 | this.cachedFields = fields; 160 | } 161 | const fieldList = this.cachedFields.map(field => field.referenceName); 162 | 163 | // Check fields correctness for config root 164 | let invalidFieldsTotal = Object.keys(cascades).filter(field => !fieldList.includes(field)); 165 | 166 | // Check fields on the lower level of config 167 | Object.values(cascades).map(fieldValues => { 168 | Object.values(fieldValues).map(innerFields => { 169 | const invalidFields = Object.keys(innerFields).filter(field => !fieldList.includes(field)); 170 | invalidFieldsTotal = [...invalidFieldsTotal, ...invalidFields]; 171 | }); 172 | }); 173 | 174 | if (invalidFieldsTotal.length > 0) { 175 | return invalidFieldsTotal; 176 | } 177 | 178 | return null; 179 | } 180 | } 181 | 182 | export { CascadingFieldsService, CascadeValidationService, ICascadeValidatorError }; 183 | -------------------------------------------------------------------------------- /src/common/manifest.service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationStorage, ConfigurationType } from './storage.service'; 2 | import { IManifest } from './types'; 3 | import { CascadeValidationService } from './cascading.service'; 4 | 5 | type Validator = (manifest: IManifest) => Promise; 6 | 7 | const ManifestMetadata = { 8 | availableVersions: [1], 9 | }; 10 | 11 | enum ValidationErrorCode { 12 | SyntaxError, 13 | MissingRequiredProperty, 14 | InvalidVersion, 15 | InvalidCascadeType, 16 | InvalidCascadeConfiguration, 17 | } 18 | 19 | interface IManifestValidationError { 20 | code: ValidationErrorCode; 21 | description: string; 22 | } 23 | 24 | class ManifestService { 25 | private configurationStorage: ConfigurationStorage; 26 | 27 | public static defaultManifest: IManifest = Object.freeze({ 28 | version: '1', 29 | cascades: {}, 30 | }); 31 | 32 | public constructor(projectId: string) { 33 | this.configurationStorage = new ConfigurationStorage(ConfigurationType.Manifest, projectId); 34 | } 35 | 36 | public async getManifest(): Promise { 37 | return this.configurationStorage.getConfiguration(); 38 | } 39 | 40 | public async updateManifest(manifest: IManifest): Promise { 41 | return this.configurationStorage.setConfiguration(manifest); 42 | } 43 | } 44 | 45 | class ManifestValidationService { 46 | private validators: Validator[] = [this.checkVersion, this.checkCascadesType, this.checkCascades]; 47 | private requiredProperties = ['version', 'cascades']; 48 | 49 | private cascadeValidator: CascadeValidationService; 50 | 51 | public constructor() { 52 | this.cascadeValidator = new CascadeValidationService(); 53 | } 54 | 55 | public async validate(manifest: Object): Promise { 56 | const errors: IManifestValidationError[] = []; 57 | 58 | const error = await this.checkRequiredProperties(manifest, this.requiredProperties); 59 | if (error) { 60 | return [error]; 61 | } 62 | 63 | for (let validator of this.validators) { 64 | const error = await validator.call(this, manifest); 65 | error ? errors.push(error) : null; 66 | } 67 | 68 | if (errors.length > 0) { 69 | return errors; 70 | } 71 | 72 | return null; 73 | } 74 | 75 | private async checkRequiredProperties( 76 | manifest: IManifest, 77 | requiredProperties: string[] 78 | ): Promise { 79 | const missingProperties: string[] = []; 80 | for (let property of requiredProperties) { 81 | if (!manifest.hasOwnProperty(property)) { 82 | missingProperties.push(property); 83 | } 84 | } 85 | if (missingProperties.length > 0) { 86 | return { 87 | code: ValidationErrorCode.MissingRequiredProperty, 88 | description: `Properties missing: ${missingProperties.join(', ')}`, 89 | }; 90 | } 91 | return null; 92 | } 93 | 94 | private async checkVersion(manifest: IManifest): Promise { 95 | if (!ManifestMetadata.availableVersions.includes(Number(manifest.version))) { 96 | return { 97 | code: ValidationErrorCode.InvalidVersion, 98 | description: `Unknown version: ${manifest.version}`, 99 | }; 100 | } 101 | return null; 102 | } 103 | 104 | private async checkCascadesType(manifest: IManifest): Promise { 105 | if (typeof manifest.cascades !== 'object') { 106 | return { 107 | code: ValidationErrorCode.InvalidCascadeType, 108 | description: `"cascades" should be an object, not ${typeof manifest.cascades}`, 109 | }; 110 | } 111 | if (Array.isArray(manifest.cascades)) { 112 | return { 113 | code: ValidationErrorCode.InvalidCascadeType, 114 | description: '"cascades" should be an object, not an array', 115 | }; 116 | } 117 | return null; 118 | } 119 | 120 | private async checkCascades(manifest: IManifest): Promise { 121 | const validator = this.cascadeValidator; 122 | const errors = await validator.validateCascades(manifest.cascades); 123 | 124 | if (errors && errors.length > 0) { 125 | return { 126 | code: ValidationErrorCode.InvalidCascadeConfiguration, 127 | description: `Invalid field refs: ${errors.join(', ')}`, 128 | }; 129 | } 130 | return null; 131 | } 132 | } 133 | 134 | export { 135 | ManifestService, 136 | ManifestValidationService, 137 | IManifestValidationError, 138 | ValidationErrorCode, 139 | }; 140 | -------------------------------------------------------------------------------- /src/common/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { CommonServiceIds, IExtensionDataService } from 'azure-devops-extension-api'; 2 | import * as SDK from 'azure-devops-extension-sdk'; 3 | 4 | enum ScopeType { 5 | Default = 'Default', 6 | User = 'User', 7 | } 8 | 9 | enum ConfigurationType { 10 | Manifest = 'manifest', 11 | } 12 | 13 | class StorageService { 14 | private storageKey: string; 15 | private scopeType: ScopeType; 16 | 17 | private dataService: IExtensionDataService; 18 | 19 | public constructor(storageKey: string, scope: ScopeType) { 20 | this.storageKey = storageKey; 21 | this.scopeType = scope; 22 | } 23 | 24 | private async getDataService(): Promise { 25 | if (this.dataService === undefined) { 26 | this.dataService = await SDK.getService( 27 | CommonServiceIds.ExtensionDataService 28 | ); 29 | } 30 | return this.dataService; 31 | } 32 | 33 | public async getData(): Promise { 34 | const dataService = await this.getDataService(); 35 | const dataManager = await dataService.getExtensionDataManager( 36 | SDK.getExtensionContext().id, 37 | await SDK.getAccessToken() 38 | ); 39 | return dataManager.getValue(this.storageKey, { 40 | scopeType: this.scopeType, 41 | }); 42 | } 43 | 44 | public async setData(data: Object): Promise { 45 | const dataService = await this.getDataService(); 46 | const dataManager = await dataService.getExtensionDataManager( 47 | SDK.getExtensionContext().id, 48 | await SDK.getAccessToken() 49 | ); 50 | return dataManager.setValue(this.storageKey, data, { 51 | scopeType: this.scopeType, 52 | }); 53 | } 54 | } 55 | 56 | class ConfigurationStorage { 57 | private storageService: StorageService; 58 | 59 | public constructor(configurationType: ConfigurationType, projectId: string) { 60 | this.storageService = new StorageService( 61 | `${configurationType}|${projectId}`, 62 | ScopeType.Default 63 | ); 64 | } 65 | 66 | public async getConfiguration(): Promise { 67 | return this.storageService.getData(); 68 | } 69 | 70 | public async setConfiguration(configuration: Object): Promise { 71 | return this.storageService.setData(configuration) as Promise; 72 | } 73 | } 74 | 75 | export { ScopeType, StorageService, ConfigurationStorage, ConfigurationType }; 76 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | export type FieldName = string; 2 | export type FieldOptions = Record; 3 | export type CascadeConfiguration = Record>; 4 | export type CascadeMap = Record; 5 | 6 | export enum FieldOptionsFlags { 7 | All = 'all', 8 | } 9 | export interface ICascade { 10 | alters: FieldName[]; 11 | cascades: Record; 12 | } 13 | 14 | export interface IManifest { 15 | version?: string; 16 | cascades?: CascadeConfiguration; 17 | } 18 | -------------------------------------------------------------------------------- /src/confighub/app.tsx: -------------------------------------------------------------------------------- 1 | import * as SDK from 'azure-devops-extension-sdk'; 2 | import * as React from 'react'; 3 | import { useEffect } from 'react'; 4 | import ConfigView from './views/ConfigView'; 5 | 6 | const App = () => { 7 | useEffect(() => { 8 | SDK.notifyLoadSucceeded(); 9 | }, []); 10 | 11 | return ; 12 | }; 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /src/confighub/components/FieldsTable.tsx: -------------------------------------------------------------------------------- 1 | import { ObservableValue } from 'azure-devops-ui/Core/Observable'; 2 | import { 3 | ColumnFill, 4 | ISimpleTableCell, 5 | renderSimpleCell, 6 | Table, 7 | TableColumnLayout, 8 | } from 'azure-devops-ui/Table'; 9 | import { IItemProvider } from 'azure-devops-ui/Utilities/Provider'; 10 | import * as React from 'react'; 11 | 12 | export const tableDefinition = [ 13 | { 14 | columnLayout: TableColumnLayout.singleLinePrefix, 15 | id: 'name', 16 | name: 'Name', 17 | readonly: true, 18 | renderCell: renderSimpleCell, 19 | width: new ObservableValue(400), 20 | }, 21 | { 22 | id: 'reference', 23 | name: 'Reference Name', 24 | readonly: true, 25 | renderCell: renderSimpleCell, 26 | width: new ObservableValue(600), 27 | }, 28 | ColumnFill, 29 | ]; 30 | 31 | interface FieldTableItem extends ISimpleTableCell { 32 | name: string; 33 | reference: string; 34 | } 35 | 36 | interface FieldsTableProps { 37 | itemProvider: IItemProvider; 38 | } 39 | 40 | const FieldsTable: React.FC = ({ itemProvider }) => ( 41 | 48 | ); 49 | 50 | export { FieldsTable, FieldTableItem }; 51 | -------------------------------------------------------------------------------- /src/confighub/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'azure-devops-ui/Button'; 2 | import { 3 | CustomHeader, 4 | HeaderTitle, 5 | HeaderTitleArea, 6 | HeaderTitleRow, 7 | TitleSize, 8 | } from 'azure-devops-ui/Header'; 9 | import { IStatusProps, Status, Statuses, StatusSize } from 'azure-devops-ui/Status'; 10 | import * as React from 'react'; 11 | import styled from 'styled-components'; 12 | import { IStatus } from '../hooks/configstorage'; 13 | 14 | interface IHeaderProps { 15 | title: string; 16 | status: IStatus; 17 | onSaveClick: () => void | Promise; 18 | } 19 | 20 | const HeaderSideContainer = styled.div` 21 | display: flex; 22 | align-items: center; 23 | 24 | & > * { 25 | margin: 0 1rem 0 1rem; 26 | } 27 | `; 28 | 29 | const Header = ({ title, status, onSaveClick }: IHeaderProps) => { 30 | const statusProps: IStatusProps = status.status ? Statuses.Success : Statuses.Failed; 31 | 32 | return ( 33 | 34 | 35 | 36 | {title} 37 | 38 | 39 | 40 |