В данной статье мы рассмотрим процесс создания формы на базе JavaScript и включения ее в экран создания запроса в JIRA.

Это может быть полезно, если вы хотите получить большую гибкость, чем предоставляют стандартные поля JIRA. Например, если вам требуется изменить расположение полей, настроить валидацию пользовательского ввода, добавить подсказки или динамически отображать поля. В этой статье мы полностью реализуем пример, используя все перечисленные возможности.  

Поставленная перед нами задача следующая:

Добавить возможность запросить сетевой доступ в стандартном запросе типа "задача" для проекта "сетевая инфраструктура".  

Для реализации задуманного нам понадобится плагин JsIncluder от команды VK. Его основная задача - подключать кастомные JS скрипты к интерфейсу JIRA, тем самым предоставляя инструменты для расширения функционала на уровне front-end.

План реализации

1. Написать HTML и CSS-код для создания скелета формы (можно использовать стиль, понравившийся вам в интернете).  

2. Объединить код в JavaScript, добавить валидацию и обработчики событий.

3. Создать в JIRA поле «Запрос сетевого доступа» типа textarea и изменить его представление в проекте на wiki. Результаты нашей формы будут записываться в это поле в виде таблицы:  

4. Создать кнопку вызова формы на экране создания запроса:

Кнопка "Подтвердить" на форме будет использоваться для проверки введенных данных и обязательных полей. Поля, не прошедшие валидацию, будут выделены красным цветом:

Если попытаться создать запрос без подтверждения, появится предупреждение:

Реализация 

HTML-разметка формы.

Класс "required" будет использоваться функцией валидации в JavaScript. Если этот класс указан, то поле должно быть обязательно заполнено или выбрана опция.

Атрибут "type" необходим для правильной обработки данных объекта и их передачи в JIRA:

