├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── dist ├── favicon.ico ├── index.html └── style.css ├── package-lock.json ├── package.json ├── src ├── colors.js ├── graph.js ├── graphHandler.js ├── main.js ├── trelloApiHelper.js └── trelloHandler.js └── webpack.config.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | gh-pages: sugarshin/gh-pages@1.0.0 4 | 5 | jobs: 6 | build_deploy: 7 | docker: 8 | - image: cimg/node:16.13.0 9 | steps: 10 | - checkout 11 | - run: npm ci 12 | - run: npm run build 13 | - gh-pages/deploy: 14 | ssh-fingerprints: '9b:f8:a1:6f:dc:1f:ab:99:f5:f5:92:b8:da:86:b3:ab' 15 | 16 | workflows: 17 | Build and Deploy: 18 | jobs: 19 | - build_deploy: 20 | filters: 21 | branches: 22 | only: 23 | - master 24 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/trelloApiHelper.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"], 3 | "globals": { 4 | "window": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/app.js 3 | .idea 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.0 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | }; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ticket Dependency Graph 2 | 3 | Use this tool with Trello to visualize the dependencies between your tickets 4 | 5 | ## Demo 6 | 7 | Link for [live demo](https://theodo.github.io/ticket-dependency-graph/) 8 | 9 | ## Installation 10 | 11 | ``` 12 | git clone https://github.com/theodo/ticket-dependency-graph.git 13 | cd ticket-dependency-graph 14 | npm install 15 | npm start 16 | ``` 17 | 18 | Server should be running on `http://localhost:8080/` 19 | 20 | ## Deployment 21 | 22 | On every commit or merge on master, the app is automatically deployed to [Github Pages](https://theodo.github.io/ticket-dependency-graph/), thanks to a [CircleCI workflow](https://app.circleci.com/pipelines/github/theodo/ticket-dependency-graph). 23 | 24 | ## Usage 25 | 26 | - Authorize the application to bind with your Trello account 27 | - Choosing a column will fetch all the cards from that column 28 | - Drag and drop a card on another to mark a dependency (or use form below the graph) 29 | - You can also use the form below the graph to add a dependency between two tickets based on their id 30 | - The dependency will be stored in the checklist "Dependencies" of the Trello card 31 | - To delete a dependency, simply click on a link and press delete 32 | - You can directly modify the dependencies within Trello : as an item of the "Dependencies" checklist, you can either use the URL of a ticket or its id (e.g.: #131) 33 | - Each time you change something in Trello (adding, moving, copying or deleting a ticket), hit "Refresh" to keep in sync. 34 | 35 | ## Tips 36 | 37 | - It's faster to use the form to add a dependency 38 | - You can Drag & Drop the tickets to move them around and arrange them before you print the graph 39 | - For better visibility, you can delete a ticket from the graph: it will not be deleted from Trello and will come back on next Refresh 40 | 41 | ## Known issues 42 | 43 | - Tickets that are moved to another column disappear from the graph 44 | - [More issues](https://github.com/theodo/ticket-dependency-graph/issues) 45 | 46 | ## Feature and bug fix backlog 47 | 48 | Managed on Trello: https://trello.com/b/9AaI3LEO/ticket-dependency-graph 49 | You can ask access to @ivanosevitch on GitHub. 50 | -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theodo/ticket-dependency-graph/739d8e496593d831b6183e8ad67de8cad1268005/dist/favicon.ico -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ticket Dependency Graph 7 | 11 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | 36 | 44 |
45 |
46 | 59 |
60 |
61 | 74 |
75 |
76 | 89 |
90 |
91 | Open Trello board 98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | 107 | 108 |
109 |
110 | 111 | 112 |
113 |
114 | 120 | 123 |
124 |
125 |
126 |
127 | 133 |
134 |
135 | 141 |
142 |
143 |
144 | 145 | 149 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /dist/style.css: -------------------------------------------------------------------------------- 1 | #dependencyGraph { 2 | height: calc(100vh - 180px); 3 | border: 1px solid #9e9e9e; 4 | } 5 | 6 | .row { 7 | margin-bottom: 5px; 8 | white-space: nowrap 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ticket-dependency-graph", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "build": "webpack --progress", 8 | "start": "webpack serve --open", 9 | "lint": "eslint 'src/**/*.js'" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/theodo/ticket-dependency-graph.git" 14 | }, 15 | "contributors": [ 16 | "varal7", 17 | "Theodo" 18 | ], 19 | "license": "ISC", 20 | "homepage": "https://github.com/theodo/ticket-dependency-graph#readme", 21 | "browserslist": [ 22 | "defaults" 23 | ], 24 | "engines": { 25 | "node": "^16.13.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.16.0", 29 | "@babel/preset-env": "^7.16.4", 30 | "acorn": "^8.6.0", 31 | "babel-loader": "^8.2.3", 32 | "eslint": "^8.3.0", 33 | "eslint-config-airbnb-base": "^15.0.0", 34 | "eslint-config-prettier": "^8.3.0", 35 | "eslint-plugin-import": "^2.25.3", 36 | "prettier": "2.5.0", 37 | "webpack": "^5.64.4", 38 | "webpack-cli": "^4.9.1", 39 | "webpack-dev-server": "^4.11.1" 40 | }, 41 | "dependencies": { 42 | "gojs": "^2.1.54", 43 | "jquery": "^3.6.0", 44 | "vue": "^2.6.14", 45 | "vue-gtag": "^1.16.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/colors.js: -------------------------------------------------------------------------------- 1 | export const trelloColors = { 2 | black: '#344563', 3 | blue: '#0079bf', 4 | green: '#61bd4f', 5 | lime: '#51e898', 6 | orange: '#ff9f1a', 7 | pink: '#ff78cb', 8 | purple: '#c377e0', 9 | red: '#eb5a46', 10 | sky: '#00c2e0', 11 | yellow: '#f2d600', 12 | }; 13 | 14 | export const linkColor = '#555'; 15 | -------------------------------------------------------------------------------- /src/graph.js: -------------------------------------------------------------------------------- 1 | import go from 'gojs'; 2 | import { trelloColors, linkColor } from './colors'; 3 | 4 | const GO = go.GraphObject.make; 5 | 6 | const getNumberNode = ( 7 | textBindingKey, 8 | shapeFillColor, 9 | textStrokeColor, 10 | visibleBindingKey 11 | ) => { 12 | const rectangleShapeProperties = [ 13 | go.Shape, 14 | 'RoundedRectangle', 15 | { 16 | fill: shapeFillColor, 17 | stroke: null, 18 | }, 19 | ]; 20 | const textShapeProperties = [ 21 | go.TextBlock, 22 | new go.Binding('text', textBindingKey), 23 | { 24 | margin: 1, 25 | font: 'bold 12px sans-serif', 26 | stroke: textStrokeColor, 27 | textAlign: 'center', 28 | }, 29 | ]; 30 | if (visibleBindingKey) { 31 | rectangleShapeProperties.push(new go.Binding('visible', visibleBindingKey)); 32 | textShapeProperties.push(new go.Binding('visible', visibleBindingKey)); 33 | } 34 | return GO( 35 | go.Panel, 36 | 'Auto', 37 | { margin: 2 }, 38 | GO(...rectangleShapeProperties), 39 | GO(...textShapeProperties) 40 | ); 41 | }; 42 | 43 | const trelloCardNumberNodes = GO( 44 | go.Panel, 45 | 'Horizontal', 46 | { alignment: go.Spot.Left }, 47 | getNumberNode('keyHashtag', '#FFDD00', 'black', null), 48 | getNumberNode( 49 | 'complexityEstimation', 50 | '#47bae0', 51 | 'white', 52 | 'isComplexityEstimationVisible' 53 | ), 54 | getNumberNode('complexityReal', '#81cae2', 'white', 'isComplexityRealVisible') 55 | ); 56 | 57 | const trelloCardLabels = GO( 58 | go.Panel, 59 | 'Vertical', 60 | { alignment: go.Spot.Left }, 61 | new go.Binding('itemArray', 'labels'), 62 | { 63 | itemTemplate: GO( 64 | go.Panel, 65 | 'Auto', 66 | { margin: 2, alignment: go.Spot.Left }, 67 | GO( 68 | go.Shape, 69 | 'RoundedRectangle', 70 | { fill: trelloColors.purple, stroke: null }, // default to purple when the color coming from a Trello card label isn't in the trelloColors list 71 | new go.Binding('fill', 'color') 72 | ), 73 | GO(go.TextBlock, new go.Binding('text', 'name'), { 74 | margin: new go.Margin(1, 4), 75 | font: 'bold 10px sans-serif', 76 | stroke: 'white', 77 | }) 78 | ), 79 | } 80 | ); 81 | 82 | window.myDiagram = GO(go.Diagram, 'dependencyGraph', { 83 | initialContentAlignment: go.Spot.Center, 84 | 'undoManager.isEnabled': true, 85 | allowCopy: false, 86 | autoScale: go.Diagram.Uniform, 87 | layout: GO(go.LayeredDigraphLayout, { direction: 90, layerSpacing: 10 }), 88 | }); 89 | 90 | window.myDiagram.nodeTemplate = GO( 91 | go.Node, 92 | 'Auto', 93 | { 94 | isShadowed: true, 95 | shadowColor: '#C5C1AA', 96 | layoutConditions: go.Part.LayoutAdded, 97 | }, 98 | { 99 | mouseDrop(e, node) { 100 | const { diagram } = node; 101 | const selectedNode = diagram.selection.first(); // assume just one Node in selection 102 | if (selectedNode instanceof go.Node) { 103 | window.graphHandler.addDependency(node.data.key, selectedNode.data.key); 104 | selectedNode.data.hasJustBeenLinked = true; 105 | // eslint-disable-next-line no-param-reassign 106 | node.isLayoutPositioned = true; 107 | } 108 | }, 109 | }, 110 | GO(go.Shape, 'RoundedRectangle', { strokeWidth: 1, fill: 'white' }), 111 | GO( 112 | go.Panel, 113 | 'Horizontal', 114 | trelloCardLabels, 115 | GO( 116 | go.Panel, 117 | 'Vertical', 118 | { padding: 6 }, 119 | trelloCardNumberNodes, 120 | GO( 121 | go.TextBlock, 122 | { 123 | margin: 2, 124 | font: '12px sans-serif', 125 | width: 120, 126 | wrap: go.TextBlock.WrapFit, 127 | alignment: go.Spot.Left, 128 | }, 129 | new go.Binding('text', 'name') 130 | ) 131 | ) 132 | ) 133 | ); 134 | 135 | // To be populated with Trello 136 | const myModel = GO(go.GraphLinksModel); 137 | myModel.nodeDataArray = [ 138 | { 139 | key: 1, 140 | keyHashtag: '#1', 141 | complexityEstimation: 13, 142 | complexityReal: 8, 143 | isComplexityEstimationVisible: true, 144 | isComplexityRealVisible: true, 145 | name: 'Connect to Trello to use the TDG', 146 | labels: [{ color: trelloColors.blue, name: 'connection' }], 147 | }, 148 | { 149 | key: 2, 150 | keyHashtag: '#2', 151 | complexityEstimation: 5, 152 | complexityReal: 8, 153 | isComplexityEstimationVisible: true, 154 | isComplexityRealVisible: true, 155 | isVisible: false, 156 | name: "Choose a board, a list, and you're good to go!", 157 | labels: [{ color: trelloColors.pink, name: 'actions' }], 158 | }, 159 | { 160 | key: 3, 161 | keyHashtag: '#3', 162 | complexityEstimation: null, 163 | complexityReal: 5, 164 | isComplexityEstimationVisible: false, 165 | isComplexityRealVisible: true, 166 | name: 'You can add a link between two tickets given their id using the form below', 167 | labels: [{ color: trelloColors.pink, name: 'actions' }], 168 | }, 169 | { 170 | key: 4, 171 | keyHashtag: '#4', 172 | complexityEstimation: 1, 173 | complexityReal: 1, 174 | isComplexityEstimationVisible: true, 175 | isComplexityRealVisible: true, 176 | name: 'Or you can use Drag&Drop: simply drag a ticket over a ticket it depends on', 177 | labels: [{ color: trelloColors.pink, name: 'actions' }], 178 | }, 179 | { 180 | key: 5, 181 | keyHashtag: '#5', 182 | complexityEstimation: 0.5, 183 | complexityReal: null, 184 | isComplexityEstimationVisible: true, 185 | isComplexityRealVisible: false, 186 | name: 'To delete a link, select it with your mouse and press the Delete key', 187 | labels: [{ color: trelloColors.purple, name: 'tips' }], 188 | }, 189 | { 190 | key: 6, 191 | keyHashtag: '#6', 192 | complexityEstimation: null, 193 | complexityReal: null, 194 | isComplexityEstimationVisible: false, 195 | isComplexityRealVisible: false, 196 | name: 'Dependencies will be stored on your Trello board!', 197 | labels: [{ color: trelloColors.purple, name: 'tips' }], 198 | }, 199 | { 200 | key: 7, 201 | keyHashtag: '#7', 202 | complexityEstimation: null, 203 | complexityReal: null, 204 | isComplexityEstimationVisible: false, 205 | isComplexityRealVisible: false, 206 | name: 'Enjoy!', 207 | }, 208 | ]; 209 | 210 | myModel.linkDataArray = [ 211 | { from: 1, to: 2 }, 212 | { from: 2, to: 3 }, 213 | { from: 2, to: 4 }, 214 | { from: 3, to: 5 }, 215 | { from: 4, to: 5 }, 216 | ]; 217 | 218 | window.myDiagram.linkTemplate = GO( 219 | go.Link, 220 | GO(go.Shape, { strokeWidth: 5, stroke: linkColor }), 221 | GO( 222 | go.Shape, 223 | { 224 | toArrow: 'RoundedTriangle', 225 | // Set segmentIndex to -Infinity to position the arrow tip at the link center. 226 | // We don't want to position it at the link end (as is default) since that would make it 227 | // overlap the link corners, making for a less pleasurable visual experience 228 | segmentIndex: -Infinity, 229 | // However, we want the _arrow_ to be centered rather than its _tip_, 230 | // so we offset it by about half an arrow length along the link. 231 | segmentOffset: new go.Point(10, 0), 232 | }, 233 | { scale: 1.9, fill: linkColor, stroke: linkColor } 234 | ) 235 | ); 236 | 237 | window.myDiagram.addDiagramListener('SelectionDeleting', (e) => { 238 | const part = e.subject.first(); // e.subject is the myDiagram.selection collection, 239 | // so we'll get the first since we know we only have one selection 240 | if (part instanceof go.Link) { 241 | const childId = part.toNode.data.key; 242 | const parentId = part.fromNode.data.key; 243 | window.trelloHandler.deleteTrelloDependency(parentId, childId); 244 | } 245 | }); 246 | 247 | window.myDiagram.addDiagramListener('SelectionMoved', (e) => { 248 | e.subject.each((part) => { 249 | if (part.data.hasJustBeenLinked) { 250 | part.data.hasJustBeenLinked = false; // eslint-disable-line no-param-reassign 251 | part.isLayoutPositioned = true; // eslint-disable-line no-param-reassign 252 | } else part.isLayoutPositioned = false; // eslint-disable-line no-param-reassign 253 | }); 254 | }); 255 | 256 | window.myDiagram.model = myModel; 257 | -------------------------------------------------------------------------------- /src/graphHandler.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import './graph'; 3 | 4 | const parseTicketName = (name) => { 5 | const matches = name.match( 6 | /^(?:\[(?\d+[.,]?\d*)\])? ?(?:\((?\d+[.,]?\d*)\))?(?: ?)(?.+)$/ 7 | ); 8 | if (!matches) 9 | return { 10 | name, 11 | complexityEstimation: null, 12 | complexityReal: null, 13 | }; 14 | return { 15 | name: matches[3] ? matches[3] : '', 16 | complexityEstimation: matches[2] ? matches[2] : null, 17 | complexityReal: matches[1] ? matches[1] : null, 18 | }; 19 | }; 20 | 21 | window.graphHandler = new Vue({ 22 | el: '#graphHandler', 23 | 24 | data: { 25 | currentParent: '', 26 | currentChild: '', 27 | newTicketId: '', 28 | newTicketName: '', 29 | dataAsJson: '', 30 | }, 31 | 32 | methods: { 33 | addDependency(parent, child) { 34 | this.addGraphDependency(parent, child); 35 | window.trelloHandler.addTrelloDependency(parent, child); 36 | }, 37 | 38 | addGraphDependency(parent, child) { 39 | window.myDiagram.startTransaction('Add dependency'); 40 | window.myDiagram.model.addLinkData({ 41 | from: parent, 42 | to: child, 43 | }); 44 | window.myDiagram.commitTransaction('Add dependency'); 45 | 46 | this.currentChild = null; 47 | this.currentParent = null; 48 | }, 49 | 50 | addOrUpdateTicket({ ticketId, ticketName, ticketLabels }) { 51 | const currentNode = window.myDiagram.model.findNodeDataForKey(ticketId); 52 | const ticketInfo = parseTicketName(ticketName); 53 | if (currentNode == null) { 54 | window.myDiagram.startTransaction('Add ticket'); 55 | const newTicket = { 56 | ...ticketInfo, 57 | key: ticketId, 58 | keyHashtag: `#${ticketId}`, 59 | isComplexityEstimationVisible: !!ticketInfo.complexityEstimation, 60 | isComplexityRealVisible: !!ticketInfo.complexityReal, 61 | labels: ticketLabels, 62 | }; 63 | window.myDiagram.model.addNodeData(newTicket); 64 | window.myDiagram.commitTransaction('Add ticket'); 65 | } else { 66 | window.myDiagram.startTransaction('Update ticket'); 67 | window.myDiagram.model.setDataProperty( 68 | currentNode, 69 | 'name', 70 | ticketInfo.name 71 | ); 72 | window.myDiagram.model.setDataProperty( 73 | currentNode, 74 | 'complexityEstimation', 75 | ticketInfo.complexityEstimation 76 | ); 77 | window.myDiagram.model.setDataProperty( 78 | currentNode, 79 | 'isComplexityEstimationVisible', 80 | !!ticketInfo.complexityEstimation 81 | ); 82 | window.myDiagram.model.setDataProperty( 83 | currentNode, 84 | 'isComplexityRealVisible', 85 | !!ticketInfo.complexityReal 86 | ); 87 | window.myDiagram.model.setDataProperty( 88 | currentNode, 89 | 'complexityReal', 90 | ticketInfo.complexityReal 91 | ); 92 | window.myDiagram.model.setDataProperty( 93 | currentNode, 94 | 'labels', 95 | ticketLabels 96 | ); 97 | window.myDiagram.commitTransaction('Update ticket'); 98 | } 99 | }, 100 | 101 | removeTicket(ticketId) { 102 | const currentNode = window.myDiagram.findNodeForKey(ticketId); 103 | if (currentNode != null) { 104 | window.myDiagram.startTransaction('Remove ticket'); 105 | window.myDiagram.remove(currentNode); 106 | window.myDiagram.commitTransaction('Remove ticket'); 107 | } 108 | }, 109 | 110 | getNodes() { 111 | return window.myDiagram.model.nodeDataArray; 112 | }, 113 | 114 | saveData() { 115 | this.dataAsJson = window.myDiagram.model.toJson(); 116 | }, 117 | 118 | loadData() { 119 | window.myDiagram.model = window.go.Model.fromJson(this.dataAsJson); 120 | }, 121 | }, 122 | }); 123 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueGtag from 'vue-gtag'; 3 | 4 | import './trelloHandler'; 5 | import './graphHandler'; 6 | 7 | Vue.use(VueGtag, { 8 | config: { id: 'UA-121199171-2' }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/trelloApiHelper.js: -------------------------------------------------------------------------------- 1 | var jQuery = require('jquery'); 2 | var opts = { 3 | version: 1, 4 | apiEndpoint: 'https://api.trello.com', 5 | authEndpoint: 'https://trello.com', 6 | intentEndpoint: 'https://trello.com', 7 | key: '39eb391c4ba671275f13eb4ee1d51116', 8 | }; 9 | 10 | var deferred, 11 | isFunction, 12 | isReady, 13 | ready, 14 | waitUntil, 15 | wrapper, 16 | slice = [].slice; 17 | 18 | // The local storage key must be related to the ticket dependency graph, to avoid conflict with the other 19 | // apps hosted on https://theodo.github.io/ 20 | // Indeed, local storage keys are shared among apps from the same host (more exactly, among the protocol://host:port combination) 21 | const tokenStorageKey = 'ticket-dependency-graph-token'; 22 | 23 | wrapper = function (window, jQuery, opts) { 24 | var $, 25 | Trello, 26 | apiEndpoint, 27 | authEndpoint, 28 | authorizeURL, 29 | baseURL, 30 | collection, 31 | fn, 32 | fn1, 33 | i, 34 | intentEndpoint, 35 | j, 36 | key, 37 | len, 38 | len1, 39 | localStorage, 40 | location, 41 | parseRestArgs, 42 | readStorage, 43 | ref, 44 | ref1, 45 | storagePrefix, 46 | token, 47 | type, 48 | version, 49 | writeStorage; 50 | $ = jQuery; 51 | (key = opts.key), 52 | (token = opts.token), 53 | (apiEndpoint = opts.apiEndpoint), 54 | (authEndpoint = opts.authEndpoint), 55 | (intentEndpoint = opts.intentEndpoint), 56 | (version = opts.version); 57 | baseURL = apiEndpoint + '/' + version + '/'; 58 | location = window.location; 59 | Trello = { 60 | version: function () { 61 | return version; 62 | }, 63 | key: function () { 64 | return key; 65 | }, 66 | setKey: function (newKey) { 67 | key = newKey; 68 | }, 69 | token: function () { 70 | return token; 71 | }, 72 | setToken: function (newToken) { 73 | token = newToken; 74 | }, 75 | rest: function () { 76 | var args, error, method, params, path, ref, success; 77 | (method = arguments[0]), 78 | (args = 2 <= arguments.length ? slice.call(arguments, 1) : []); 79 | (ref = parseRestArgs(args)), 80 | (path = ref[0]), 81 | (params = ref[1]), 82 | (success = ref[2]), 83 | (error = ref[3]); 84 | opts = { 85 | url: '' + baseURL + path, 86 | type: method, 87 | data: {}, 88 | dataType: 'json', 89 | success: success, 90 | error: error, 91 | }; 92 | if (!$.support.cors) { 93 | opts.dataType = 'jsonp'; 94 | if (method !== 'GET') { 95 | opts.type = 'GET'; 96 | $.extend(opts.data, { 97 | _method: method, 98 | }); 99 | } 100 | } 101 | if (key) { 102 | opts.data.key = key; 103 | } 104 | if (token) { 105 | opts.data.token = token; 106 | } 107 | if (params != null) { 108 | $.extend(opts.data, params); 109 | } 110 | return $.ajax(opts); 111 | }, 112 | authorized: function () { 113 | return token != null; 114 | }, 115 | deauthorize: function () { 116 | token = null; 117 | writeStorage(tokenStorageKey, token); 118 | }, 119 | authorize: function (userOpts) { 120 | var k, persistToken, ref, regexToken, scope, v; 121 | opts = $.extend( 122 | true, 123 | { 124 | type: 'redirect', 125 | persist: true, 126 | interactive: true, 127 | scope: { 128 | read: true, 129 | write: false, 130 | account: false, 131 | }, 132 | expiration: '30days', 133 | }, 134 | userOpts 135 | ); 136 | regexToken = /[&#]?token=([0-9a-f]{64})/; 137 | persistToken = function () { 138 | if (opts.persist && token != null) { 139 | return writeStorage(tokenStorageKey, token); 140 | } 141 | }; 142 | if (opts.persist) { 143 | if (token == null) { 144 | token = readStorage(tokenStorageKey); 145 | } 146 | } 147 | if (token == null) { 148 | token = 149 | (ref = regexToken.exec(location.hash)) != null ? ref[1] : void 0; 150 | } 151 | if (this.authorized()) { 152 | persistToken(); 153 | location.hash = location.hash.replace(regexToken, ''); 154 | return typeof opts.success === 'function' ? opts.success() : void 0; 155 | } 156 | if (!opts.interactive) { 157 | return typeof opts.error === 'function' ? opts.error() : void 0; 158 | } 159 | scope = (function () { 160 | var ref1, results; 161 | ref1 = opts.scope; 162 | results = []; 163 | for (k in ref1) { 164 | v = ref1[k]; 165 | if (v) { 166 | results.push(k); 167 | } 168 | } 169 | return results; 170 | })().join(','); 171 | switch (opts.type) { 172 | case 'popup': 173 | (function () { 174 | var authWindow, 175 | height, 176 | left, 177 | origin, 178 | receiveMessage, 179 | ref1, 180 | top, 181 | width; 182 | waitUntil( 183 | 'authorized', 184 | (function (_this) { 185 | return function (isAuthorized) { 186 | if (isAuthorized) { 187 | persistToken(); 188 | return typeof opts.success === 'function' 189 | ? opts.success() 190 | : void 0; 191 | } else { 192 | return typeof opts.error === 'function' 193 | ? opts.error() 194 | : void 0; 195 | } 196 | }; 197 | })(this) 198 | ); 199 | width = 420; 200 | height = 470; 201 | left = window.screenX + (window.innerWidth - width) / 2; 202 | top = window.screenY + (window.innerHeight - height) / 2; 203 | origin = 204 | (ref1 = /^[a-z]+:\/\/[^\/]*/.exec(location)) != null 205 | ? ref1[0] 206 | : void 0; 207 | authWindow = window.open( 208 | authorizeURL({ 209 | return_url: origin, 210 | callback_method: 'postMessage', 211 | scope: scope, 212 | expiration: opts.expiration, 213 | name: opts.name, 214 | }), 215 | 'trello', 216 | 'width=' + 217 | width + 218 | ',height=' + 219 | height + 220 | ',left=' + 221 | left + 222 | ',top=' + 223 | top 224 | ); 225 | receiveMessage = function (event) { 226 | var ref2; 227 | if ( 228 | event.origin !== authEndpoint || 229 | event.source !== authWindow 230 | ) { 231 | return; 232 | } 233 | if ((ref2 = event.source) != null) { 234 | ref2.close(); 235 | } 236 | if (event.data != null && /[0-9a-f]{64}/.test(event.data)) { 237 | token = event.data; 238 | } else { 239 | token = null; 240 | } 241 | if (typeof window.removeEventListener === 'function') { 242 | window.removeEventListener('message', receiveMessage, false); 243 | } 244 | isReady('authorized', Trello.authorized()); 245 | }; 246 | return typeof window.addEventListener === 'function' 247 | ? window.addEventListener('message', receiveMessage, false) 248 | : void 0; 249 | })(); 250 | break; 251 | default: 252 | window.location = authorizeURL({ 253 | redirect_uri: location.href, 254 | callback_method: 'fragment', 255 | scope: scope, 256 | expiration: opts.expiration, 257 | name: opts.name, 258 | }); 259 | } 260 | }, 261 | addCard: function (options, next) { 262 | var baseArgs, getCard; 263 | baseArgs = { 264 | mode: 'popup', 265 | source: key || window.location.host, 266 | }; 267 | getCard = function (callback) { 268 | var height, left, returnUrl, top, width; 269 | returnUrl = function (e) { 270 | var data; 271 | window.removeEventListener('message', returnUrl); 272 | try { 273 | data = JSON.parse(e.data); 274 | if (data.success) { 275 | return callback(null, data.card); 276 | } else { 277 | return callback(new Error(data.error)); 278 | } 279 | } catch (_error) {} 280 | }; 281 | if (typeof window.addEventListener === 'function') { 282 | window.addEventListener('message', returnUrl, false); 283 | } 284 | width = 500; 285 | height = 600; 286 | left = window.screenX + (window.outerWidth - width) / 2; 287 | top = window.screenY + (window.outerHeight - height) / 2; 288 | return window.open( 289 | intentEndpoint + '/add-card?' + $.param($.extend(baseArgs, options)), 290 | 'trello', 291 | 'width=' + 292 | width + 293 | ',height=' + 294 | height + 295 | ',left=' + 296 | left + 297 | ',top=' + 298 | top 299 | ); 300 | }; 301 | if (next != null) { 302 | return getCard(next); 303 | } else if (window.Promise) { 304 | return new Promise(function (resolve, reject) { 305 | return getCard(function (err, card) { 306 | if (err) { 307 | return reject(err); 308 | } else { 309 | return resolve(card); 310 | } 311 | }); 312 | }); 313 | } else { 314 | return getCard(function () {}); 315 | } 316 | }, 317 | }; 318 | ref = ['GET', 'PUT', 'POST', 'DELETE']; 319 | fn = function (type) { 320 | return (Trello[type.toLowerCase()] = function () { 321 | return this.rest.apply(this, [type].concat(slice.call(arguments))); 322 | }); 323 | }; 324 | for (i = 0, len = ref.length; i < len; i++) { 325 | type = ref[i]; 326 | fn(type); 327 | } 328 | Trello.del = Trello['delete']; 329 | ref1 = [ 330 | 'actions', 331 | 'cards', 332 | 'checklists', 333 | 'boards', 334 | 'lists', 335 | 'members', 336 | 'organizations', 337 | 'lists', 338 | ]; 339 | fn1 = function (collection) { 340 | return (Trello[collection] = { 341 | get: function (id, params, success, error) { 342 | return Trello.get(collection + '/' + id, params, success, error); 343 | }, 344 | }); 345 | }; 346 | for (j = 0, len1 = ref1.length; j < len1; j++) { 347 | collection = ref1[j]; 348 | fn1(collection); 349 | } 350 | window.Trello = Trello; 351 | authorizeURL = function (args) { 352 | var baseArgs; 353 | baseArgs = { 354 | response_type: 'token', 355 | key: key, 356 | }; 357 | return ( 358 | authEndpoint + 359 | '/' + 360 | version + 361 | '/authorize?' + 362 | $.param($.extend(baseArgs, args)) 363 | ); 364 | }; 365 | parseRestArgs = function (arg) { 366 | var error, params, path, success; 367 | (path = arg[0]), (params = arg[1]), (success = arg[2]), (error = arg[3]); 368 | if (isFunction(params)) { 369 | error = success; 370 | success = params; 371 | params = {}; 372 | } 373 | path = path.replace(/^\/*/, ''); 374 | return [path, params, success, error]; 375 | }; 376 | localStorage = window.localStorage; 377 | if (localStorage != null) { 378 | storagePrefix = 'trello_'; 379 | readStorage = function (key) { 380 | return localStorage[storagePrefix + key]; 381 | }; 382 | writeStorage = function (key, value) { 383 | if (value === null) { 384 | return delete localStorage[storagePrefix + key]; 385 | } else { 386 | return (localStorage[storagePrefix + key] = value); 387 | } 388 | }; 389 | } else { 390 | readStorage = writeStorage = function () {}; 391 | } 392 | }; 393 | 394 | deferred = {}; 395 | 396 | ready = {}; 397 | 398 | waitUntil = function (name, fx) { 399 | if (ready[name] != null) { 400 | return fx(ready[name]); 401 | } else { 402 | return ( 403 | deferred[name] != null ? deferred[name] : (deferred[name] = []) 404 | ).push(fx); 405 | } 406 | }; 407 | 408 | isReady = function (name, value) { 409 | var fx, fxs, i, len; 410 | ready[name] = value; 411 | if (deferred[name]) { 412 | fxs = deferred[name]; 413 | delete deferred[name]; 414 | for (i = 0, len = fxs.length; i < len; i++) { 415 | fx = fxs[i]; 416 | fx(value); 417 | } 418 | } 419 | }; 420 | 421 | isFunction = function (val) { 422 | return typeof val === 'function'; 423 | }; 424 | 425 | wrapper(window, jQuery, opts); 426 | -------------------------------------------------------------------------------- /src/trelloHandler.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { trelloColors } from './colors'; 3 | import './trelloApiHelper'; 4 | import './graphHandler'; 5 | 6 | const lastBoardChoice = 'lastBoardChoice'; 7 | const lastListChoice = 'lastListChoice'; 8 | const lastLabelChoice = 'lastLabelChoice'; 9 | 10 | window.trelloHandler = new Vue({ 11 | el: '#trello', 12 | 13 | data: { 14 | authenticated: false, 15 | boards: null, 16 | selectedBoard: '', 17 | lists: null, 18 | labels: null, 19 | selectedList: '', 20 | selectedLabel: '', 21 | cards: null, 22 | loading: false, 23 | trelloUrl: null, 24 | }, 25 | 26 | methods: { 27 | onBoardChange(event) { 28 | const boardId = event.target.value; 29 | 30 | this.selectBoard(boardId).then(() => { 31 | this.persistChoicesInLocalStorage(); 32 | }); 33 | }, 34 | 35 | onListChange(event) { 36 | const listId = event.target.value; 37 | 38 | this.selectList(listId).then(() => { 39 | this.persistChoicesInLocalStorage(); 40 | }); 41 | }, 42 | 43 | onLabelChange(event) { 44 | const labelId = event.target.value; 45 | 46 | this.selectLabel(labelId).then(() => { 47 | this.persistChoicesInLocalStorage(); 48 | }); 49 | }, 50 | 51 | persistChoicesInLocalStorage() { 52 | window.localStorage.setItem(lastBoardChoice, this.selectedBoard); 53 | window.localStorage.setItem(lastListChoice, this.selectedList); 54 | window.localStorage.setItem(lastLabelChoice, this.selectedLabel); 55 | }, 56 | 57 | authorize() { 58 | window.Trello.authorize({ 59 | type: 'popup', 60 | name: 'Ticket Dependency Graph', 61 | scope: { 62 | read: 'true', 63 | write: 'false', 64 | }, 65 | expiration: 'never', 66 | success: this.authSuccessHandler, 67 | error() { 68 | console.warn('Failed authentication'); // eslint-disable-line no-console 69 | }, 70 | }); 71 | }, 72 | 73 | authSuccessHandler() { 74 | const vm = this; 75 | console.log('Successful authentication'); // eslint-disable-line no-console 76 | this.loading = true; 77 | window.Trello.get('/member/me/boards').then((data) => { 78 | vm.boards = data; 79 | vm.loading = false; 80 | 81 | // Thanks to Vue.nextTick, we wait for Vue to update the DOM, for the board dropdown to be filled with 82 | // the list of boards before retrieveLastBoardAndListChoice sets a chosen value in the board dropdown. 83 | Vue.nextTick(vm.retrievePersistedChoicesFromLocalStorage); 84 | }); 85 | }, 86 | 87 | refresh() { 88 | const vm = this; 89 | this.loading = true; 90 | 91 | if (!this.selectedList && !this.selectedLabel){ 92 | this.loading = false; 93 | return Promise.resolve(); 94 | } 95 | 96 | let cardsPromise; 97 | if (this.selectedList) { 98 | cardsPromise = window.Trello.get(`/lists/${this.selectedList}/cards`); 99 | } 100 | if (this.selectedLabel) { 101 | cardsPromise = cardsPromise || window.Trello.get( 102 | `/boards/${this.selectedBoard}/cards` 103 | ) 104 | cardsPromise = cardsPromise.then((cards) => 105 | cards.filter((card) => 106 | card.labels.some((label) => label.id === this.selectedLabel) 107 | ) 108 | ); 109 | } 110 | 111 | return cardsPromise.then((data) => { 112 | vm.cards = data; 113 | vm.deleteUselessCards(); 114 | vm.addOrUpdateCards(); 115 | vm.calculateDependenciesAsPromises().then((linkDataArray) => { 116 | window.myDiagram.model.linkDataArray = linkDataArray; 117 | vm.loading = false; 118 | }); 119 | }); 120 | }, 121 | 122 | retrievePersistedChoicesFromLocalStorage() { 123 | const boardChoiceId = window.localStorage.getItem(lastBoardChoice); 124 | const listChoiceId = window.localStorage.getItem(lastListChoice); 125 | const labelChoiceId = window.localStorage.getItem(lastLabelChoice); 126 | 127 | if (!boardChoiceId || (!listChoiceId && !labelChoiceId)) { 128 | return Promise.resolve(); 129 | } 130 | 131 | return this.selectBoard(boardChoiceId).then(() => 132 | Vue.nextTick(() => { 133 | if (listChoiceId) { 134 | this.selectedList = listChoiceId; 135 | } 136 | if (labelChoiceId){ 137 | this.selectedLabel = labelChoiceId; 138 | } 139 | this.refresh(); 140 | }) 141 | ); 142 | }, 143 | 144 | selectBoard(boardId) { 145 | this.selectedBoard = boardId; 146 | 147 | return Promise.all([ 148 | window.Trello.get(`/boards/${boardId}/lists`), 149 | window.Trello.get(`/boards/${boardId}/shortUrl`), 150 | window.Trello.get(`/boards/${boardId}/labels`), 151 | ]).then(([lists, trelloUrl, labels]) => { 152 | this.lists = lists; 153 | this.trelloUrl = trelloUrl._value; // eslint-disable-line no-underscore-dangle 154 | this.labels = labels; 155 | }); 156 | }, 157 | 158 | selectList(listId) { 159 | this.selectedList = listId; 160 | return this.refresh(); 161 | }, 162 | 163 | selectLabel(labelId) { 164 | this.selectedLabel = labelId; 165 | return this.refresh(); 166 | }, 167 | 168 | addOrUpdateCards() { 169 | for (let i = 0; i < this.cards.length; i += 1) { 170 | const card = this.cards[i]; 171 | window.graphHandler.addOrUpdateTicket({ 172 | ticketId: card.idShort, 173 | ticketName: card.name, 174 | ticketLabels: card.labels.map((ticketLabel) => ({ 175 | color: trelloColors[ticketLabel.color], 176 | name: ticketLabel.name, 177 | })), 178 | }); 179 | } 180 | }, 181 | 182 | deleteUselessCards() { 183 | const nodes = window.graphHandler.getNodes(); 184 | const toBeRemoved = []; 185 | for (let i = 0; i < nodes.length; i += 1) { 186 | const node = nodes[i]; 187 | if (!this.isTicketIdInList(node.key)) { 188 | toBeRemoved.push(node.key); 189 | } 190 | } 191 | for (let i = 0; i < toBeRemoved.length; i += 1) { 192 | window.graphHandler.removeTicket(toBeRemoved[i]); 193 | } 194 | }, 195 | 196 | calculateDependenciesAsPromises() { 197 | const vm = this; 198 | const linkDataArray = []; 199 | const promises = []; 200 | for (let iCard = 0; iCard < vm.cards.length; iCard += 1) { 201 | promises.push( 202 | new Promise((resolve) => { 203 | vm.getOrCreateDependencyChecklist(vm.cards[iCard]).then( 204 | (checklist) => { 205 | const ticketIds = 206 | vm.getDependentTicketsFromChecklist(checklist); 207 | for (let j = 0; j < ticketIds.length; j += 1) { 208 | linkDataArray.push({ 209 | from: ticketIds[j].ticketId, 210 | to: vm.getTicketIdFromIdCard(checklist.idCard), 211 | }); 212 | } 213 | resolve(); 214 | } 215 | ); 216 | }) 217 | ); 218 | } 219 | return new Promise((resolve) => { 220 | Promise.all(promises).then(() => { 221 | resolve(linkDataArray); 222 | }); 223 | }); 224 | }, 225 | 226 | getTicketIdFromIdCard(idCard) { 227 | if (this.cards == null) { 228 | return null; 229 | } 230 | for (let i = 0; i < this.cards.length; i += 1) { 231 | if (this.cards[i].id === idCard) { 232 | return this.cards[i].idShort; 233 | } 234 | } 235 | return null; 236 | }, 237 | 238 | isTicketIdInList(ticketId) { 239 | for (let i = 0; i < this.cards.length; i += 1) { 240 | if (this.cards[i].idShort === ticketId) { 241 | return true; 242 | } 243 | } 244 | return false; 245 | }, 246 | 247 | addTrelloDependency(parentId, childId) { 248 | let childCard = null; 249 | let parentCard = null; 250 | if (this.cards == null) { 251 | console.warn('Fail adding dependency in Trello'); // eslint-disable-line no-console 252 | return false; 253 | } 254 | for (let i = 0; i < this.cards.length; i += 1) { 255 | if (this.cards[i].idShort === childId) { 256 | childCard = this.cards[i]; 257 | } 258 | if (this.cards[i].idShort === parentId) { 259 | parentCard = this.cards[i]; 260 | } 261 | } 262 | if (childCard == null || parentCard == null) { 263 | console.warn('Fail adding dependency in Trello'); // eslint-disable-line no-console 264 | return false; 265 | } 266 | return this.getOrCreateDependencyChecklist(childCard).then( 267 | (checklist) => { 268 | const checkItem = { 269 | name: parentCard.url, 270 | }; 271 | window.Trello.post( 272 | `/checklists/${checklist.id}/checkItems`, 273 | checkItem 274 | ); 275 | } 276 | ); 277 | }, 278 | 279 | deleteTrelloDependency(parentId, childId) { 280 | const vm = this; 281 | let childCard = null; 282 | if (this.cards == null) { 283 | console.warn('Fail deleting dependency in Trello'); // eslint-disable-line no-console 284 | return false; 285 | } 286 | for (let i = 0; i < this.cards.length; i += 1) { 287 | if (this.cards[i].idShort === childId) { 288 | childCard = this.cards[i]; 289 | } 290 | } 291 | if (childCard == null) { 292 | console.warn('Fail deleting dependency in Trello'); // eslint-disable-line no-console 293 | return false; 294 | } 295 | return this.getOrCreateDependencyChecklist(childCard).then( 296 | (checklist) => { 297 | const ticketIds = vm.getDependentTicketsFromChecklist(checklist); 298 | for (let i = 0; i < ticketIds.length; i += 1) { 299 | if (ticketIds[i].ticketId === parentId) { 300 | window.Trello.delete( 301 | `/checklists/${checklist.id}/checkItems/${ticketIds[i].checkItemId}` 302 | ); 303 | console.log('Dependency deleted'); // eslint-disable-line no-console 304 | return; 305 | } 306 | } 307 | } 308 | ); 309 | }, 310 | 311 | getDependentTicketsFromChecklist(checklist) { 312 | const ticketIds = []; 313 | if (checklist.checkItems == null) { 314 | return ticketIds; 315 | } 316 | for (let i = 0; i < checklist.checkItems.length; i += 1) { 317 | const checkItem = checklist.checkItems[i]; 318 | ticketIds.push({ 319 | checkItemId: checkItem.id, 320 | ticketId: this.getTicketIdFromCheckItemName(checkItem.name), 321 | }); 322 | } 323 | return ticketIds; 324 | }, 325 | 326 | getTicketIdFromCheckItemName(checkItemName) { 327 | if (checkItemName[0] === '#') { 328 | return checkItemName.split('#')[1]; 329 | } 330 | return parseInt(checkItemName.split('/')[5].split('-')[0], 10); 331 | }, 332 | 333 | getOrCreateDependencyChecklist(card) { 334 | return new Promise((resolve) => { 335 | window.Trello.get(`/cards/${card.id}/checklists`).then((checklists) => { 336 | for (let k = 0; k < checklists.length; k += 1) { 337 | if (checklists[k].name === 'Dependencies') { 338 | return resolve(checklists[k]); 339 | } 340 | } 341 | const checklist = { 342 | name: 'Dependencies', 343 | idCard: card.id, 344 | }; 345 | return window.Trello.post('/checklists/', checklist).then((data) => { 346 | resolve(data); 347 | }); 348 | }); 349 | }); 350 | }, 351 | }, 352 | }); 353 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './src/main.js', 3 | mode: 'development', 4 | output: { 5 | filename: 'app.js', 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.js$/, 11 | exclude: /(node_modules)/, 12 | use: { 13 | loader: 'babel-loader', 14 | options: { 15 | presets: ['@babel/preset-env'], 16 | }, 17 | }, 18 | }, 19 | ], 20 | }, 21 | devServer: { 22 | static: './dist', 23 | }, 24 | resolve: { 25 | alias: { 26 | vue$: 'vue/dist/vue.js', 27 | }, 28 | }, 29 | }; 30 | --------------------------------------------------------------------------------