├── .glitch-assets ├── _headers ├── package.json ├── server.js ├── manifest.json ├── sigma.svg ├── costello_logo.svg ├── README.md ├── index.html └── client.js /.glitch-assets: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Access-Control-Allow-Origin: * 3 | Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trello-costello", 3 | "description": "Add costs to cards & view the sum in your board bar.", 4 | "main": "server.js", 5 | "scripts": { 6 | "start": "node server.js" 7 | }, 8 | "dependencies": { 9 | "express": "^4.12.4" 10 | }, 11 | "keywords": [ 12 | "node", 13 | "express" 14 | ], 15 | "license": "MIT" 16 | } 17 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // server.js 2 | // where your node app starts 3 | 4 | // init project 5 | var express = require('express'); 6 | var app = express(); 7 | 8 | app.use(function(req, res, next) { 9 | res.header("Access-Control-Allow-Origin", "*"); 10 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); 11 | next(); 12 | }); 13 | 14 | app.use(express.static('./')); 15 | 16 | // listen for requests :) 17 | var listener = app.listen(process.env.PORT, function () { 18 | console.log('Your app is listening on port ' + listener.address().port); 19 | }); -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Costello", 3 | "details":"With the Costello Power-Up enabled, you'll be able to open a card and set a cost. That cost will be displayed on the front of the card (costs of zero are in red) and a sum of the costs will be displayed in the top right corner of your board.\n\n* Add costs to cards\n* See a summary of those costs on your board\n* Costello costs nothing!\n* [Source available on Github](https://github.com/webrender/trello-costello)\n\n### Screenshot\n\n![Costello Demo GIF](https://info.trello.com/hubfs/Power-Ups/Costello/costello_demo.gif?t=1516965722234)", 4 | "icon": { 5 | "url": "./costello_logo.svg" 6 | }, 7 | "author": "webrender", 8 | "capabilities": [ 9 | "board-buttons", 10 | "callback", 11 | "card-badges", 12 | "card-buttons", 13 | "show-settings" 14 | ], 15 | "connectors": { 16 | "iframe": { 17 | "url": "./index.html" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /sigma.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Slice 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /costello_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | trello-logo-blue 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Σ 20 | 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Costello: Costs for Trello 🚀 2 | 3 | **Update: Costello is now listed on Trello by default! You no longer need to follow the procedure below, just find it under Power-Ups in your board menu.** 4 | 5 | Hey there 👋 6 | 7 | This is a Trello Power-Up which lets you add costs to cards and provides a sum of those costs as a board item. 8 | To enable this Power-Up for your Trello boards, you'll need to go here: 9 | 10 | 👉 https://trello.com/power-ups/admin 11 | 12 | Select the Trello team you want to add the Power-Up to. Note: You need to be an admin of the Trello team to add custom Power-Ups to it. 13 | 14 | Now click the Create new Power-Up button. If this is your first time adding a Power-Up, you'll need to agree to a "Joint Developer Agreement" first. After you have done that, you just need to give your Power-Up a name, and paste this app's manifest url, https://webrender.github.io/trello-costello/manifest.json. 15 | 16 | Click Save and it's time to celebrate 🎉 🎊 17 | 18 | Now when you look at the Power-Ups for any board in that team, this Power-Up will be available. Enable it and you'll be able to set costs for cards when you open them. If you'd like to add functionality, you can fork this Glitch project and edit the source as you please. -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |

Costello: Costs for Trello 🚀

32 | 33 |

34 | Hey there 👋 35 |

36 | 37 | This is a Trello Power-Up which lets you add costs to cards and provides a sum of those costs as a board item. 38 | 39 | 40 | 41 | 42 | 43 |

44 | To enable this Power-Up for your Trello boards, you'll need to go here: 45 |

46 | 47 |

48 | 👉 https://trello.com/power-ups/admin 49 |

50 | 51 |

52 | Select the Trello team you want to add the Power-Up to. Note: You need to be an admin of the Trello team to add custom Power-Ups to it. 53 |

54 | 55 |

56 | Now click the Create new Power-Up button. If this is your first time adding a Power-Up, you'll need to agree to a "Joint Developer Agreement" first. After you have done that, you just need to give your Power-Up a name, and paste this app's manifest url, https://webrender.github.io/trello-costello/manifest.json. 57 |

58 | 59 |

60 | Click Save and it's time to celebrate 🎉 🎊 61 |

62 | 63 |

64 | Now when you look at the Power-Ups for any board in that team, this Power-Up will be available. Enable it and you'll be able to set costs for cards when you open them. If you'd like to add functionality, you can fork this Glitch project and edit the source as you please. 65 |

66 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | /* global TrelloPowerUp */ 2 | 3 | var Promise = TrelloPowerUp.Promise; 4 | 5 | var SIGMA_ICON = './sigma.svg'; 6 | 7 | var getBadges = function(t){ 8 | // we used to store costs in a board-level object, but 9 | // https://github.com/webrender/trello-costello/issues/11 10 | // reported that the length of the field could exceed the 11 | // 4,000 character limit for trello data. costs are now stored 12 | // in card level objects, and this is here to convert old board- 13 | // level costs to the new card-level objects 14 | return t.card('id') 15 | .then(function(card) { 16 | return t.get('board', 'shared', 'costs') 17 | .then(function(oldCosts) { 18 | var returnCosts = function () { 19 | return t.get('card', 'shared', 'costs') 20 | .then(function(costs){ 21 | return t.get('board', 'shared', 'costFields') 22 | .then(function(costFields){ 23 | var badges = []; 24 | if (!costFields) { 25 | // create array 26 | var newCostFields = ['Total Cost']; 27 | return t.set('board', 'shared', 'costFields', newCostFields) 28 | .then(function() { 29 | return getBadges(t); 30 | }); 31 | } 32 | if (costs) { 33 | if(Array.isArray(costs)) { 34 | costs.forEach(function(cost, idx){ 35 | if (cost) { 36 | badges.push({ 37 | text: costFields[idx] + ': ' + parseFloat(cost).toLocaleString(undefined,{minimumFractionDigits:2}), 38 | color: (cost == 0) ? 'red' : null 39 | }); 40 | } 41 | }); 42 | return badges; 43 | } else { 44 | // Initially, the card-level object used the cost title as the key, 45 | // but I realized this would cause issues when renaming titles. 46 | // costs are now stored in an array of objects, where the first object 47 | // is always the default title. 48 | var newCostArray = []; 49 | newCostArray.push(costs['Total Cost']); 50 | return t.set('card', 'shared', 'costs', newCostArray) 51 | .then(function() { 52 | return t.set('board','shared','refresh',Math.random()) 53 | .then(function() { 54 | return getBadges(t); 55 | }); 56 | }); 57 | } 58 | } else { 59 | return []; 60 | } 61 | }); 62 | }); 63 | } 64 | // oldcosts: these are legacy costs from v1 that were stored on the board-level object 65 | if (oldCosts && oldCosts[card.id]) { 66 | return t.set('card', 'shared', 'costs', [oldCosts[card.id]]) 67 | .then(function() { 68 | delete oldCosts[card.id]; 69 | return t.set('board', 'shared', 'costs', oldCosts) 70 | .then(function() { 71 | return returnCosts(); 72 | }); 73 | }) 74 | } else { 75 | return returnCosts(); 76 | } 77 | }); 78 | }); 79 | }; 80 | 81 | 82 | var getBoardButtons = function(t) { 83 | // get all the cards 84 | return t.get('board', 'shared', 'costFields') 85 | .then(function(costFields) { 86 | return t.cards('id', 'name', 'idList', 'labels') 87 | .then(function(cards) { 88 | var getCosts = []; 89 | // get all the card costs 90 | cards.forEach(function(card){ 91 | getCosts.push(t.get(card.id, 'shared', 'costs')) 92 | }); 93 | return Promise.all(getCosts) 94 | .then(function(costArray) { 95 | var sums = Array(costFields.length).fill(0); 96 | // for each card 97 | costArray.forEach(function(cardCosts) { 98 | // for each cost on the card 99 | if (cardCosts && Array.isArray(cardCosts)) { 100 | cardCosts.forEach(function(cost,idx) { 101 | if(cost) 102 | sums[idx] += parseFloat(cost); 103 | }); 104 | } 105 | }); 106 | var boardButtons = []; 107 | sums.forEach(function(sum, idx) { 108 | boardButtons.push({ 109 | icon: SIGMA_ICON, 110 | text: costFields[idx] + ': ' + parseFloat(sum).toLocaleString(undefined,{minimumFractionDigits:2}), 111 | callback: function(t) { 112 | return t.lists('id', 'name') 113 | .then(function(lists){ 114 | var entries = []; 115 | var activeIds = cards.map(function(card){return card.id;}); 116 | 117 | var summaryByColumn = function(t) { 118 | var listSums = {}; 119 | var columnEntries = []; 120 | costArray.forEach(function(cardCosts, cardIdx) { 121 | if (cardCosts && cardCosts.length > 0) { 122 | var cardId = cards[cardIdx].id; 123 | // for each active card 124 | if (activeIds.indexOf(cardId) > -1) { 125 | // if it has this cost attached 126 | if (cardCosts[idx]) { 127 | // see if listSums already has a sum under this listId 128 | if (!listSums[cards[cardIdx].idList]) { 129 | // if not create it 130 | listSums[cards[cardIdx].idList] = 0; 131 | } 132 | // add the cost to the list sum 133 | listSums[cards[cardIdx].idList] += parseFloat(cardCosts[idx]); 134 | } 135 | } 136 | } 137 | }); 138 | Object.keys(listSums).forEach(function(listId) { 139 | var listName = lists.find(function(list){return listId == list.id}).name; 140 | columnEntries.push({ 141 | text: listName + ': ' + parseFloat(listSums[listId]).toFixed(2).toLocaleString(undefined,{minimumFractionDigits:2}) 142 | }); 143 | }); 144 | return t.popup({ 145 | title: 'Summary by Column', 146 | items: columnEntries 147 | }); 148 | } 149 | 150 | var summaryByLabel = function(t) { 151 | var listSums = {}; 152 | var columnEntries = []; 153 | cards.forEach(function(card, cardIdx){ 154 | if (costArray[cardIdx] && costArray[cardIdx][idx]) { 155 | if (card.labels.length > 0) { 156 | card.labels.forEach(function(label) { 157 | var displayName = label.name || label.color; 158 | if (listSums[displayName]) { 159 | listSums[displayName] += parseFloat(costArray[cardIdx][idx]); 160 | } else { 161 | listSums[displayName] = parseFloat(costArray[cardIdx][idx]); 162 | } 163 | }); 164 | } 165 | } 166 | }); 167 | 168 | for (var listSum in listSums) { 169 | columnEntries.push({text: listSum + ': ' + parseFloat(listSums[listSum]).toFixed(2).toLocaleString(undefined,{minimumFractionDigits:2})}); 170 | } 171 | return t.popup({ 172 | title: 'Summary by Label', 173 | items: columnEntries 174 | }); 175 | } 176 | 177 | entries.push({text: '🔍 Summary by Column...', callback: summaryByColumn}); 178 | entries.push({text: '🔍 Summary by Label...', callback: summaryByLabel}); 179 | costArray.forEach(function(cardCosts, cardIdx) { 180 | if (cardCosts && cardCosts.length > 0 && cardCosts[idx]) { 181 | var cost = cards[cardIdx].id; 182 | if (activeIds.indexOf(cost) > -1) { 183 | var cb = function(a){t.showCard(a);}; 184 | entries.push({ 185 | text: parseFloat(cardCosts[idx]).toLocaleString(undefined,{minimumFractionDigits:2}) + ' - ' + cards.find(function(card){return card.id == cost;}).name, 186 | callback: cb.bind(null, cost) 187 | }); 188 | } 189 | } 190 | }); 191 | 192 | return t.popup({ 193 | title: 'Cost Summary', 194 | items: entries 195 | }); 196 | }); 197 | } 198 | }); 199 | }); 200 | return boardButtons; 201 | }); 202 | }); 203 | }); 204 | } 205 | 206 | var getSettings = function(t) { 207 | return t.get('board', 'shared', 'costFields') 208 | .then(function(costFields) { 209 | return t.popup({ 210 | title: 'Manage Cost Fields', 211 | items: function(t, options) { 212 | var buttons = [{ 213 | text: options.search !== '' ? 'Add cost field: ' + options.search : '(Enter a title to add cost field)', 214 | callback: function(t) { 215 | costFields.push(options.search); 216 | return t.set('board', 'shared', 'costFields', costFields) 217 | .then(function() { 218 | return getSettings(t); 219 | }); 220 | } 221 | }]; 222 | if (costFields && Array.isArray(costFields) && costFields.length > 0) { 223 | costFields.forEach(function(costField, idx) { 224 | buttons.push({ 225 | text: costField, 226 | callback: function(t) { 227 | return t.popup({ 228 | title: 'Set Field Name', 229 | items: function(t, subopt) { 230 | return [{ 231 | text: subopt.search !== '' ? 'Rename field to "' + subopt.search + '"': '(Enter a new name for this field.)', 232 | callback: function(t) { 233 | costFields[idx] = subopt.search; 234 | return t.set('board', 'shared', 'costFields', costFields) 235 | .then(function() { 236 | return getSettings(t); 237 | }); 238 | } 239 | }, { 240 | text: 'Delete ' + costField + ' field.', 241 | callback: function(t) { 242 | // not only do we need to delete this field from the costField array, 243 | // we also need to delete that index from any card-level costs object 244 | return t.cards('id') 245 | .then(function(cards) { 246 | var requests = []; 247 | cards.forEach(function(card) { 248 | requests.push(t.get(card.id, 'shared', 'costs')); 249 | }); 250 | return Promise.all(requests) 251 | .then(function(cardCostsArray) { 252 | if (cardCostsArray) { 253 | var updates = []; 254 | cardCostsArray.forEach(function(cardCosts, cardIdx) { 255 | if (cardCosts) { 256 | cardCosts.splice(idx, 1); 257 | } 258 | updates.push(t.set(cards[cardIdx].id, 'shared', 'costs', cardCosts)); 259 | }); 260 | if (updates) { 261 | return Promise.all(updates) 262 | .then(function() { 263 | costFields.splice(idx, 1); 264 | return t.set('board', 'shared', 'costFields', costFields) 265 | .then(function(){ 266 | return getSettings(t); 267 | }); 268 | }); 269 | } else { 270 | costFields.splice(idx, 1); 271 | return t.set('board', 'shared', 'costFields', costFields) 272 | .then(function(){ 273 | return getSettings(t); 274 | }); 275 | } 276 | } else { 277 | costFields.splice(idx, 1); 278 | return t.set('board', 'shared', 'costFields', costFields) 279 | .then(function(){ 280 | return getSettings(t); 281 | }); 282 | } 283 | }); 284 | }); 285 | } 286 | }] 287 | }, 288 | search: { 289 | placeholder: costFields[0] 290 | } 291 | }); 292 | } 293 | }); 294 | }); 295 | } 296 | buttons.push({ 297 | icon: SIGMA_ICON, 298 | text: 'Reset Cost Fields', 299 | callback: function(t) { 300 | return t.popup({ 301 | title: 'Reset Cost Fields: Are you sure?', 302 | items: function(t, options) { 303 | var buttons = [{ 304 | text: 'Yes, reset all fields.', 305 | callback: function(t) { 306 | // create array 307 | var newCostFields = ['Total Cost']; 308 | return t.set('board', 'shared', 'costFields', newCostFields) 309 | .then(function() { 310 | return t.cards('id') 311 | .then(function(cards) { 312 | var requests = [] 313 | cards.forEach(function(card) { 314 | requests.push(t.remove(card.id, 'shared', 'costs', )); 315 | }); 316 | return Promise.all(requests) 317 | .then(function(cardCostsArray) { 318 | t.closePopup(); 319 | return getBadges(t); 320 | }); 321 | }); 322 | }); 323 | } 324 | }]; 325 | return buttons; 326 | }, 327 | }); 328 | } 329 | }); 330 | return buttons; 331 | }, 332 | search: { 333 | placeholder: 'Enter new cost field', 334 | empty: 'Error', 335 | searching: 'Processing...' 336 | } 337 | }); 338 | }); 339 | } 340 | 341 | var getButtons = function(t) { 342 | return t.get('board', 'shared', 'costFields') 343 | .then(function(costFields){ 344 | return t.get('card', 'shared', 'costs') 345 | .then(function(costs){ 346 | var buttons = []; 347 | costFields.forEach(function(cost, idx){ 348 | buttons.push({ 349 | icon: SIGMA_ICON, 350 | text: costs && costs[idx] ? costFields[idx] + ': ' + parseFloat(costs[idx]).toLocaleString(undefined,{minimumFractionDigits:2}) :'Add ' + costFields[idx] + '...', 351 | callback: t.memberCanWriteToModel('card') ? function(t) { 352 | return t.popup({ 353 | title: 'Set ' + costFields[idx] + '...', 354 | items: function(t, options) { 355 | var newCost = parseFloat(options.search).toFixed(2) 356 | var buttons = [{ 357 | text: !Number.isNaN(parseFloat(options.search)) ? 'Set ' + costFields[idx] + ' to ' + parseFloat(newCost).toLocaleString(undefined,{minimumFractionDigits:2}) : '(Enter a number to set ' + costFields[idx] + '.)', 358 | callback: function(t) { 359 | if (newCost != 'NaN') { 360 | var newCosts = costs ? costs : Array(costFields.length).fill(null); 361 | newCosts[idx] = newCost; 362 | return t.set('card','shared','costs', newCosts) 363 | .then(function() { 364 | return t.set('board','shared','refresh',Math.random()) 365 | .then(function() { 366 | return t.closePopup(); 367 | }); 368 | }); 369 | } 370 | return t.closePopup(); 371 | } 372 | }]; 373 | if (costs && costs[idx]) { 374 | buttons.push({ 375 | text: 'Remove ' + costFields[idx] + '.', 376 | callback: function(t) { 377 | var newCosts = costs ? costs : Array(costFields.length).fill(null); 378 | newCosts[idx] = null; 379 | t.set('card','shared','costs', newCosts); 380 | return t.closePopup(); 381 | } 382 | }); 383 | } 384 | return buttons; 385 | }, 386 | search: { 387 | placeholder: 'Enter Cost', 388 | empty: 'Error', 389 | searching: 'Processing...' 390 | } 391 | }); 392 | } : null 393 | }); 394 | }); 395 | return buttons; 396 | }); 397 | }); 398 | } 399 | 400 | TrelloPowerUp.initialize({ 401 | 'board-buttons': function(t, options){ 402 | return getBoardButtons(t); 403 | }, 404 | 'card-badges': function(t, options){ 405 | return getBadges(t); 406 | }, 407 | 'card-buttons': function(t, options) { 408 | return getButtons(t); 409 | }, 410 | 'show-settings': function(t, options) { 411 | return getSettings(t); 412 | } 413 | }); 414 | --------------------------------------------------------------------------------