<div class="access-container">
    <form>
        <h2>Запрос сетевого доступа <span id="removerule" class="aui-icon aui-icon-small aui-iconfont-delete" title="Удалить правило">remove rule</span> </h2>
        <hr>
        <div class="row">
            <h4 class="title-left">Кто владелец сервиса</h4>
            <div class="input-group">
                <input class="required" id="access-input-fio" type="text" placeholder="ФИО/Название отдела" fieldName="Владелец сервиса"/>
            </div>
        </div>
        <div class="row">
            <h4 class="title-left">Для какого сервиса требуется открыть доступ</h4>
            <div class="input-group">
                <input class="required" id="access-input-description" type="text" placeholder="Краткое описание функционала и с чем он взаимодействует" fieldName="Для какого сервиса требуется открыть доступ"/>
            </div>
        </div>
        <div class="row">
            <div class="input-group">    
                <input id="terms-subs" type="checkbox" fieldName="Требуется выделение новой подсети"/>
                <label for="terms-subs">Требуется выделение новой подсети</label>     
            </div>
            <div class="col-half">
                <div class="input-group" id="input-subs">
                    <input class="required" id="access-input-subnet" type="text" placeholder="Существующая подсеть" fieldName="Существующая подсеть"/>
                </div>
            </div>
        </div>
        <div class="row">
            <div class="col-half">
                <div class="input-group" id="input-hubs">
                    <input class="required" id="access-input-hosts" type="number" label="input-hubs" placeholder="Количество хостов" fieldName="Количество хостов"/>
                </div>
            </div>
        </div>
        <div class="row">        
            <div class="input-group">
                <input id="terms-internet" type="checkbox" fieldName="Требуется исходящий доступ в сеть интернет"/>
                <label for="terms-internet">Требуется исходящий доступ в сеть интернет</label>
            </div>
        </div>
        <div class="row">
                <div class="input-group" id="internet-select-div-access">
                    <select class="required" id="internet-select-access" type="select" fieldName="Исходящий доступ в сеть интернет">
                        <option selected value="None">None</option>
                        <option value="1000">Полный</option>
                        <option value="1001">К определенным адресам</option>
                    </select>
                </div>
            <div class="col-half">
                <div class="input-group" id="internet-input-sites">
                    <input class="required" id="access-input-addresses" type="text2" label="input-internet-sites" placeholder="Список адресов (сайтов)" fieldName="Доступ в сеть интернет к адресам"/>
                </div>
            </div>
        </div>
        <div class="row">
            <div class="input-group" id="internet-timelimit-access">
                <input id="terms-internet-access" type="checkbox" fieldName="Постоянный доступ"/>
                <label for="terms-internet-access">Постоянный доступ</label>
            </div>
            <div class="col-half">
                <div class="input-group" id="internet-timelimitdata-access">
                    <p style="font-size: 11px; color: gray">Дата и время закрытия доступа</p>
                    <input class="required" id="access-input-datetime" type="datetime-local" placeholder="Дата и время закрытия доступа" fieldName="Дата и время закрытия доступа"/>
                </div>
            </div>
        </div>

        <div class="row">
            <h4>IP Адрес/а или подсети с которых и к которым нужно открыть доступ</h4>
            <p style="font-size: 11px; color: gray">IP Адреса/ URL ресурсы</p>
            <div class="col-half">
                <div class="input-group">
                    <input class="required" id="access-input-source" type="text2" label="input-url-sites-input" placeholder="Источник" fieldName="IP Адрес/а или подсети с которых нужно открыть доступ"/>
                </div>
            </div>
            <div class="col-half">
                <div class="input-group">
                    <input class="required" id="access-input-destiny" type="text2" label="input-url-sites" placeholder="Назначение" fieldName="IP Адрес/а или подсети к которым нужно открыть доступ"/>                  
                </div>
            </div>           
        </div>        
        <div class="row">
            <h4>Протокол</h4>
            <div class="input-group">
                <select class="required" id="access-input-nettype" type="select" fieldName="Протокол">
                    <option selected value="None">None</option>
                    <option>TCP</option>
                    <option>UDP</option>
                    <option>TCP+UDP</option>
                    <option>Не знаю</option>                    
                </select>
            </div>
        </div>    
        <div class="row">
            <div class="col-half">
                <h4>Порт/ы на которые устанавливается соединение</h4>
                <div class="input-group">
                    <input class="required" id="access-input-ports" type="text2" label="input-internet-ports-output" fieldName="Порт/ы на которые устанавливается соединение"/>
                </div>
            </div>
        </div>            
         <div class="row">
            <div class="input-group">
                <input id="terms-port-access" type="checkbox" fieldName="Требуется доступ к ресурсу из интернета на хост/ы с выделением публичного IP"/>
                <label for="terms-port-access">Требуется доступ к ресурсу из интернета на хост/ы с выделением публичного IP</label>
            </div>
             <div class="col-half" id="div-internet-ports-input">
                <h4>Порт/ы открытые в интернет</h4>
                <div class="input-group">
                    <input class="required" id="access-input-inetports" type="text2" label="input-internet-ports-input" fieldName="Порт/ы открытые в интернет"/>
                </div>
            </div>           
        </div>      
        <div class="row">
            <h4>Комментарий (необязательно)</h4>
            <div class="input-group">
                <textarea id="input-comment" name="input-comment" rows="5" fieldName="Комментарий"></textarea>  
            </div>   
        </div>                         
        <button type="button" id="network_signupbtn_id" class="access_buttons">Подтвердить</button>
    </form>
</div>

CSS формы:

<style type="text/css">
    *,
    *:before,
    *:after {
        box-sizing: border-box;
    }   
    .access-container body {
        padding: 1em;
        font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
        font-size: 13px;
        color: #b9b9b9;
        background-color: #e3e3e3;
    }
    .access-container h4 {
        color: #033dea;
    }
    .access-container input[type="datetime-local"],
    .access-container input[type="text"],
    .access-container textarea,
    .access-container input[type="number"],
    .access-container input[type="radio"] + label,
    .access-container input[type="checkbox"] + label:before,
    .access-container select option,
    .access-container select {
        height: 35px;
        width: 100%;
        padding: 0 15px;
        line-height: 15px;
        background-color: #f9f9f9;
        border: 1px solid #e5e5e5;
        border-radius: 3px;
        -webkit-transition: 0.35s ease-in-out;
        -moz-transition: 0.35s ease-in-out;
        -o-transition: 0.35s ease-in-out;
        transition: 0.35s ease-in-out;
        transition: all 0.35s ease-in-out;
    }
    .access-container input:focus {
        outline: 0;
        border-color: #01209a;
    }
    .access-container input:focus + .input-icon i {
        color: #033dea;
    }
    .access-container input:focus + .input-icon:after {
        border-right-color: #033dea;
    }
    .access-container input[type="radio"] {
        display: none;
    }
    .access-container input[type="radio"] + label,
    .access-container select {
        display: inline-block;
        width: 50%;
        text-align: center;
        float: left;
        border-radius: 0;
    }
    .access-container input[type="radio"] + label:first-of-type {
        border-top-left-radius: 3px;
        border-bottom-left-radius: 3px;
    }
    .access-container input[type="radio"] + label:last-of-type {
        border-top-right-radius: 3px;
        border-bottom-right-radius: 3px;
    }
    .access-container input[type="radio"] + label i {
        padding-right: 0.4em;
    }
    .access-container input[type="radio"]:checked + label,
    .access-container input:checked + label:before,
    .access-container select:focus,
    .access-container select:active {
        background-color: #033dea;
        color: #fff;
        border-color: #01209a;
    }
    .access-container input[type="checkbox"] {
        display: none;
    }
    .access-container input[type="checkbox"] + label {
        position: relative;
        display: block;
        padding-left: 1.6em;
    }
    .access-container input[type="checkbox"] + label:before {
        position: absolute;
        top: 0.2em;
        left: 0;
        display: block;
        width: 1em;
        height: 1em;
        padding: 0;
        content: "";
    }
    .access-container input[type="checkbox"] + label:after {
        position: absolute;
        top: 0.45em;
        left: 0.2em;
        font-size: 0.8em;
        color: #fff;
        opacity: 0;
        font-family: FontAwesome;
        content: " ";
    }
    .access-container input:checked + label:after {
        opacity: 1;
    }
    .access-container select {
        height: 35px;
        line-height: 13px;
    }
    .access-container select:first-of-type {
        border-top-left-radius: 3px;
        border-bottom-left-radius: 3px;
    }
    .access-container select:last-of-type {
        border-top-right-radius: 3px;
        border-bottom-right-radius: 3px;
    }
    .access-container select:focus,
    .access-container select:active {
        outline: 0;
    }
    .access-container select option {
        background-color: #033dea;
        color: #fff;
    }
    .access-container .input-group {
        margin-bottom: 1em;
        zoom: 1;
    }
    .access-container .input-group:before,
    .access-container .input-group:after {
        content: "";
        display: table;
    }
    .access-container .input-group:after {
        clear: both;
    }
    .access-container .input-group-icon {
        position: relative;
    }
    .access-container .input-group-icon input {
        padding-left: 4.4em;
    }
    .access-container .input-group-icon .input-icon {
        position: absolute;
        top: 0;
        left: 0;
        width: 3.4em;
        height: 15px;
        line-height: 15px;
        text-align: center;
        pointer-events: none;
    }
    .access-container .input-group-icon .input-icon:after {
        position: absolute;
        top: 0.6em;
        bottom: 0.6em;
        left: 3.4em;
        display: block;
        border-right: 1px solid #e5e5e5;
        content: "";
        -webkit-transition: 0.35s ease-in-out;
        -moz-transition: 0.35s ease-in-out;
        -o-transition: 0.35s ease-in-out;
        transition: 0.35s ease-in-out;
        transition: all 0.35s ease-in-out;
    }
    .access-container .input-group-icon .input-icon i {
        -webkit-transition: 0.35s ease-in-out;
        -moz-transition: 0.35s ease-in-out;
        -o-transition: 0.35s ease-in-out;
        transition: 0.35s ease-in-out;
        transition: all 0.35s ease-in-out;
    }
    .access-container {
        max-width: 85%;
        padding: 1em 3em 2em 3em;
        margin: 0em auto;
        background-color: #fff;
        border-radius: 4.2px;
        box-shadow: 0px 3px 10px -2px rgba(0, 0, 0, 0.2);
    }
    .access-container .row {
        zoom: 1;
    }
    .access-container .row:before,
    .access-container .row:after {
        content: "";
        display: table;
    }
    .access-container .row:after {
        clear: both;
    }
    .access-container .col-half {
        padding-right: 10px;
        float: left;
        width: 50%;
    }
    .access-container .col-half:last-of-type {
        padding-right: 0;
    }
    .access-container .col-third {
        padding-right: 10px;
        float: left;
        width: 33.33333333%;
    }
    .access-container .col-third:last-of-type {
        padding-right: 0;
    }
    .access-container #span-required{
        color:red !important;
    }        
    .title-right {
        float: right;
    }   
    .title-left {
        display: inline;
    }        
    .side-layout {
        display: flex;
        justify-content: space-between;
    }    
    .access_buttons {
        background-color: #4CAF50;
        color: white;
        padding: 14px 20px;
        margin: 8px 0;
        border: none;
        cursor: pointer;
        width: 100%;
        opacity: 0.9;
    }
    .directors_buttons:hover {
        opacity:1;
    }    
    .error {
      position: relative;
      animation: shake .1s linear;
      animation-iteration-count: 3;
      border: 1px solid red;
      outline: none;
    }
    @media only screen and (max-width: 540px) {
        .col-half {
            width: 100%;
            padding-right: 0;
        }
    }

