Create
cancel
Showing results for 
Search instead for 
Did you mean: 
Sign up Log in

Next challenges

Recent achievements

  • Global
  • Personal

Recognition

  • Give kudos
  • Received
  • Given

Leaderboard

  • Global

Trophy case

Kudos (beta program)

Kudos logo

You've been invited into the Kudos (beta program) private group. Chat with others in the program, or give feedback to Atlassian.

View group

It's not the same without you

Join the community to find out what other Atlassian users are discussing, debating and creating.

Atlassian Community Hero Image Collage

REST Endpoint: Iterate over Json map and parse to HTML table Edited

Hi all,

Based on my previous post, I want to be able to parse JSON data to an html table from data pulled from an external API, which in our case is our ServiceNow instance.

The REST Endpoint Code is as follows:

import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.xml.MarkupBuilder
import groovy.transform.BaseScript

import groovyx.net.http.ContentType
import groovyx.net.http.HTTPBuilder
import groovyx.net.http.Method

import javax.ws.rs.core.MultivaluedMap
import javax.ws.rs.core.Response
import javax.ws.rs.core.MediaType



@BaseScript CustomEndpointDelegate delegate2

CMRDisplay(httpMethod: "GET") { MultivaluedMap queryParams ->

    def query = queryParams.getFirst("query") as String

    def rt = [:]
    if (query) {

      String url = "https://test.service-now.com"
      String uriPath = "/api/now/table/u_jira_change_data"

      HTTPBuilder http = new HTTPBuilder(url)

      def output = http.request(Method.GET, ContentType.JSON) {
        uri.path = uriPath
        uri.query = [sysparm_query:"u_jira_ticket_number=$query", sysparm_fields:"u_change_record.number,u_change_record.short_description,u_change_record.state", sysparm_display_value: "true"]
headers.'Authorization' = "Basic ${"xxxxxxxx".bytes.encodeBase64().toString()}"

    response.failure = { resp, reader ->
            log.warn("Failed to query ServiceNow API: " + reader.text)
         }
      }

    def cmrState = output["result"]*."u_change_record.state"
    def cmrNumber = output["result"]*."u_change_record.number"
    def cmrDesc = output["result"]*."u_change_record.short_description"

    def table =
"""<table class="aui aui-table-list" aria-hidden="true">
<thead>
<tr>
<th id="record-num">Record Number</th>
<th id="record-desc">Description</th>
<th id="record-state">State</th>
</tr>
</thead>
<tbody>
<tr class="aui-row-subtle">
<td headers="record-num">${cmrNumber}</td>
<td headers="record-desc">${cmrDesc}</td>
<td headers="record-state">${cmrState}</td>
</tr>
</tbody>
</table>
"""
  Response.ok().type(MediaType.TEXT_HTML).entity(table.toString()).build()
  }

}

Working with a test issue in our sandbox instance, we get the following JSON data:

{
"result": [
{
"u_change_record.number":"CHG0010042",
"u_change_record.state":"Draft",
"u_change_record.short_description":"test app req 5"
},
{
"u_change_record.number":"CHG0010061",
"u_change_record.state":"Draft",
"u_change_record.short_description":"test"
},
{
"u_change_record.number":"CHG0016010",
"u_change_record.state":"Draft",
"u_change_record.short_description":"Test Jira"
},
{
"u_change_record.number":"CHG0010057",
"u_change_record.state":"Draft",
"u_change_record.short_description":"tesst"
}
]
}

And the REST Endpoint code renders the json into the following. html table:

Screen Shot 2021-02-18 at 3.45.37 PM.png

My only problem now is that the rows don't generate as expected. Obviously, there should be 4 rows in total, with [CMR1..., test app req 5, Draft] in one row, then [CMR2..., test, Draft] as the second row, and so on. But instead, the output results are being crammed into one row.

I want the table to look something like this, but styled with css to resemble a Jira table:

Screen Shot 2021-02-17 at 9.55.52 AM.png

 

Additionally, the css class attributes don't take effect for some reason. Referencing Jira's AUI Documentation, I figured that the CSS would automatically apply when referencing the 'aui' class in for the table.

I know I have to iterate through the JSON Map somehow, but I don't know how to map that out to the html table and have each record number/recrod desc/record state set listed out as rows on the table.

 

