tickets.js 9.95 KB
/**
 * Freshdesk ticket plugin. Drag tickets into the diagram. Tickets are
 * updated on file open, page select and via Extras, Update Tickets.
 * 
 * Drag freshdesk tickets into the diagram. Domain must match deskDomain.freshdesk.com.
 * 
 * Use #C to configure the client as follows:
 * 
 * https://www.draw.io/?p=tickets#C%7B"ticketsConfig"%3A %7B"deskApiKey"%3A"YOUR_API_KEY"%2C"deskDomain"%3A"YOUR_DOMAIN"%7D%7D
 * 
 * Use an additional "open" variable in the config JSON to open a file after parsing as follows:
 * 
 * ...#_TICKETS%7B"ticketsConfig"%3A %7B"deskApiKey"%3A"YOUR_API_KEY"%2C"deskDomain"%3A"YOUR_DOMAIN"%7D%2C"open"%3A"ID_WITH_PREFIX"%7D
 * 
 * Required JSON parameters:
 * - deskApiKey=api_key (see user profile)
 * - deskDomain=subdomain (subdomain.freshdesk.com)
 * 
 * Optional JSON parameters:
 * - deskStatus: Lookup for status codes (code => string)
 * - deskTypes: Lookup for ticket types (string => story, task, subTask, feature,
 * bug, techTask, epic, improvement, fault, change, access, purchase or itHelp)
 * 
 * The current configuration is stored in localStorage under ".tickets-config". Use
 * https://jgraph.github.io/drawio-tools/tools/convert.html for URI encoding.
 */
