├── .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 |
26 |
27 |
28 |
34 | Connect to Trello
35 |
36 |
42 | Refresh
43 |
44 |
45 |
46 |
52 |
53 | Choose a board
54 |
55 |
56 | {{ board.name }}
57 |
58 |
59 |
60 |
61 |
67 |
68 | Filter by column
69 |
70 |
71 | {{ list.name }}
72 |
73 |
74 |
75 |
76 |
82 |
83 | Filter by label
84 |
85 |
86 | {{ label.name }}
87 |
88 |
89 |
90 |
99 |
100 |
101 |
102 |
103 |
124 |
125 |
135 |
139 | Load Json
140 |
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 |
--------------------------------------------------------------------------------