Any tips/suggestions as always are highly appreciated!

 

1 answer

1 accepted

0 votes
Answer accepted

I would recommend you become familiar with groovy's markup builder

Here is something I quickly put together to in the Scriptrunner Console

import groovy.json.JsonSlurper
import groovy.xml.MarkupBuilder
def jsonString ="""{"result": [{
"u_change_record.number":"CHG0010042",
"u_change_record.state":"Draft",
"u_change_record.short_description":"test app req 5"
},{
"u_change_record.number":"CHG0010061",
"u_change_record.state":"Draft",
"u_change_record.short_description":"test"
},{
"u_change_record.number":"CHG0016010",
"u_change_record.state":"Draft",
"u_change_record.short_description":"Test Jira"
},{
"u_change_record.number":"CHG0010057",
"u_change_record.state":"Draft",
"u_change_record.short_description":"tesst"
}]}"""

def json = new JsonSlurper().parseText(jsonString)
def writer = new StringWriter()
def xml = new MarkupBuilder(writer)
xml.table(class:'aui'){
thead{
tr{
json.result[0].keySet().each{
th it.split(/\./)[1]
}
}
}
tbody{
json.result.each{ res->
tr{
res.values().each{
td it
}
}
}
}
}
writer.toString()

That generated a table that looks like this:

2021-02-18 15_54_12-Script Console.png

Hi @Peter-Dave Sheehan ,

Thanks for the reply! I'll be sure to further reference the XML Builder. Hopefully I can use it in this case.

So I tried using the the code you provided and integrating it with the REST Endpoint code. I do believe this is close to the solution I am looking for. The only thing, however, is that I think the REST Endpoint code has to return a javax.ws.rs.core.Response object. 

This is how I modified the code to include yours:

import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.xml.MarkupBuilder
import groovy.transform.BaseScript

import groovyx.net.http.ContentType
import groovyx.net.http.HTTPBuilder
import groovyx.net.http.Method

import javax.ws.rs.core.MultivaluedMap
import javax.ws.rs.core.Response
import javax.ws.rs.core.MediaType

@BaseScript CustomEndpointDelegate delegate2

CMRDisplay(httpMethod: "GET") { MultivaluedMap queryParams ->

    def query = queryParams.getFirst("query") as String

    def rt = [:]
    if (query) {

      String url = "https://test.service-now.com"
      String uriPath = "/api/now/table/u_jira_change_data"

      HTTPBuilder http = new HTTPBuilder(url)

      def output = http.request(Method.GET, ContentType.JSON) {
        uri.path = uriPath
        uri.query = [sysparm_query:"u_jira_ticket_number=$query",
sysparm_fields:"u_change_record.number,u_change_record.short_description,u_change_record.state",
sysparm_display_value: "true"]
headers.'Authorization' = "Basic ${"xxxxxxxx".bytes.encodeBase64().toString()}"

    response.failure = { resp, reader ->
            log.warn("Failed to query ServiceNow API: " + reader.text)
         }
      }

    def cmrState = output["result"]*."u_change_record.state"
    def cmrNumber = output["result"]*."u_change_record.number"
    def cmrDesc = output["result"]*."u_change_record.short_description"

rt = output
def json = new groovy.json.JsonBuilder()

json rootKey: rt

def jsonString = rt.toString()
def jsonOutput = new JsonSlurper().parseText(jsonString)
def writer = new StringWriter()
def xml = new MarkupBuilder(writer)
xml.table(class:'aui'){
thead{
tr{
jsonOutput.result[0].keySet().each{
th it.split(/\./)[1]
}
}
}
tbody{
jsonOutput.result.each{ res->
tr{
res.values().each{
td it
}
}
}
}
}
Response.ok().type(MediaType.TEXT_HTML).entity(writer.toString()).build()
}
}

 

Upon testing this endpoint, unfortunately it didn't produce the table but rather this String message:

 