Объединим получившийся HTML и CSS в переменную "htmlText" для использования в нашем JavaScript-скрипте.

Подключим поле (в нашем примере это поле "Запрос сетевого доступа" с идентификатором "customfield_12505"), в котором будем рисовать таблицу, оно уже должно быть создано в JIRA, и скроем его с экрана:

var $postFunction = $("#customfield_12505");
$postFunction.closest('.field-group').hide();

Добавим кнопку вызова формы и запишем в переменную startHTML:

<div class="field-group">
<div id="rules">
    <a class="aui-button" id="createRule" title="Запросить сетевой доступ"><span class="aui-icon aui-icon-small aui-iconfont-add"></span> <b>Запросить сетевой доступ</b></a>
</div>
</div>

Добавим переменные "startHTML" и "htmlText" на экран, но скроем форму до ее вызова:

$postFunction.closest('.field-group').before(startHTML);
$postFunction.closest('.field-group').before(htmlText);
AJS.$('.access-container').hide();

Переопределим кнопку "Создать", чтобы она не срабатывала при открытой форме. Для этого создадим кнопку-копию "Создать" и настроим ее замену. Добавим вызов алерта с использованием AUI JIRA:

$('#create-issue-submit').before('<input class="aui-button aui-button-primary" id="create-issue-submit-fake" type="submit" value="Create">');
$('#create-issue-submit-fake').hide();

AJS.$('#create-issue-submit-fake').on('click', function () {
    JIRA.Messages.showErrorMsg('Незаполненна форма сетевого доступа');
})

AJS.$('a#createRule').on('click', function () {
    AJS.$('.access-container').show();
    if ($('#access-input-fio').attr("disabled") != "disabled"){
        $('#create-issue-submit').hide();
        $('#create-issue-submit-fake').show();
    }
    $('#rules').hide();
})

AJS.$('span#removerule').on('click', function () {
    removeRule();
})

function removeRule() {
    AJS.$('.access-container').hide();
    $('#rules').show();
    $('#create-issue-submit').show();
    $('#create-issue-submit-fake').hide();
}

Настроим динамическое отображение полей:

$('#input-hubs').hide();
$('#access-input-hosts').removeClass( "required" );
$('#internet-select-div-access').hide();
$('#internet-select-access').removeClass( "required" );
$('#internet-timelimit-access').hide();
$('#access-input-addresses').removeClass( "required" );
$('#internet-timelimitdata-access').hide();
$('#access-input-inetports').removeClass( "required" );
$('#internet-input-sites').hide();
$('#access-input-departments').removeClass( "required" );
$('#div-internet-ports-input').hide();
$('#access-input-datetime').removeClass( "required" );
$('#div-approve-departmnets').hide();


