В предыдущей статье "Несколько примеров успешного изобретения велосипеда" мы поделились рядом решений, полученных путем комбинирования наших плагинов для Atlassian, таких как MyGroovy, JSIncluder и MyCalendar. На этот раз мы рассмотрим еще один плагин из нашей коллекции — Custom Select List.
Custom Select List в сочетании с Groovy или JavaScript превращается в очень мощный инструмент по автоматизации, главная особенность которого — это метаданные опций. Очень много решений реализовано у нас с применением именно этой особенности. В основном, это блоки согласования. С помощью метаданных удобно хранить цепочки согласования с заранее определенными этапами и ответственными, но этим сфера его применения не ограничивается. Рассмотрим на примере реальной задачи, как можно с помощью данного плагина реализовать создание подзадач по шаблону.
Инструменты, которые понадобятся: MyGroovy, JSIncluder и Custom Select List.
Проблема
Команда разработки проводит сессии по планированию спринтов для быстрой декомпозиции и оценки историй. Необходим удобный способ создания подзадач, чтобы можно было использовать "шаблоны" — заранее заготовленные списки задач для типов историй. Это многократно ускорило бы процесс.
Решение
Сначала мы проверили наличие готовых решений. Сразу на маркетплейсе нашли подходящий плагин, перекрывающий данную потребность, но так как у этого плагина нет поддержки версии Data Center Jira Software, это не гарантирует его соответствие большим кластерным средам. Также плагин не состоит в программе Atlassian Marketplace Bug Bounty, поэтому от идеи со сторонним решением пришлось отказаться.
В итоге решили, что создание сабтасков по шаблону будет через loop-переход. Для этого на экран перехода нужно вывести поле для выбора шаблона и настроить форму для редактирования данных и управления количеством создаваемых сабтасков.
Итоговый вид перехода Quick Sub-Task будет выглядеть так:
Реализация
Шаг 1: создаем список шаблонов.
Для этого воспользуемся бесплатным плагином Custom Select List. Создаем новое поле и называем его "Quick subtasks template". Получившееся поле имеет customfield - 63803
. Это поле будем использовать на следующем шаге для отрисовки формы по заданному шаблону.
Добавим первый шаблон "Ручное тестирование":
-
value
— это название элемента в списке; -
data
— метаданные нашего шаблона, где мы будем хранить данные в виде json, затем с помощью JS будем рисовать форму.
Шаблон будет содержать следующие поля: summary
, description
, estimate
, cfields
.
-
cfields
— это дополнительные поля, которые будет добавлять сама команда заказчика в виде:name: "имя поля"
,value: "значение"
.
В данном примере дополнительные поля могут быть только с типом "список", в дальнейшем при необходимости можно добавить поддержку и других типов полей. Это задается на шаге 3 в groovy-скрипте.
Шаг 2: создание формы редактирования подзадач.
Готовим frontend-часть, в этом нам поможет JS Includer.
Код должен уметь получать данные из шаблона и формировать на их основе форму. Также код должен уметь добавлять или удалять элементы формы, чтобы можно было управлять количеством создаваемых через форму подзадач. Ну и, конечно же, код должен уметь сохранять итоговую форму в какое-нибудь поле (его создадим далее), чтобы с помощью пост-функции парсить данные и на их основе создавать подзадачи.
Создаем новое поле Subs creator
с типом Text Field (multi-line). В него мы будем сохранять итоговые значения с нашей формы. Получившееся поле имеет customfield - 63339
. Затем, на шаге 3, с помощью groovy-скрипта, из значений этого поля будут создаваться наши сабтаски.
Приступим к написанию нашей формы.
(function ($) {
/*
количество подзадач, выведенных на форму
*/
var counter = 0;
/*
id текущего перехода. Нужно, чтобы ограничить действие скрипта конкретными переходами
*/
var transition_id = parseInt($('form#issue-workflow-transition input[name="action"]').val(), 10);
/*
форма одной подзадачи
*/
function addInput() {
let $newItem = $('\
<div class="main-subs-menu-item" style="margin-top:10px;">\
<label class="item-name">Подзадача </label>\
<a href="#" onClick="$(this).parent().remove();" class="deleteItem">Delete</a>\
<HR>\
<div class="subs-menu-item">\
</div>\
</div>\
');
counter++;
var subName = $newItem.find('label').text();
$newItem.find('label').text(subName + ' ' + counter);
/*
получаем значения из шаблона Quick subtasks template для выбранного элемента
*/
var dataTemplate = getDataTemplate();
var customfields = dataTemplate.cfields;
if (customfields) {
for (let customfield of customfields) {
var $firstaddingItem = $('\
<span class="subs-menu-element">\
<p style="color:DarkGray;">Name</p>\
<input class="textfield text long-field" type="text">\
</span>\
');
$firstaddingItem.find('p').text(customfield.name);
$firstaddingItem.find('input').val(customfield.value);
$newItem.find('.subs-menu-item').append($firstaddingItem);
}
}
for (let field of Object.keys(dataTemplate)) {
if (field != "cfields") {
var $addingtem = $('\
<span class="subs-menu-element">\
<p style="color:DarkGray;">Name</p>\
<input class="textfield text long-field" type="text">\
</span>\
');
$addingtem.find('p').text(field);
$addingtem.find('input').val(dataTemplate[field]);
$newItem.find('.subs-menu-item').append($addingtem);
}
}
$('#main-subs-id').append($newItem);
}
/*
получаем значения из шаблона Quick subtasks template для выбранного элемента
*/
function getDataTemplate() {
var customData = "";
var url = "/rest/customselect/1.0/customoption/noCheck/";
var option = $('#customfield_63803').val();
$.ajax({
url: url + option,
async: false,
dataType: 'json',
success: function (data) {
customData = JSON.parse(data.data);
}
});
return customData;
}
/*
[71,401] id переходов, на которых будет работать скрипт
делаем кнопку "Добавить подзадачу"
навешиваем обработчик на кнопку "осуществить переход", чтобы перед переходом сохранить значения формы
*/
if( [71,401].indexOf(transition_id) > -1 ){
console.log("[JSI]: JC-44145 Quick subtasks creator");
var $form = $('\
<div class="field-group" id="dynamic-input">\
<label>Quick creator</label>\
<div id="main-subs-id" class="main-subs">\
\
</div>\
<input id="SOMEUNIEQUEID-add-item-button" style="margin-top: 10px;" class="button" type="button" value="Добавить подзадачу">\
</div>\
');
let cfResult = $('#customfield_63339');
cfResult.parent().hide();
cfResult.parent().after($form);
$("#SOMEUNIEQUEID-add-item-button").on('click', addInput);
$('#dynamic-input').hide();
$('#customfield_63803').change(function () {
$('#dynamic-input').show();
});
$('#issue-workflow-transition-submit').click(function(){
var result= "[";
$('.main-subs-menu-item').each(function(){
var name = $(this).find('.item-name').text();
result += '{"'+name+'":{';
var $el = $(this).find('.subs-menu-element');
$.each($el,function(index, element){
var p = $(element).find('p');
var c = $(element).find('input');
result += '"'+$(p).last().text()+'":"'+$(c).last().val()+'",';
});
result = result.slice(0, -1);
result += '}},';
});
result = result.slice(0, -1);
result += ']';
cfResult.val(result).trigger('change')
});
}
})(AJS.$);
Шаг 3: MyGroovy post-function (создание подзадач).
У нас готова форма и заведены все необходимые поля, пора приступать к backend-части. Так как одна из задач, которая перед нами стояла — это дать возможность заказчику самому выбирать поля, которые необходимо добавить в шаблон, то нужно учесть это в скрипте и завязаться на имена полей и опций, а не на их id.
Скрипт будет состоять из нескольких шагов. Первый шаг — это получить и распарсить значения из поля с Subs creator
(customfield - 63339
), а вторым шагом уже создать по полученным данным подзадачи.
Дополнительно к этому можно добавить переиндекс создаваемых задач и обернуть создание в отдельный поток.
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.customfields.manager.OptionsManager
import groovy.json.JsonSlurper
import com.atlassian.jira.issue.IssueFactory
import com.atlassian.jira.issue.index.IssueIndexingService
import com.atlassian.jira.util.ImportUtils
optionsManager = ComponentAccessor.getComponent(OptionsManager)
customFieldManager = ComponentAccessor.getCustomFieldManager()
/*
получаем результат формы
*/
subsConfig = gcfv(issue, 63339)
/*
успешно заполненная форма (шаг 2) должна вернуть следующего вида json
[
{"Подзадача 1":{"Resource Type":"qa","summary":"Подготовка тестрана","estimate":"6","description":""}},
{"Подзадача 2":{"Resource Type":"qa","summary":"Ручное тестирование","estimate":"16","description":""}}
]
*/
data = new JsonSlurper().parseText(subsConfig)
/*
для каждой подзадачи с формы мы запускаем создание
*/
if (data){
data.each { sub ->
def subFields = sub.values().first()
createSubMutableIssue(subFields)
}
}
def createSubMutableIssue(map) {
def issueFactory = ComponentAccessor.getComponentOfType(IssueFactory.class)
def newIssue = issueFactory.getIssue()
newIssue.projectId = issue.projectId
newIssue.issueTypeId = "50"
newIssue.summary = map["summary"]?map["summary"]:issue.summary
newIssue.reporter = currentUser
newIssue.assignee = null
newIssue.description = map["description"] ? map["description"] : ""
newIssue.setOriginalEstimate(map["estimate"].toLong()*60*60)
newIssue.setEstimate(map["estimate"].toLong()*60*60)
map.each { k, v ->
if (!(["description", "summary", "estimate"].contains(k))) {
def cf = customFieldManager.getCustomFieldObjectByName(k)
if (cf.getCustomFieldType() instanceof com.atlassian.jira.issue.customfields.impl.SelectCFType) {
def fieldConfig = cf.getRelevantConfig(issue)
def option = optionsManager.getOptions(fieldConfig).getOptionForValue(v, null)
newIssue.setCustomFieldValue(cf, option)
}
}
}
newIssue.setParentObject(issue)
ComponentAccessor.issueManager.createIssueObject(currentUser, newIssue)
ComponentAccessor.subTaskManager.createSubTaskIssueLink(issue, newIssue, currentUser)
reIndexIssue(newIssue)
return newIssue
}
def gcfv(issue, fieldId) {
issue.getCustomFieldValue(getCustomFieldObject(fieldId))
}
def getCustomFieldObject(fieldId) {
customFieldManager.getCustomFieldObject(fieldId)
}
/*
реиндекс нужен чтобы созданные подзадачи появились в фильтрах жиры
*/
def reIndexIssue(issue){
boolean wasIndexing = ImportUtils.isIndexIssues()
ImportUtils.setIndexIssues(true)
ComponentAccessor.getComponent(IssueIndexingService.class).reIndex(issue)
ImportUtils.setIndexIssues(wasIndexing)
}
Чтобы переход не дожидался создания подзадач, можно добавить в код запуск отдельного потока (thread) для создания.
import com.atlassian.jira.util.thread.OffRequestThreadExecutor
def offRequestThreadExecutor = ComponentAccessor.getComponent(OffRequestThreadExecutor.class)
def threading = Thread.start {
offRequestThreadExecutor.execute({
/*
code
*/
})
}
Смотрим, что получилось
Для примера создадим 3 подзадачи по шаблону и немного скорректируем входные параметры. Так выглядит наша кнопка:
Выберем шаблон "Ручное тестирование":
Появилась кнопка "Добавить подзадачу". Нам нужно создать их 3, поэтому нажимаем 3 раза на кнопку добавить:
Все 3 формы не умещаются на экране, но на примере двух мы видим, что из шаблона подтянулись следующие значения:
Пользовательское поле: "Resource Type" (тип: Select List) с вариантом "qa"
summary: Ручное тестирование
estimate: 4
description: ""
Поменяем значения summary и estimate у двух подзадач и после создания получим следующий результат:
В заключение
Custom Select List можно использовать не только для создания шаблонов к подзадачам. Сценариев множество, например, для заполнения полей на форме запроса при создании или кастомизации рассылки уведомлений на переходах. Но самое частое применение — это согласование, где есть множество вводных и сложная матрица ответственных с различными этапами и условиями. Так мы в рамках одного статуса зацикливаем все согласование по запросу и, тем самым, избегаем избыточности статусной модели.
И это далеко не весь список возможных реализаций. При должном уровне фантазии и времени можно делать достаточно интересные вещи, главное — не переусердствовать, ведь кому-то это потом еще и поддерживать.
Vays333
Спасибо за статью! Полезно ;)