{"message":"expecting '}' or ',' but got current char 'r' with an int value of 114\n\nThe current
character read is 'r' with an int value of 114\nexpecting '}' or ',' but got current char 'r' with an
int value of 114\nline number 1\nindex number 1\n{result=[{u_change_record.number=CHG0010042,
u_change_record.state=Draft, u_change_record.short_description=test app req 5},
{u_change_record.number=CHG0010061, u_change_record.state=Draft,
u_change_record.short_description=test}, {u_change_record.number=CHG0016010,
u_change_record.state=Draft, u_change_record.short_description=Test Jira},
{u_change_record.number=CHG0010057, u_change_record.state=Draft,
u_change_record.short_description=tesst}]}\n.^","stack-trace":"groovy.json.JsonException: expecting '}'
or ',' but got current char 'r' with an int value of 114\n\nThe current character read is 'r' with an
int value of 114\nexpecting '}' or ',' but got current char 'r' with an int value of 114\nline number
1\nindex number 1\n{result=[{u_change_record.number=CHG0010042, u_change_record.state=Draft,
u_change_record.short_description=test app req 5}, {u_change_record.number=CHG0010061,
u_change_record.state=Draft, u_change_record.short_description=test},
{u_change_record.number=CHG0016010, u_change_record.state=Draft, u_change_record.short_description=Test
Jira}, {u_change_record.number=CHG0010057, u_change_record.state=Draft,
u_change_record.short_description=tesst}]}\n.^\n\tat
org.apache.groovy.json.internal.JsonParserCharArray.complain(JsonParserCharArray.java:149)\n\tat
org.apache.groovy.json.internal.JsonParserCharArray.decodeJsonObject(JsonParserCharArray.java:140)\n\tat
org.apache.groovy.json.internal.JsonParserCharArray.decodeValueInternal(JsonParserCharArray.java:182)\n\
tat org.apache.groovy.json.internal.JsonParserCharArray.decodeValue(JsonParserCharArray.java:153)\n\tat
org.apache.groovy.json.internal.JsonParserCharArray.decodeFromChars(JsonParserCharArray.java:43)\n\tat
org.apache.groovy.json.internal.JsonParserCharArray.parse(JsonParserCharArray.java:380)\n\tat
org.apache.groovy.json.internal.BaseJsonParser.parse(BaseJsonParser.java:110)\n\tat
Script1839$_run_closure1.doCall(Script1839.groovy:52)\n\tat
com.onresolve.scriptrunner.runner.rest.common.UserCustomScriptEndpoint$_doEndpoint_closure2.doCall(UserCustomScriptEndpoint.groovy:187)\n\tat
com.onresolve.scriptrunner.runner.rest.common.UserCustomScriptEndpoint$_doEndpoint_closure2.doCall(UserCustomScriptEndpoint.groovy)\n\tat
com.onresolve.scriptrunner.runner.diag.DiagnosticsManagerImpl$DiagnosticsExecutionHandlerImpl$_execute_closure1.doCall(DiagnosticsManagerImpl.groovy:350)\n\tat
com.onresolve.scriptrunner.runner.diag.DiagnosticsManagerImpl$DiagnosticsExecutionHandlerImpl$_execute_closure1.doCall(DiagnosticsManagerImpl.groovy)\n\tat
com.onresolve.scriptrunner.runner.ScriptExecutionRecorder.withRecording(ScriptExecutionRecorder.groovy:13)\n\tat
com.onresolve.scriptrunner.runner.ScriptExecutionRecorder$withRecording$0.call(Unknown Source)\n\tat
com.onresolve.scriptrunner.runner.diag.DiagnosticsManagerImpl$DiagnosticsExecutionHandlerImpl.execute(DiagnosticsManagerImpl.groovy:348)\n\tat
com.onresolve.scriptrunner.runner.rest.common.UserCustomScriptEndpoint.doEndpoint(UserCustomScriptEndpoint.groovy:220)\n\tat
com.onresolve.scriptrunner.runner.rest.common.UserCustomScriptEndpoint.getUserEndpoint(UserCustomScriptEndpoint.groovy:71)\n","status-code":"INTERNAL_SERVER_ERROR"}

First, it looks like you left some api credentials in your last comment. If they are real, you might want to obfuscate them.

Second, I'm not sure what this section's purpose is:

rt = output
def json = new groovy.json.JsonBuilder()

json rootKey: rt

def jsonString = rt.toString()
def jsonOutput = new JsonSlurper().parseText(jsonString)

But it seems that that might be what is triggering your error. 

The HTTPBuilder should already have taken care of parsing the JSON response into a data structure object.

You should be able to just use that instead of my jsonOutput.

@Peter-Dave Sheehan 

Thanks for mentioning the credentials part...big uh oh on my end.

And yes that's exactly what it was. I forgot the builder already took care of the Json.

Now I have a table as expected :D

Screen Shot 2021-02-19 at 3.47.46 PM.png

 

Many Thanks!

Only one question left though...how come the builder didn't register the "class:aui" attribute? Because now the only thing is that the css didn't carry over.

The class is there is you inspect the html.

But when calling just a rest api, the styles are not loaded. If you were to display this table in web-panel within an existing Jira environment (where all the styles and scripts are loaded) then the table will have the desired style.

@Peter-Dave Sheehan 

Ah ok, gotcha. So if I wanted to have the table stylized, I'd have to apply some inline css in the endpoint code?

Correct, but maybe instead of in-line style, create a <style> block above your table and then use class names in your table. Especially if you need to apply certain styles for each row or cell.

@Peter-Dave Sheehan 

Ok. Two more things (and then I should be good to go. You've been a really great help to me, I can't thank you enough!):

 

1.) When you mention creating a style block above my table, how exactly would I initiate that? Would I write something like :