//--------------------------LISTENERS------------------------------------

$('#terms-subs').change(function() {
    if(this.checked) {
        $('#input-hubs').show();
        $('#access-input-hosts').addClass( "required" );
        $('#access-input-subnet').removeClass( "required" );
        $('#access-input-subnet').val(null).trigger('change');
        $('#input-subs').hide();
    } else {
        $('#input-hubs').hide();
        $('#access-input-hosts').removeClass( "required" );
        $('#access-input-hosts').val(null).trigger('change');
        $('#access-input-subnet').addClass( "required" );
        $('#input-subs').show();
    }
});

$('#terms-internet').change(function() {
    if(this.checked) {
        $('#internet-select-div-access').show();
        $('#internet-timelimit-access').show();
        $('#internet-timelimitdata-access').show();
        $('#internet-select-access').addClass( "required" );
        $('#access-input-datetime').addClass( "required" );
        $('#internet-input-sites').hide();
        $('#terms-internet-access').prop('checked');
    } else {
        $('#internet-select-div-access').hide();
        $('#internet-timelimit-access').hide();
        $('#internet-timelimitdata-access').hide();
        $('#internet-select-access').removeClass( "required" );
        $('#internet-select-access').val("");
        $('#access-input-datetime').removeClass( "required" );
        $('#access-input-datetime').val(null).trigger('change');
        $('#internet-input-sites').hide();
        $('#terms-internet-access').prop( "checked", false );

    }
});

$('#internet-select-access').change(function() {
    if ($('#internet-select-access').val() == "1001"){
        $('#internet-input-sites').show();
        $('#access-input-addresses').addClass( "required" );
    }
    if ($('#internet-select-access').val() == "1000"){
        $('#internet-input-sites').hide();
        $('#access-input-addresses').removeClass( "required" );
        $('#access-input-addresses').val(null).trigger('change');
    }
});

$('#terms-internet-access').change(function() {
    if(this.checked) {
        $('#internet-timelimitdata-access').hide();
        $('#access-input-datetime').removeClass( "required" );
        $('#access-input-datetime').val(null).trigger('change');
    } else {
        $('#internet-timelimitdata-access').show();
        $('#access-input-datetime').addClass( "required" );
    }
});

$('#terms-port-access').change(function() {
    if(this.checked) {
        $('#div-internet-ports-input').show();
        $('#access-input-inetports').addClass( "required" );
    } else {
        $('#div-internet-ports-input').hide();
        $('#access-input-inetports').removeClass( "required" );
        $('#access-input-inetports').val(null).trigger('change');
    }
});

$('#terms-company-access').change(function() {
    if(this.checked) {
        $('#div-approve-departmnets').show();
        $('#access-input-departments').addClass( "required" );
    } else {
        $('#div-approve-departmnets').hide();
        $('#access-input-departments').removeClass( "required" );
        $('#access-input-departments').val(null).trigger('change');
    }
});

Отдельно стоит отметить поле типа "auiSelect2", с помощью которого мы создали интерактивный список. Данные хранятся в виде мультисписка, но вводятся пользователем как обычный текст. Это очень удобно для ручного ввода списка IP-адресов

Реализация auiSelect2 на примере одного из полей, в нашей форме это поле с id = input-internet-sites:

   function getOptions() {
        var options = []
        var id = "-1"
        var value = "Начните печатать"
        options.push({id: id, text: value})
        return options
    }

    var options =  getOptions();
    AJS.$("input[label=input-internet-sites]").auiSelect2({
        multiple: true,
        tokenSeparators: [",", " ", "\n", "\t"],
        initSelection: function(element, callback) {
            var data = [];
            $(element.val().split(",")).each(function() {
                var selectedOptionId = this
                var existOption = options.find(function(i) {return i.id == selectedOptionId})
                if (existOption != undefined) {
                    data.push({
                        id: existOption.id,
                        text: existOption.text
                    });
                } else {
                    data.push({
                        id: this,
                        text: this
                    });
                }
            });
            callback(data);
        },

        data: options,
        createSearchChoice: function(term) {
            var lastResults = [];
            var text = term + (lastResults.some(function(r) {
                return r.text == term
            }) ? "" : "");
            return {
                id: "C-" + term,
                text: text
            };
        },
        dropdownAutoWidth: true, dropdownCss: {width: 'auto'}
    });

