1/**
2 * Freshdesk ticket plugin. Drag tickets into the diagram. Tickets are
3 * updated on file open, page select and via Extras, Update Tickets.
4 *
5 * Drag freshdesk tickets into the diagram. Domain must match deskDomain.freshdesk.com.
6 *
7 * Use #C to configure the client as follows:
8 *
9 * https://www.draw.io/?p=tickets#C%7B"ticketsConfig"%3A %7B"deskApiKey"%3A"YOUR_API_KEY"%2C"deskDomain"%3A"YOUR_DOMAIN"%7D%7D
10 *
11 * Use an additional "open" variable in the config JSON to open a file after parsing as follows:
12 *
13 * ...#_TICKETS%7B"ticketsConfig"%3A %7B"deskApiKey"%3A"YOUR_API_KEY"%2C"deskDomain"%3A"YOUR_DOMAIN"%7D%2C"open"%3A"ID_WITH_PREFIX"%7D
14 *
15 * Required JSON parameters:
16 * - deskApiKey=api_key (see user profile)
17 * - deskDomain=subdomain (subdomain.freshdesk.com)
18 *
19 * Optional JSON parameters:
20 * - deskStatus: Lookup for status codes (code => string)
21 * - deskTypes: Lookup for ticket types (string => story, task, subTask, feature,
22 * bug, techTask, epic, improvement, fault, change, access, purchase or itHelp)
23 *
24 * The current configuration is stored in localStorage under ".tickets-config". Use
25 * https://jgraph.github.io/drawio-tools/tools/convert.html for URI encoding.
26 */
27Draw.loadPlugin(function(ui)
28{
29	var config = null;
30	var deskDomain = null;
31	var deskApiKey = null;
32	var graph = ui.editor.graph;
33
34	var deskPriority = {'1': 'minor', '2': 'major',
35		'3': 'critical', '4': 'blocker'};
36	var deskTypes = {'Question': 'story', 'Incident': 'techTask', 'Problem': 'fault',
37		'Feature Request': 'feature', 'Lead': 'purchase'};
38	var deskStatus = {'2': 'Open', '3': 'Pending', '4': 'Resolved', '5': 'Closed',
39		'6': 'Waiting on Customer', '7': 'Waiting on Third Party',
40		'8': 'Resolved Internally'};
41	var deskStatusWidth = {};
42
43	function configure()
44	{
45		deskDomain = 'https://' + config.deskDomain + '.freshdesk.com';
46		deskApiKey = config.deskApiKey;
47
48		deskTypes = config.deskTypes || deskTypes;
49		deskStatus = config.deskStatus || deskStatus;
50		deskStatusWidth = {};
51
52		// Precomputes text widths for custom ticket status
53		var div = document.createElement('div');
54		div.style.fontFamily = 'Arial,Helvetica';
55		div.style.visibility = 'hidden';
56		div.style.position = 'absolute';
57		div.style.fontSize = '11px';
58
59		document.body.appendChild(div);
60
61		for (var key in deskStatus)
62		{
63			div.innerHTML = '';
64			mxUtils.write(div, deskStatus[key]);
65			deskStatusWidth[key] = div.clientWidth + 4;
66		}
67
68		document.body.removeChild(div);
69	};
70
71	if (window.location.hash != null && window.location.hash.substring(0, 9) == '#_TICKETS')
72	{
73		try
74		{
75			var temp = JSON.parse(decodeURIComponent(
76				window.location.hash.substring(9)));
77
78			if (temp != null && temp.ticketsConfig != null)
79			{
80				config = temp.ticketsConfig;
81				configure();
82				ui.fileLoaded(new LocalFile(ui, ui.emptyDiagramXml, this.defaultFilename, true));
83				ui.editor.setStatus('Drag tickets from <a href="' + deskDomain +
84					'/a/tickets/filters/all_tickets" target="_blank">' +
85					deskDomain + '</a>');
86			}
87		}
88		catch (e)
89		{
90			console.error(e);
91		}
92	}
93
94	function isDeskLink(link)
95	{
96		if (deskDomain != null)
97		{
98			var dl = deskDomain.length;
99
100			return config != null && link.substring(0, dl) == deskDomain &&
101				(link.substring(dl, dl + 18) == '/helpdesk/tickets/' ||
102				link.substring(dl, dl + 11) == '/a/tickets/');
103		}
104		else
105		{
106			return false;
107		}
108	};
109
110	function getIdForDeskLink(link)
111	{
112		return link.substring(link.lastIndexOf('/') + 1);
113	};
114
115	function getDeskTicket(id, fn)
116	{
117		var xhr = new XMLHttpRequest();
118		xhr.open('GET', deskDomain + '/api/v2/tickets/' + id);
119		xhr.setRequestHeader('Authorization', 'Basic ' + btoa(deskApiKey + ':x'));
120
121		xhr.onload = function ()
122		{
123			if (xhr.status >= 200 && xhr.status <= 299)
124			{
125				fn(JSON.parse(xhr.responseText), xhr);
126			}
127			else
128			{
129				fn(null, xhr);
130			}
131		};
132
133		xhr.onerror = function ()
134		{
135			fn(null, xhr);
136		};
137
138		xhr.send();
139	};
140
141	function updateStyle(cell, ticket)
142	{
143		var type = (ticket.type != null) ? deskTypes[ticket.type] : 'bug';
144		var status = deskStatus[ticket.status] || 'Unknown';
145		var priority = deskPriority[ticket.priority];
146		var sw = deskStatusWidth[ticket.status];
147		var prev = cell.style;
148
149		cell.style = mxUtils.setStyle(cell.style, 'issueType', type);
150		cell.style = mxUtils.setStyle(cell.style, 'issueStatus', status);
151		cell.style = mxUtils.setStyle(cell.style, 'issueStatusWidth', sw);
152		cell.style = mxUtils.setStyle(cell.style, 'issuePriority', priority);
153
154		return prev != cell.style;
155	};
156
157	function shortString(s, max)
158	{
159		if (s.length > max)
160		{
161			return s.substring(0, max) + '...';
162		}
163		else
164		{
165			return s;
166		}
167	}
168
169	function updateData(cell, ticket)
170	{
171		var changed = false;
172
173		function setAttr(key, value)
174		{
175			var prev = cell.value.getAttribute(key);
176			value = value || '';
177
178			if (prev != value)
179			{
180				cell.value.setAttribute(key, value);
181
182				return true;
183			}
184			else
185			{
186				return false;
187			}
188		};
189
190		changed = setAttr('abstract', shortString(ticket.description_text, 600)) |
191			setAttr('email_config_id', ticket.email_config_id) |
192			setAttr('requester_id', ticket.requester_id) |
193			setAttr('group_id', ticket.group_id) |
194			setAttr('created_at', ticket.created_at) |
195			setAttr('updated_at', ticket.updated_at) |
196			setAttr('due_by', ticket.due_by) |
197			setAttr('tags', ticket.tags.join(' '));
198
199		for (var key in ticket.custom_fields)
200		{
201			changed = changed | setAttr(key, ticket.custom_fields[key]);
202		}
203
204		return changed;
205	};
206
207	function updateTickets(spin)
208	{
209		if (config != null && (!spin || ui.spinner.spin(document.body, mxResources.get('loading') + '...')))
210		{
211			var validate = false;
212			var pending = 0;
213
214			graph.view.states.visit(function(id, state)
215			{
216				var link = graph.getLinkForCell(state.cell);
217
218				if (link != null && isDeskLink(link))
219				{
220					var id = getIdForDeskLink(link);
221					pending++;
222
223					getDeskTicket(id, function(ticket, req)
224					{
225						pending--;
226
227						if (ticket != null)
228						{
229							// Expression must execute both calls
230							if (updateStyle(state.cell, ticket) |
231								updateData(state.cell, ticket))
232							{
233								graph.view.invalidate(state.cell, true, false);
234								state.style = null;
235								validate = true;
236							}
237						}
238
239						if (pending == 0)
240						{
241							if (spin)
242							{
243								ui.spinner.stop();
244							}
245
246							if (validate)
247							{
248								graph.view.validate();
249							}
250						}
251					})
252				}
253			});
254
255			if (spin && pending == 0)
256			{
257				ui.spinner.stop();
258			}
259		}
260	};
261
262	function getCellForLink(link)
263	{
264		for (var key in graph.view.states.map)
265		{
266			var cell = graph.view.states.map[key].cell;
267
268			if (link == graph.getLinkForCell(cell))
269			{
270				return cell;
271			}
272		}
273	};
274
275	// Adds resource for action
276	mxResources.parse('updateTickets=Update Tickets...');
277
278	// Adds action
279	ui.actions.addAction('updateTickets', function()
280	{
281		updateTickets(true);
282	});
283
284	// Updates tickets in opened files
285	ui.editor.addListener('fileLoaded', function()
286	{
287		updateTickets(false);
288	});
289
290	// Updates tickets when page changes
291	ui.editor.addListener('pageSelected', function()
292	{
293		updateTickets(false);
294	});
295
296	// Adds menu item
297	var menu = ui.menus.get('extras');
298	var oldFunct = menu.funct;
299
300	menu.funct = function(menu, parent)
301	{
302		oldFunct.apply(this, arguments);
303
304		ui.menus.addMenuItems(menu, ['-', 'updateTickets'], parent);
305	};
306
307	// Intercepts ticket URLs
308	var uiInsertTextAt = ui.insertTextAt;
309
310	ui.insertTextAt = function(text, dx, dy, html, asImage, crop, resizeImages)
311	{
312		if (isDeskLink(text))
313		{
314			var cell = getCellForLink(text);
315
316			if (cell != null)
317			{
318				// Selects existing ticket with same link
319				graph.setSelectionCell(cell);
320				graph.scrollCellToVisible(graph.getSelectionCell());
321			}
322			else if (ui.spinner.spin(document.body, mxResources.get('loading') + '...'))
323			{
324				// Creates new shape
325				var id = getIdForDeskLink(text);
326
327				getDeskTicket(id, function(ticket, req)
328				{
329					ui.spinner.stop();
330
331					if (ticket != null)
332					{
333						var cell = null;
334
335				    	graph.getModel().beginUpdate();
336				    	try
337				    	{
338				    		cell = graph.insertVertex(graph.getDefaultParent(), null,
339				    			'%title%\n\n<b>Updated:</b> %updated_at% ' +
340				    			'(<a href="' + deskDomain + '/contacts/%requester_id%">From</a>)',
341								graph.snap(dx), graph.snap(dy), 200, 50,
342								'html=1;whiteSpace=wrap;overflow=hidden;shape=mxgraph.atlassian.issue;' +
343								'fontSize=12;verticalAlign=top;align=left;spacingTop=25;' +
344								'strokeColor=#A8ADB0;fillColor=#EEEEEE;backgroundOutline=1;');
345
346				    		graph.setLinkForCell(cell, text);
347				    		cell.value.setAttribute('title', shortString(ticket.subject, 40));
348				    		cell.value.setAttribute('subject', ticket.subject);
349							cell.value.setAttribute('placeholders', '1');
350							cell.value.setAttribute('ticket_id', id);
351				    		updateData(cell, ticket);
352							updateStyle(cell, ticket);
353
354				    		// Adds ticket ID label
355				    		var label1 = new mxCell('%ticket_id%', new mxGeometry(0, 0, 60, 20),
356						   		'strokeColor=none;fillColor=none;part=1;resizable=0;align=left;' +
357						   		'autosize=1;points=[];deletable=0;editable=0;connectable=0;');
358						   	graph.setAttributeForCell(label1, 'placeholders', '1');
359						   	label1.geometry.relative = true;
360						   	label1.geometry.offset = new mxPoint(20, 0);
361						   	label1.vertex = true;
362						   	cell.insert(label1);
363
364				    		graph.updateCellSize(cell);
365				    		cell.geometry.width = Math.max(220, cell.geometry.width);
366				    		cell.geometry.height += 10;
367				    	}
368				    	finally
369				    	{
370				    		graph.getModel().endUpdate();
371				    	}
372
373						graph.setSelectionCell(cell);
374					}
375					else
376					{
377						var err = req.status
378
379						try
380						{
381							err = JSON.parse(req.responseText);
382						}
383						catch (e)
384						{
385							// ignore
386						}
387
388						ui.handleError({message: err.message});
389					}
390				});
391			}
392
393	    	return null;
394		}
395		else
396		{
397			return uiInsertTextAt.apply(this, arguments);
398		}
399	};
400});
401