style{
.
css
.
}

right above where the structure of the table starts (above xml.table), define the classes with css elements, and then reference the class names for thead, dr, etc.? (Hope this makes sense.)

 

2.)  Looking through the groovy markup builder doc that you referred to me in the beginning of this post, I don't see anything about css property names that are accepted in groovy/by the markupbuilder. If you review the css I added to the headers and (attempted to add to the) table:

    xml.table('style':'column-rule: 4px double #ff00ff'){
thead('style':'color:rgba(183,189,199,255); font-family:arial,sans-serif; border: 1px solid #dddddd'){
tr{
jsonOutput.result[0].keySet().each{
th it.split(/\./)[1]
}
}
}
tbody{
jsonOutput.result.each{ res->
tr{
res.values().each{
td it
}
}
}
}
}

 

Screen Shot 2021-02-19 at 6.48.20 PM.png

The color and font for the headers change, as expected. But the border doesn't get created, and for the table as a whole, the column-rule property doesn't get rendered. Is this because I'm not setting the properties correctly?

And on a further note regarding the css properties usable by the markupbuilder code/groovy in general, do you know of any documentation/web resources that I could reference to further build upon stylizing the table within the rest endpoint code? My end goal is to make this table look similar to the tables in a jira environment...like the table generated from the markup builder code that you ran in the scriptrunner console.

 

________________________

 

I'm very close to the output I'm looking for from the REST Endpoint, and I couldn't have done it without your help. Thank you!

For the style tag, I would do it like this:

def xml = new MarkupBuilder(writer)
xml.style{
mkp.yieldUnescaped """
.aui td {color:red}
"""
}
xml.table(class:'aui'){
[...]
}

For in-line style or any other attribute, just give the tags a list of attributes in map form to the parens for the tag. The map can be right in the markup builder, or you can extract it and put it in a variable

For example

def tableAttr = [ class:'aui', style:'column-rule: 4px double #ff00ff']
xml.table(tableAttr){
{...]
}

Or

xml.table(class:'aui', style:'column-rule: 4px double #ff00ff'){
{...]
}

Now, I'm not really great at css and formatting. So I can't tell you how to write your css or in-line styles to get your desired results. But just by grabbing all the css from my chrome developer pannel, you can try this (inside the triple-quotes):

table.aui {border-collapse: collapse;width: 100%}

table.aui table.aui {margin: 0}

table.aui>caption {color: #7a869a;background: #f4f5f7;border-bottom: 1px solid #dfe1e6;
caption-side: top;padding: 7px 10px;text-align: left}

table.aui>tbody>tr>td,table.aui>tbody>tr>th,table.aui>tfoot>tr>td,table.aui>tfoot>tr>th,table.aui>thead>tr>td,table.aui>thead>tr>th {
padding: 7px 10px;text-align: left;vertical-align: top}

table.aui>tbody>tr>td>ul.menu,table.aui>tbody>tr>th>ul.menu,table.aui>tfoot>tr>td>ul.menu,table.aui>tfoot>tr>th>ul.menu,table.aui>thead>tr>td>ul.menu,table.aui>thead>tr>th>ul.menu {
list-style-type: none;margin: 0;padding: 0}