Валидация:

function CheckRequired(e) {
    var $form = $('.access-container');
    if ($form.find('.required').filter(function () {
        return this.value === '' || this.value === "None" || this.value === '-1'
    }).length > 0) {
        $form.find('.required').filter(function () {
            if (this.value === '' || this.value === "None" || this.value === '-1'){
                setTooltip(this, "Поле не заполнено");
            } else {
                $(this).css({"border": "0px"});
            }
        });

        return false;
    }
    return true;
}
function setTooltip(field, text) {
    if ($(field).attr("type") == "text2"){
        $(field).parent().children(":first").addClass('error');
        $(field).parent().children(":first").css({"border": "1px solid red"});
        setTimeout(function() {
            $(field).parent().children(":first").removeClass('error');
        }, 500);
    } else {
        $(field).addClass('error');
        $(field).css({"border": "1px solid red"});
        setTimeout(function () {
            $(field).removeClass('error');
        }, 500);
    }
}

Реализуем сохранение полей формы в поле JIRA для последующего отображения в запросе:

var ids = ["access-input-fio","access-input-description","terms-subs",    "access-input-subnet","access-input-hosts","terms-internet",
"internet-select-access","access-input-addresses",
"terms-internet-access","access-input-datetime","access-input-source",    "access-input-destiny","access-input-nettype","access-input-ports",
"terms-port-access","access-input-inetports","terms-company-access",
"access-input-departments","terms-vpn-access",
"input-comment"];

function setValue(ids) {
    $.each(ids, function( index, value ) {
        var $field = $('#' + value);
        var type = $field.attr("type");
        $field.attr("disabled", "disabled");
        $field.css({"background-color" : "#e5e5e5"})
        var nameField = $field.attr("fieldName");
        var valueField = "";
        if (type == "checkbox"){
            if ($field.is(":checked")){
                valueField = "Да"
            }
        } else if (type == "text"){
            valueField = $field.val()
        } else if (type == "datetime-local"){
            valueField = $field.val().replace("T", " ")
        } else if (type == "select"){
            valueField = $field.find('option:selected').text();
        } else if (type == "text2"){
            valueField = $field.val().replaceAll("C-", "")
        } else {
            valueField = $field.val()
        }

        if (valueField) {
            var valField = "||" + nameField + "||" + valueField + "||\n"
            var setVal = $postFunction.val() + valField
            $postFunction.val(setVal).trigger('change')
        }
    })
}

Кнопка "Подтвердить" проверяет заполнение всех полей, и в случае успеха записывает таблицу в запрос: 

$('#network_signupbtn_id').bind("click", function (e) {
    if (CheckRequired(e)) {
        setValue(ids);
        JIRA.Messages.showSuccessMsg('Таблица успешно заполнена');
        $('#network_signupbtn_id').hide();
        $('#create-issue-submit').show();
        $('#create-issue-submit-fake').hide();
    } else {
        e.preventDefault();
        e.stopPropagation();
    }
});

На этом наша статья завершается. Остается только собрать все фрагменты кода в один JavaScript-скрипт и вставить его в плагин JsIncluder на вкладке "General" в разделе "Code".

На вкладке "Binding" выберите ваш проект и тип запроса, на которых вы хотите применить этот код. Затем установите флажок "Выполнять на экране создания" и сохраните скрипт.

 

Комментарии (2)


  1. Master_of_Slaves
    19.06.2023 08:31
    -1

    А зачем?


    1. FerazYulgushev Автор
      19.06.2023 08:31
      +1

      Это касается новых продаж. До появления вменяемой альтернативы пока так :(