Select List Conversions
Write a behaviour that converts a text field to a select or a multi-select field, on initialization of the form.
You can also specify the available options for the field, including a picker function that performs as-you-type searching.
For example:
- Allow the Jira user to select any GitHub or Bitbucket repository.
- Require a link to another Jira issue where they can only pick from a constrained list.
- Require a link to a remote Jira issue, for instance, constraining the remote issue to just those of a Support Request type.
- Pick from a list of customers, where the customer information comes from a CRM database or REST service.
- Pick from a list of Confluence spaces.
- The ability to rename options.
- The ability to disable options.
Walkthrough - pick from Jira issues
Link a text field to display a searchable list of issues that come from a JQL query. For example, you want to enforce the association of an end-user Incident issue type with a Root Cause issue type.
Walkthrough - external REST service
Create and test endpoint
This example deals with picking from a list that comes from an external provider, in this case GitHub.
The browser cannot make requests directly to the GitHub REST API due to anti-XSS measures, so we will write a REST endpoint that will effectively proxy the request on to GitHub. We will need to manipulate the request and response slightly.
If this is working properly, you can move on to hooking it up to a text field.
Behaviour
As in the previous example, create a behaviour and set the initializer code to:
getFieldByName("TextFieldB").convertToMultiSelect([ // <1>
ajaxOptions: [
url : getBaseUrl() + "/rest/scriptrunner/latest/custom/githubRepoQuery", // <2>
query : true, // keep going back to the sever for each keystroke
minQueryLength: 4, // <3>
keyInputPeriod: 500, // <4>
formatResponse: "general", // <5>
]
])Line 1: Concert a custom field called TextFieldB, this time to a multiselect
Line 3: The URL of the endpoint that we tested above
Line 5: Don't make any query until the user has typed four characters
Line 6: Wait 500ms after the user has stopped typing before looking for repos
Line 7: When showing arbitrary, non-issue data, this must be "general"
Testing should produce something similar to:
Walkthrough - choose from a database table
In this example we'll configure the picker to read from a database query. You would use this if you can get read-only access to the target database and there is no suitable REST API. For example, allowing the selection of a customer name where the customer list is in a CRM database.
The example worked through here reads the JiraEventType table from the current Jira. This is pointless, but is used because it's something you will be able to test with, and is almost identical to querying any other database.
Walkthrough - pick issue from remote Jira
This example demonstrates linking a remote Jira issue with the current issue, but where the remote issue must match a JQL query (performed on the remote instance).
You might use this if you have an internal Jira and a customer-facing Jira, and you want to enforce selecting a remote issue which has the same Customer as on the internal instance. For the walkthrough, we use Atlassian's public-facing Jira instance, and restrict the remote issue list to issues affecting Bitbucket Server with the Enterprise component.
Pick Confluence space
This example uses the Confluence remote API to search for a space.
Note that the searching uses the same logic as when you search in the Confluence space directory, and searches on space name, description and label. You need to type a complete word from any one of these to get the correct results.
REST endpoint code:
This script is compatible with ScriptRunner version 10.x and above.
import com.atlassian.applinks.api.ApplicationLink
import com.atlassian.applinks.api.ApplicationLinkService
import com.atlassian.applinks.api.application.confluence.ConfluenceApplicationType
import com.atlassian.sal.api.component.ComponentLocator
import com.atlassian.sal.api.net.Request
import com.atlassian.sal.api.net.Response as SalResponse
import com.atlassian.sal.api.net.ResponseException
import com.atlassian.sal.api.net.ResponseHandler
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import groovy.transform.BaseScript
import org.apache.commons.lang3.StringUtils
import jakarta.ws.rs.core.MultivaluedMap
import jakarta.ws.rs.core.Response
@BaseScript CustomEndpointDelegate delegate
listConfluenceSpaces { MultivaluedMap queryParams ->
def query = queryParams.getFirst("query") as String
def applicationLinkService = ComponentLocator.getComponent(ApplicationLinkService)
def ApplicationLink confluenceLink = applicationLinkService.getPrimaryApplicationLink(ConfluenceApplicationType)
assert confluenceLink
def authenticatedRequestFactory = confluenceLink.createImpersonatingAuthenticatedRequestFactory()
def confResponse = null
authenticatedRequestFactory
.createRequest(Request.MethodType.GET, "rest/spacedirectory/1/search.json?query=${URLEncoder.encode(query)}&type=global&status=current")
.addHeader("Content-Type", "application/json")
.execute(new ResponseHandler<SalResponse>() {
@Override
void handle(SalResponse response) throws ResponseException {
confResponse = new JsonSlurper().parse(response.getResponseBodyAsStream())
}
})
def rt = [
items : confResponse.spaces.collect { space ->
def html = "${space.key} (${space.name})"
if (query) {
html = html.replaceAll(/(?i)$query/) { "<b>${it}</b>" }
}
[
value: space.label,
html : html,
label: space.key,
icon : space.logo.href,
]
},
total : confResponse.totalSize,
footer: "Choose Confluence space...",
]
return Response.ok(new JsonBuilder(rt).toString()).build()
}This script is compatible with ScriptRunner versions 8.x to 9.x.
import com.atlassian.applinks.api.ApplicationLink
import com.atlassian.applinks.api.ApplicationLinkService
import com.atlassian.applinks.api.application.confluence.ConfluenceApplicationType
import com.atlassian.sal.api.component.ComponentLocator
import com.atlassian.sal.api.net.Request
import com.atlassian.sal.api.net.Response as SalResponse
import com.atlassian.sal.api.net.ResponseException
import com.atlassian.sal.api.net.ResponseHandler
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import groovy.transform.BaseScript
import org.apache.commons.lang3.StringUtils
import javax.ws.rs.core.MultivaluedMap
import javax.ws.rs.core.Response
@BaseScript CustomEndpointDelegate delegate
listConfluenceSpaces { MultivaluedMap queryParams ->
def query = queryParams.getFirst("query") as String
def applicationLinkService = ComponentLocator.getComponent(ApplicationLinkService)
def ApplicationLink confluenceLink = applicationLinkService.getPrimaryApplicationLink(ConfluenceApplicationType)
assert confluenceLink
def authenticatedRequestFactory = confluenceLink.createImpersonatingAuthenticatedRequestFactory()
def confResponse = null
authenticatedRequestFactory
.createRequest(Request.MethodType.GET, "rest/spacedirectory/1/search.json?query=${URLEncoder.encode(query)}&type=global&status=current")
.addHeader("Content-Type", "application/json")
.execute(new ResponseHandler<SalResponse>() {
@Override
void handle(SalResponse response) throws ResponseException {
confResponse = new JsonSlurper().parse(response.getResponseBodyAsStream())
}
})
def rt = [
items : confResponse.spaces.collect { space ->
def html = "${space.key} (${space.name})"
if (query) {
html = html.replaceAll(/(?i)$query/) { "<b>${it}</b>" }
}
[
value: space.label,
html : html,
label: space.key,
icon : space.logo.href,
]
},
total : confResponse.totalSize,
footer: "Choose Confluence space...",
]
return Response.ok(new JsonBuilder(rt).toString()).build()
}Behaviour initializer code:
getFieldByName("TextFieldE").convertToMultiSelect([
ajaxOptions: [
url : getBaseUrl() + "/rest/scriptrunner/latest/custom/listConfluenceSpaces",
query : true,
formatResponse: "general"
]
])Should result in something like:
Dynamically changing the picker query
In the first example we saw how you could turn a text field into a dropdown that allowed users to pick an issue from the results of a JQL query that we set once, in the initializer.
Great, but what if the validation query should be formed based on other inputs? That is to say, what if you needed the user to link to an issue from different JQL queries, depending on what other information was present on the form?
In this simple example we will work through, the form has a project-picker custom field, and a text field that will take the value of an issue the user selects. The JQL query needs to be of the form project = <selectedProject> and ….
In our example, the project picker has the name ProjectPicker, and the field that will be converted to an issue-picking single select has the name TextFieldA.
To do this, we don't use an initializer - instead we add an on change server-side script that will convert TextFieldA to a single-select issue picker - the JQL query will be formed from the value of the ProjectPicker field.
Add code similar to this to the project picker field, or whatever are the inputs that should drive the JQL query:
def selectedProject = getFieldById(getFieldChanged()).value as Project
def jqlSearchField = getFieldByName("TextFieldA")
if (selectedProject) {
jqlSearchField.setReadOnly(false).setDescription("Select an issue in the ${selectedProject.name} project")
jqlSearchField.convertToSingleSelect([
ajaxOptions: [
url : getBaseUrl() + "/rest/scriptrunner-jira/latest/issue/picker",
query : true,
data : [
currentJql: "project = ${selectedProject.key} ORDER BY key ASC", // <1>
label : "Pick high priority issue in ${selectedProject.name} project",
],
formatResponse: "issue"
],
css : "max-width: 500px; width: 500px",
])
} else {
// selected project was null - disable control
jqlSearchField.convertToShortText()
jqlSearchField.setReadOnly(true).setDescription("Please select a project before entering the issue")
}Line 13: Build JQL query based on other field inputs
You may notice the value of the issue picker is not cleared if it becomes invalid for the new JQL query. The best solution to this is to add an on change validator for the issue picker field. Alternatively, you could add a workflow validator if this is on a workflow function.
So, when the project picker is changed and it makes the selected issue invalid, you will see:
This is done by adding the following server-side script for the issue picker field, in our case called TextFieldA:
def selectedIssueField = getFieldById(getFieldChanged())
def selectedIssue = selectedIssueField.value as String
log.debug("selectedIssue changed: ${selectedIssue}")
def selectedProject = getFieldByName("ProjectPicker").value as Project
if (selectedIssue && selectedProject) {
def jqlQueryBuilder = JqlQueryBuilder.newBuilder()
def searchService = ComponentAccessor.getComponent(SearchService)
def user = ComponentAccessor.jiraAuthenticationContext.getLoggedInUser()
def query = jqlQueryBuilder.where().project(selectedProject.id).and().issue(selectedIssue).buildQuery() // <1>
if (searchService.searchCount(user, query) == 1) { // <2>
selectedIssueField.clearError()
} else {
selectedIssueField.setError("Issue not found in the selected project")
}
}Line 12: Build a query corresponding to that used for the picker
Line 13: Check the currently selected issue is found in that query