table.aui>tbody>tr>td>ul.menu>li,table.aui>tbody>tr>th>ul.menu>li,table.aui>tfoot>tr>td>ul.menu>li,table.aui>tfoot>tr>th>ul.menu>li,table.aui>thead>tr>td>ul.menu>li,table.aui>thead>tr>th>ul.menu>li {
float: left;margin: 0 10px 0 0;width: auto}

table.aui>tbody>tr,table.aui>tfoot>tr {background: #fff;color: #172b4d}

table.aui>tbody>tr:first-child>td,table.aui>tbody>tr:first-child>th,table.aui>tfoot>tr:first-child>td,table.aui>tfoot>tr:first-child>th {
border-top: 1px solid #dfe1e6}

table.aui>thead {border-bottom: 2px solid #dfe1e6}

table.aui>tbody>tr>th,table.aui>thead>tr>th {
color: #7a869a;font-size: 12px;font-weight: 600;line-height: 1.66666667;
letter-spacing: 0;text-transform: none}

table.aui>tbody>tr>th {font-size: inherit;background: #fff}

table.aui .aui-button-link {padding-top: 0;padding-bottom: 0;line-height: inherit;
height: auto;border: 0}

table.aui:not(.aui-table-list)>tbody>tr>td,table.aui:not(.aui-table-list)>tbody>tr>th,table.aui:not(.aui-table-list)>tfoot>tr>td,table.aui:not(.aui-table-list)>tfoot>tr>th {
border-bottom: 1px solid #dfe1e6}

table.aui.aui-table-interactive>tbody>tr:focus-within,table.aui.aui-table-interactive>tbody>tr:hover,table.aui.aui-table-list>tbody>tr:focus-within,table.aui.aui-table-list>tbody>tr:hover {
background: rgba(9,30,66,.08)}

table.aui.aui-table-interactive>tbody>tr.aui-row-subtle *,table.aui.aui-table-list>tbody>tr.aui-row-subtle * {
color: #b3bac5}

table.aui.aui-table-interactive>tbody>tr.aui-row-subtle * .aui-avatar,table.aui.aui-table-interactive>tbody>tr.aui-row-subtle * .aui-button,table.aui.aui-table-interactive>tbody>tr.aui-row-subtle * .aui-icon,table.aui.aui-table-list>tbody>tr.aui-row-subtle * .aui-avatar,table.aui.aui-table-list>tbody>tr.aui-row-subtle * .aui-button,table.aui.aui-table-list>tbody>tr.aui-row-subtle * .aui-icon {
opacity: .8}

And Finally, when testing this, I found that the browser ignores some css if you don't include the DOCTYPE declaration.

I was able to do it like this:

Response.ok().type(MediaType.TEXT_HTML).entity('<!DOCTYPE html>' +writer.toString()).build()

Screen Shot 2021-02-22 at 2.17.12 PM.png

And there we have it! Amazing!

@Peter-Dave Sheehan Thanks for putting the time into helping with this!

Like Peter-Dave Sheehan likes this

Hi @Peter-Dave Sheehan ,

Hope this isn't a big ask...this deviates a little from my original ask for this post, but there's two other things I wanted to do with this table.

Is there a way to update/overwrite the table headers that were pulled from the api request (number, state, short_description) to "Number", "Status", "Short Description"? 

And also, for each value in the number column, how would I go about converting each value to a link that references the value itself in the loop? For example, for the number CHG0010042, it would contain the link: "test.com/CHG0010042", and CHG0010061 would contain: test.com/CHG0010061, and so on.

@Peter-Dave Sheehan 

 

I was actually able to change the table headers myself. It was just simply listing the values for the headers rather than iterating though the key values:

 

xml.table(class:'aui') {
thead {
tr{
th 'Number'
th 'Status'
th 'Short Description'
}
}
tbody{
output.result.each{ res->
tr{
res.values().each{
td it
}
}
}
}
}

Now I just need to figure out how to embed a link in each of the record number values (test.com/${number}).

The potential danger your manually putting in the headers is that with JSON and the resulting map object, there is no guarantee of the order of the fields. Should that change for some reason in the API, you may get mis-allignment.

You can get around that by also defining each td by calling a specific key. 

tbody{
output.result.each{ res->
tr{
td res['u_change_record.number']
td res['u_change_record.state']
td res['u_change_record.short_description']
}
}
}

But you would loose the ability of capturing new columns automatically (you may not want that).

Another possible approach would be to create a simple mapping.

def headerMaps = [
'u_change_record.number': 'Number',
'u_change_record.state' : 'State',
'u_change_record.short_description' : 'Short Description'
]

Then

thead {
tr{
json.result[0].keySet().each{
th headerMaps[it] ?: it.split(/\./)[1]
}
}
}

With this, you will get the mapped value if it is found, but there will always be a fallback of using the built-in key value.

 

As for creating a link, if using the static approach above, you can expand it to add the link like this:

tbody{
output.result.each{ res->
tr{
td {
def number = res['u_change_record.number']
a(href:"https://test.com/$number",target:'_blank') number
}
td res['u_change_record.state']
td res['u_change_record.short_description']
}
}
}


If you are still using the loop

tbody{
output.result.each{ res->
tr{
res.values().each{
if(it=='number'){
td {
def number = res['u_change_record.number']
a(href:"https://test.com/$number",target:'_blank') number
}
} else {
td it
}
}
}
}
}

@Peter-Dave Sheehan 

Thanks for the suggestion of mapping/calling the headers! I didn't think about the order of the values potentially changing up in response.

The updated headers successfully display with the changes to thead:

.
.
.
def headerMaps = [
'u_change_record.number': 'Number',
'u_change_record.state' : 'State',
'u_change_record.short_description' : 'Short Description'
]
xml.table(class:'aui'){
thead {
tr{
output.result[0].keySet().each{
th headerMaps[it] ?: it.split(/\./)[1]
}
}
}
.
.
.

 ...as for the url embedding, however, that still doesn't work :(

I went with using the loop and edited the tbody block as follows:

tbody{
output.result.each{ res->
tr{
res.values().each{
if(it=='number'){
td {
def number = res['u_change_record.number']
a(href:"https://test.service-now.com/cm?id=cm_stdnew&table=change_request&view=sp&number=$number",target:'_blank') number
}
} else {
td it
}
}
}
}
}

 

And here's how the table looks now with the changes in place:

Screen Shot 2021-02-23 at 2.32.11 PM.png

The headers successfully changed. But the "Number" values are still plain and the urls didn't get embedded. It's nothing to do with the url itself because I even tried testing with the link to Google, and that didn't get embedded either.

Sorry my mistake...

Try this

tbody{
output.result.each{ res->
tr{
res.values().eachWithIndex{val, idx->
def colHeader= res.keySet()[idx]
if(colHeader=='u_change_record.number'){
td {
a(href:"https://test.service-now.com/cm?id=cm_stdnew&table=change_request&view=sp&number=$val",target:'_blank') {
mkp.yield val
}
}
} else {
td it
}
}
}
}
}

Screen Shot 2021-02-23 at 3.47.27 PM.png

Thanks!

That seems to have solved the problem with the url embedding...but there's a new problem now. The values for State and Short Description don't appear now.

Bit of a transcription error ... change "td it" in the else block to "td val"

Like Ian Balas likes this

@Peter-Dave Sheehan 

Ah, I think I got it! All I had to change was at the end of the condition to td val:

 

tbody{
output.result.each{ res->
tr{
res.values().eachWithIndex{val, idx->
def colHeader= res.keySet()[idx]
if(colHeader=='u_change_record.number'){
td {
a(href:"https://test.service-now.com/cm?id=cm_stdnew&table=change_request&view=sp&number=$val",target:'_blank') {
mkp.yield val
}
}
} else {
td val
}
}
}
}
}

Let me know if this was the right way to correct the code.

But with that, I think I now have my final expected result:

 

Screen Shot 2021-02-23 at 4.03.55 PM.png

Once again, thanks for all the help!

Suggest an answer

Log in or Sign up to answer
TAGS

Community Events

Connect with like-minded Atlassian users at free events near you!

Find an event

Connect with like-minded Atlassian users at free events near you!

Unfortunately there are no Community Events near you at the moment.

Host an event

You're one step closer to meeting fellow Atlassian users at your local event. Learn more about Community Events

Events near you