JIRA Custom Select Field


There are many issue tracking systems. In my humble opinion, JIRA (http://www.atlassian.com) is the most sophisticated one.

Recenty, I tried to integrate a new field into the structure af an issue. Here are my requirements:

  • the content may appear inside of a select control
  • the content is pulled every time when they insert a new issue or change the existing ones
  • the content could be pretty large. Maybe 1000 items. The user should be able to filter.

As you already know JIRA offers some default field types. For further information see this documentation: http://confluence.atlassian.com/display/JIRA/Adding+a+Custom+Field

There is also a select control with configurable list of options. But what about a long list of options you don’t know when creating the field?

My fear became true. JIRA out of the box features aren’t sufficient for me. Fortunately, there is also the way to implement own plugins. So this is what I did.  Here’s my cookbook:

Go to the JIRA page and download the SDK. Follow the instructions. At the end you have a pom.xml which is very similar to this one:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.jira.customfields.impl</groupId>
	<artifactId>sophisticated-select-field</artifactId>
	<version>1.0-SNAPSHOT</version>
	<organization>
		<name>softwarechaos</name>
		<url>softwarechaos.wordpress.com</url>
	</organization>
	<name>sophisticated-select-field</name>
	<description>This is my first but very clever select field plugin for Atlassian JIRA.</description>
	<packaging>atlassian-plugin</packaging>
	<dependencies>
		<dependency>
			<groupId>com.atlassian.jira</groupId>
			<artifactId>atlassian-jira</artifactId>
			<version>${jira.version}</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.6</version>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>com.atlassian.jira</groupId>
			<artifactId>jira-func-tests</artifactId>
			<version>${jira.version}</version>
			<scope>test</scope>
		</dependency>
	</dependencies>
	<build>
		<plugins>
			<plugin>
				<groupId>com.atlassian.maven.plugins</groupId>
				<artifactId>maven-jira-plugin</artifactId>
				<version>3.7.2</version>
				<extensions>true</extensions>
				<configuration>
					<productVersion>${jira.version}</productVersion>
					<productDataVersion>${jira.data.version}</productDataVersion>
				</configuration>
			</plugin>
			<plugin>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<source>1.6</source>
					<target>1.6</target>
				</configuration>
			</plugin>
		</plugins>
	</build>
	<properties>
		<jira.version>4.4.1</jira.version>
		<jira.data.version>4.4</jira.data.version>
		<amps.version>3.7.2</amps.version>
	</properties>
</project>

Then implement your classes in src/main/java. The API of JIRA contains all default field types. Browse to the class SelectCFType that fulfills my requirements. The constructor of that class has one parameter calle optionsManager about which I assume that it’s able to manage the items (about 1000). There is also a very magical method called getVelocityParameters. Here are passed the issue, field itself (?) and field layout item.  The optionsManager is encapsulated so you can use it by this operator. Note: Apache velocity plays a crucial role in JIRA when defining howto show the content or lock some features for some users. Read more below.

The final implementation of the class looks like this:

package com.jira.customfields.impl;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.SortedSet;
import java.util.TreeSet;

import org.apache.log4j.Level;
import org.apache.log4j.Logger;
import org.dom4j.DocumentException;

import org.dom4j.io.SAXReader;
import org.xml.sax.InputSource;

import com.atlassian.jira.issue.Issue;
import com.atlassian.jira.issue.customfields.converters.SelectConverter;
import com.atlassian.jira.issue.customfields.converters.StringConverter;
import com.atlassian.jira.issue.customfields.impl.SelectCFType;
import com.atlassian.jira.issue.customfields.manager.GenericConfigManager;
import com.atlassian.jira.issue.customfields.manager.OptionsManager;
import com.atlassian.jira.issue.customfields.persistence.CustomFieldValuePersister;
import com.atlassian.jira.issue.fields.CustomField;
import com.atlassian.jira.issue.fields.config.FieldConfig;
import com.atlassian.jira.issue.fields.layout.field.FieldLayoutItem;
import com.atlassian.jira.issue.search.SearchContextImpl;
import com.atlassian.jira.issue.customfields.option.Option;

/**
 * This is a very sophisticated SELECT field
 * @author kg
 * @since 02.02.2012
 */
public class SophisticatedFieldPlugin extends SelectCFType
{
	/**
	 * Custom field specific logger
	 */
    private static final Logger log = Logger.getLogger(com.jira.customfields.impl.SophisticatedFieldPlugin.class);

    /**
     * URL to query all items
     */
    public String url;

    /**
     * options manager passed in constructor
     */
    private OptionsManager optionsManager;

	public SophisticatedFieldPlugin(
			CustomFieldValuePersister customFieldValuePersister,
			StringConverter stringConverter, SelectConverter selectConverter,
			OptionsManager optionsManager,
			GenericConfigManager genericConfigManager) {
		super(customFieldValuePersister, stringConverter, selectConverter,
				optionsManager, genericConfigManager);
		this.optionsManager = optionsManager;

		log.setLevel(Level.ALL);
		log.info("SophisticatedFieldPlugin constructor");
	}

	static <K,V extends Comparable> SortedSet<Map.Entry> entriesSortedByValues(Map map) {
	    SortedSet<Map.Entry> sortedEntries = new TreeSet<Map.Entry>(
	        new Comparator<Map.Entry>() {

	        	  private String prepare( Object o )
	        	  {
	        	    return ((String)o).toLowerCase().replace( 'ä', 'a' )
	        	                                    .replace( 'ö', 'o' )
	        	                                    .replace( 'ü', 'u' )
	        	                                    .replace( 'ß', 's' );
	        	  }
	            @Override public int compare(Map.Entry e1, Map.Entry e2) {
	                return prepare( e2.getValue() ).compareTo( prepare( e1.getValue() ) );
	            }
	        }
	    );
	    sortedEntries.addAll(map.entrySet());
	    return sortedEntries;
	}

	static boolean containsItem( List optionList, String value )
	{
		if( optionList == null ) return false;
		for( Option option : optionList )
		{
			if( option.getValue().indexOf( value ) != -1 )
				return true;
		}
		return false;
	}

	public SortedSet<Map.Entry> searchItems()
	{
		Properties properties = new Properties();
		File file = new File("/srv/jira-data/plugins/sophisticated_field_plugin.txt");
		try {
		    properties.load(new FileInputStream(file));
		    this.url = (String) properties.get("url");
		} catch (IOException e) {
			log.error( "Could not read properties file: " + file.getAbsolutePath() );
		}
		log.info("url:" + url);

		log.info("calling searchItems");
		Map m = new HashMap();
	    try
	    {
	        SAXReader saxReader = new SAXReader();
	        org.dom4j.Document d = saxReader.read( new URL(url) );

	        log.info(url);

	        List dataNodes = d.getRootElement().elements("data");

	        log.info( "dataNodes.length=" + dataNodes.size());

	        for (Iterator iterator = dataNodes.iterator(); iterator.hasNext();)
	        {
	        	org.dom4j.Element dataNode = (org.dom4j.Element) iterator.next();

	        	org.dom4j.Element idNode = dataNode.element("id");
	        	org.dom4j.Element nameNode = dataNode.element("name");	     

	        	String id = idNode.getStringValue();
	        	String name = nameNode.getStringValue();

	        	m.put(id, name);
	        }
	    }
	    catch (Exception e)
	    {
	    	log.error("searchItems exception");
	    	log.error(e.getMessage());
	        e.printStackTrace();
	    }
	    return entriesSortedByValues(m);
	}	

	@Override
	public Map getVelocityParameters(Issue issue,
            CustomField field,
            FieldLayoutItem fieldLayoutItem)
    {
		log.info("calling getVelocityParameters");

		Map parameters = super.getVelocityParameters(issue, field, fieldLayoutItem);

		FieldConfig fieldConfig = null;
		if(issue == null)
		{
			log.info("issue is null");
			fieldConfig = field.getReleventConfig(new SearchContextImpl());
		} else {
			log.info( "issue is not null" );
			log.info( "issue-id:" + issue.getId() );
			fieldConfig = field.getRelevantConfig(issue);
		}

		log.info("before search items");
		SortedSet<Map.Entry> set = searchItems();
		log.info("==> " + set.size());

		List existingOptions = optionsManager.getOptions(fieldConfig);
		log.info("EXISTING-OPTIONS: " + existingOptions.size() );
		/*
		for( Option option : existingOptions )
		{
			log.info( option.getOptionId() + " ==> " + option.getValue() );
		}
		*/

		for( Map.Entry el : set )
		{
			String key = el.getKey();
			String val = el.getValue();

			String itm = val + " [" + key + "]";

			if(containsItem( existingOptions, key ) ) {
				//log.info( itm + " found ==> continue" );
				continue;
			}
			log.warn( itm + " NOT FOUND!!!");
			Option option = optionsManager.createOption(fieldConfig, null, null, itm);
		}
		return parameters;
	}
}

Note:  the method createOption saves the new items in the backend of JIRA. You don’t have any chance to set the value (hidden value, not the text) of an item. They are managed by JIRA! As you can see the itm is a concatanation of val and key. If you need the key part of the composed value you may need some velocity skills. See below.

Okay. The class is ready to use. For troubleshooting myself the log level is activated so that I can trace the execution of my code by screening the JIRA log.

The next step is defining of velocity templates. If you need a solution in a short time you don’t have time for studying the documentations of all software of this world. So what I did was:

find / -name "*.vm"

I found the default velocity templates used by JIRA for displaying select fields. I copied them into my src/main/resources folder and made some changes.

Note: there is a plugin descriptor created by Maven in your src/main/resources directory. You have to link it with the velocity templates.

<atlassian-plugin key="${project.groupId}.${project.artifactId}" name="${project.name}" plugins-version="2">
	<plugin-info>
		<description>${project.description}</description>
		<version>${project.version}</version>
		<vendor name="software chaos" url="softwarechaos.wordpress.com"/>
	</plugin-info>
	<customfield-type key="sophisticated-select-field" name="Sophisticated Select Field">
		<description>Dropdown field filled with many items.</description>
		<resource type="velocity" name="view" location="templates/com/jira/customfields/impl/view-opportunity.vm"/>
		<resource type="velocity" name="edit" location="templates/com/jira/customfields/impl/edit-opportunity.vm"/>
		<resource type="velocity" name="xml" location="templates/com/jira/customfields/impl/xml-opportunity.vm"/>
	</customfield-type>
</atlassian-plugin>

The velocity templates look like this:

view: the original key is parsed to show a hyperlink to an external application.

#if ($value)
#set ($index1 = $value.toString().indexOf("["))
#if($index1 != -1)
	#set ($index1 = $index1 + 1)
#end
#set ($index2 = $value.toString().indexOf("]"))
	#if($index1 != -1 && $index2 != -1)
		#set ($opp = $value.toString().substring( $index1, $index2 ))
	#end

<a href="http://myurl/targetapplication?key=${opp}" target="_blank"> 
$!value.toString() 
</a>
#end

edit: I wrote some javascript code to filter the items.

#customControlHeader ($action $customField.id $customField.name $fieldLayoutItem.required $displayParameters $auiparams)

<input type="button" value="Filter" onclick='javascript:
var filterInputField = document.getElementById("inputFilter");
var f = document.getElementById("$customField.id");
function refresh() {
	var f = arguments[0];
	var v = arguments[1];
	var o = f.options;
	if( !this.__allValues ) {
		this.__allValues = {};
		for(var i=0;i=0;i--) {
		f.options[i] = null;
	}
	for(var key in this.__allValues ) {
		var value = this.__allValues[key];
		if( value.toLowerCase().indexOf( v.toLowerCase() ) != -1 ) {
			var newO = new Option(value, key, false, true);
			f.options[f.options.length] = newO;
		}
	}
};
refresh(f,filterInputField.value);'/>

    #if (!$fieldLayoutItem || $fieldLayoutItem.required == false)
        $i18n.getText("common.words.none")
    #else
        $i18n.getText("common.words.none")
    #end
    #foreach ($option in $configs.options)
        #if(!$option.disabled || $option.disabled == false || ($value && $value == $option.optionId.toString()))
            $option.value
        #end
    #end

Number of items: $configs.options.size()
#customControlFooter ($action $customField.id $fieldLayoutItem.fieldDescription $displayParameters $auiparams)

xml: this file is unchanged.

#if ($value)
    $value.toString()
#end

Package your plugin by executing “mvn clean package” and deploy it (http://confluence.atlassian.com/display/JIRA044/Managing+JIRA%27s+Plugins#ManagingJIRAsPlugins-Installingapluginmanually).

Now, you are probably curious about the result. Here is it!

Issue in the edit mode:

 

Issue in the view mode:

Advertisements

2 thoughts on “JIRA Custom Select Field

  1. When I try to write my plugin extending SelectCFType it doesn’t accept. Do i use AbstractCustomField or Abstractsinglefield for select list plugin.

    • What do you mean with “… it doesn’t accept…”?

      SelectCFType is a subclass of AbstractSingleField. And AbstractSingleField inherits from AbstractCustomField. They have the same “interface”. SelectCFType is even more specific and supposed to be used for selecting multiple values.

      My advice: please check your logfiles! JIRA provides very good logs. You can even check the reason for why your control doesn’t appear in the page. Please use log.setLevel(Level.ALL); to log every step of the control initialization.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s