Draw.loadPlugin(function(ui)
{
	var config = null;
	var deskDomain = null;
	var deskApiKey = null;
	var graph = ui.editor.graph;
	
	var deskPriority = {'1': 'minor', '2': 'major',
		'3': 'critical', '4': 'blocker'};
	var deskTypes = {'Question': 'story', 'Incident': 'techTask', 'Problem': 'fault',
		'Feature Request': 'feature', 'Lead': 'purchase'};
	var deskStatus = {'2': 'Open', '3': 'Pending', '4': 'Resolved', '5': 'Closed',
		'6': 'Waiting on Customer', '7': 'Waiting on Third Party',
		'8': 'Resolved Internally'};
	var deskStatusWidth = {};
	
	function configure()
	{
		deskDomain = 'https://' + config.deskDomain + '.freshdesk.com';
		deskApiKey = config.deskApiKey;
		
		deskTypes = config.deskTypes || deskTypes;
		deskStatus = config.deskStatus || deskStatus;
		deskStatusWidth = {};

		// Precomputes text widths for custom ticket status
		var div = document.createElement('div');
		div.style.fontFamily = 'Arial,Helvetica';
		div.style.visibility = 'hidden';
		div.style.position = 'absolute';
		div.style.fontSize = '11px';
		
		document.body.appendChild(div);
		
		for (var key in deskStatus)
		{
			div.innerText = '';
			mxUtils.write(div, deskStatus[key]);
			deskStatusWidth[key] = div.clientWidth + 4;
		}

		document.body.removeChild(div);
	};
	
	if (window.location.hash != null && window.location.hash.substring(0, 9) == '#_TICKETS')
	{
		try
		{
			var temp = JSON.parse(decodeURIComponent(
				window.location.hash.substring(9)));
			
			if (temp != null && temp.ticketsConfig != null)
			{
				config = temp.ticketsConfig;
				configure();
				ui.fileLoaded(new LocalFile(ui, ui.emptyDiagramXml, this.defaultFilename, true));
				ui.editor.setStatus('Drag tickets from <a href="' + mxUtils.htmlEntities(deskDomain) +
					'/a/tickets/filters/all_tickets" target="_blank">' +
					mxUtils.htmlEntities(deskDomain) + '</a>');
			}
		}
		catch (e)
		{
			console.error(e);
		}
	}

	function isDeskLink(link)
	{
		if (deskDomain != null)
		{
			var dl = deskDomain.length;
			
			return config != null && link.substring(0, dl) == deskDomain &&
				(link.substring(dl, dl + 18) == '/helpdesk/tickets/' ||
				link.substring(dl, dl + 11) == '/a/tickets/');
		}
		else
		{
			return false;
		}
	};
	
	function getIdForDeskLink(link)
	{
		return link.substring(link.lastIndexOf('/') + 1);
	};
	
	function getDeskTicket(id, fn)
	{
		var xhr = new XMLHttpRequest();
		xhr.open('GET', deskDomain + '/api/v2/tickets/' + id);
		xhr.setRequestHeader('Authorization', 'Basic ' + btoa(deskApiKey + ':x'));

		xhr.onload = function ()
		{
			if (xhr.status >= 200 && xhr.status <= 299)
			{
				fn(JSON.parse(xhr.responseText), xhr);
			}
			else
			{
				fn(null, xhr);
			}
		};
		
		xhr.onerror = function ()
		{
			fn(null, xhr);
		};

		xhr.send();
	};
	
	function updateStyle(cell, ticket)
	{
		var type = (ticket.type != null) ? deskTypes[ticket.type] : 'bug';
		var status = deskStatus[ticket.status] || 'Unknown';
		var priority = deskPriority[ticket.priority];
		var sw = deskStatusWidth[ticket.status];
		var prev = cell.style;
		
		cell.style = mxUtils.setStyle(cell.style, 'issueType', type);
		cell.style = mxUtils.setStyle(cell.style, 'issueStatus', status);
		cell.style = mxUtils.setStyle(cell.style, 'issueStatusWidth', sw);
		cell.style = mxUtils.setStyle(cell.style, 'issuePriority', priority);
		
		return prev != cell.style;
	};
	
	function shortString(s, max)
	{
		if (s.length > max)
		{
			return s.substring(0, max) + '...';
		}
		else
		{
			return s;
		}
	}
	
	function updateData(cell, ticket)
	{
		var changed = false;
		
		function setAttr(key, value)
		{
			var prev = cell.value.getAttribute(key);
			value = value || '';
			
			if (prev != value)
			{
				cell.value.setAttribute(key, value);
				
				return true;
			}
			else
			{
				return false;
			}
		};
		
		changed = setAttr('abstract', shortString(ticket.description_text, 600)) |
			setAttr('email_config_id', ticket.email_config_id) |
			setAttr('requester_id', ticket.requester_id) |
			setAttr('group_id', ticket.group_id) |
			setAttr('created_at', ticket.created_at) |
			setAttr('updated_at', ticket.updated_at) |
			setAttr('due_by', ticket.due_by) |
			setAttr('tags', ticket.tags.join(' '));
		
		for (var key in ticket.custom_fields)
		{
			changed = changed | setAttr(key, ticket.custom_fields[key]);
		}

		return changed;
	};
	
	function updateTickets(spin)
	{
		if (config != null && (!spin || ui.spinner.spin(document.body, mxResources.get('loading') + '...')))
		{
			var validate = false;
			var pending = 0;
			
			graph.view.states.visit(function(id, state)
			{
				var link = graph.getLinkForCell(state.cell);
				
				if (link != null && isDeskLink(link))
				{
					var id = getIdForDeskLink(link);
					pending++;
					
					getDeskTicket(id, function(ticket, req)
					{
						pending--;
						
						if (ticket != null)
						{
							// Expression must execute both calls
							if (updateStyle(state.cell, ticket) |
								updateData(state.cell, ticket))
							{
								graph.view.invalidate(state.cell, true, false);
								state.style = null;
								validate = true;
							}
						}
						
						if (pending == 0)
						{
							if (spin)
							{
								ui.spinner.stop();
							}
							
							if (validate)
							{
								graph.view.validate();
							}
						}
					})
				}
			});
			
			if (spin && pending == 0)
			{
				ui.spinner.stop();
			}
		}
	};
	
	function getCellForLink(link)
	{
		for (var key in graph.view.states.map)
		{
			var cell = graph.view.states.map[key].cell;
			
			if (link == graph.getLinkForCell(cell))
			{
				return cell;
			}
		}
	};

	// Adds resource for action
	mxResources.parse('updateTickets=Update Tickets...');

	// Adds action
	ui.actions.addAction('updateTickets', function()
	{
		updateTickets(true);
	});
	
	// Updates tickets in opened files
	ui.editor.addListener('fileLoaded', function()
	{
		updateTickets(false);
	});

	// Updates tickets when page changes
	ui.editor.addListener('pageSelected', function()
	{
		updateTickets(false);
	});

	// Adds menu item
	var menu = ui.menus.get('extras');
	var oldFunct = menu.funct;
	
	menu.funct = function(menu, parent)
	{
		oldFunct.apply(this, arguments);
		
		ui.menus.addMenuItems(menu, ['-', 'updateTickets'], parent);
	};

	// Intercepts ticket URLs
	var uiInsertTextAt = ui.insertTextAt;
	
	ui.insertTextAt = function(text, dx, dy, html, asImage, crop, resizeImages)
	{
		if (isDeskLink(text))
		{
			var cell = getCellForLink(text);
			
			if (cell != null)
			{
				// Selects existing ticket with same link
				graph.setSelectionCell(cell);
				graph.scrollCellToVisible(graph.getSelectionCell());
			}
			else if (ui.spinner.spin(document.body, mxResources.get('loading') + '...'))
			{
				// Creates new shape
				var id = getIdForDeskLink(text);
				
				getDeskTicket(id, function(ticket, req)
				{
					ui.spinner.stop();
					
					if (ticket != null)
					{
						var cell = null;
						
				    	graph.getModel().beginUpdate();
				    	try
				    	{
				    		cell = graph.insertVertex(graph.getDefaultParent(), null,
				    			'%title%\n\n<b>Updated:</b> %updated_at% ' +
				    			'(<a href="' + deskDomain + '/contacts/%requester_id%">From</a>)',
								graph.snap(dx), graph.snap(dy), 200, 50,
								'html=1;whiteSpace=wrap;overflow=hidden;shape=mxgraph.atlassian.issue;' +
								'fontSize=12;verticalAlign=top;align=left;spacingTop=25;' +
								'strokeColor=#A8ADB0;fillColor=#EEEEEE;backgroundOutline=1;');
				    		
				    		graph.setLinkForCell(cell, text);
				    		cell.value.setAttribute('title', shortString(ticket.subject, 40));
				    		cell.value.setAttribute('subject', ticket.subject);
							cell.value.setAttribute('placeholders', '1');
							cell.value.setAttribute('ticket_id', id);
				    		updateData(cell, ticket);
							updateStyle(cell, ticket);

				    		// Adds ticket ID label
				    		var label1 = new mxCell('%ticket_id%', new mxGeometry(0, 0, 60, 20),
						   		'strokeColor=none;fillColor=none;part=1;resizable=0;align=left;' +
						   		'autosize=1;points=[];deletable=0;editable=0;connectable=0;');
						   	graph.setAttributeForCell(label1, 'placeholders', '1');
						   	label1.geometry.relative = true;
						   	label1.geometry.offset = new mxPoint(20, 0);
						   	label1.vertex = true;
						   	cell.insert(label1);
				    		
				    		graph.updateCellSize(cell);
				    		cell.geometry.width = Math.max(220, cell.geometry.width);
				    		cell.geometry.height += 10;
				    	}
				    	finally
				    	{
				    		graph.getModel().endUpdate();
				    	}
				    	
						graph.setSelectionCell(cell);
					}
					else
					{
						var err = req.status
						
						try
						{
							err = JSON.parse(req.responseText);
						}
						catch (e)
						{
							// ignore
						}
						
						ui.handleError({message: err.message});
					}
				});
			}
			
	    	return null;
		}
		else
		{
			return uiInsertTextAt.apply(this, arguments);
		}
	};
});