Здесь я расскажу, как сделать канбан-доску для проекта в Jira, пользуясь только QML и JavaScript. С небольшими доработками вместо Jira вы можете использовать любой другой трекер, имеющий REST API.
Альтернативы для умных и богатых
Необходимые оговорки
Начало работы с Jira REST API
Создаем проект в Qt Creator
Рисуем дизайн карточки запроса
Описываем колонку карточек
Окно для доски
Пишем код для вызова REST API
LocalStorage для сохранения и восстановления параметров
Добавляем варианты группировки
Что дальше?
Предыстория
Некоторое время назад, теперь уже практически в другой жизни, в мою бытность руководителем проекта, я понял, что теряю представление о занятости участников нашего проекта. Кто-то занимается Большим и Важным делом, кто-то исправляет срочные баги, а может быть кто-то, извините, балду пинает, а я об этом не в курсе и задачи ему не ставлю. И мне захотелось иметь наглядную картинку текущих дел.
Если у вашей организации уже диагностировали kanban в хронической стадии и вы тяготеете ко всему натуральному и осязаемому, то, скорее всего, ваша доска выглядит вроде этой, и у нее тоже есть разделение по этапам процесса:
Взято отсюда.
В моем случае такой вариант не прокатил бы по нескольким причинам.
Во-первых, вся команда, за исключением пары человек, находилась в другом городе, а устраивать видеомитинги мне не казалось рациональным.
Во-вторых, у меня есть стойкое отвращение ко всякому ручному труду и вручную нацеплять бумажки на доску (а больше было некому, см. предыдущий пункт), отслеживать движение задач в трекере и соответственно передвигать бумажки на доске мне претило. Можно было нарисовать карточки в компьютере, в Excel или в Trello, но следить за задачами и передвигать карточки опять пришлось бы самостоятельно.
В-третьих, и самое главное, глядя на эту доску, можно видеть общее состояние дел, находить узкие места на участках конвейера по производству ПО, но в ней совершенно не видно людей и их загрузки.
Поэтому мне нужна была доска:
а) электронная
б) связанная с трекером, т.е. отражающую текущую ситуацию
в) и чтобы столбец на доске соответствовал конкретному человеку
Короче говоря, эту задачу я на тот момент решил, сделал представление на web-страничке. Но о ней ничего вам не расскажу — и трекер тот (PVCS Tracker) не слишком распространен, API у него на dll, да и код странички сейчас не найти.
А сейчас я решил повторить упражнение, взяв в качестве инструментария QML. Выбор объясняется просто — мне он чуть более знаком, чем веб-технологии, и я знаю, как встроить получившийся модуль в свой инструмент, написанный на Python и PyQt.
Альтернативы для умных и богатых
Да, я знаю, что для Jira существует энное количество плагинов, в которых есть Kanban-доска — поиск в marketplace по слову «kanban» находит 33 варианта.
Но использовать плагин означает, что нужно выбить у руководства его покупку по цене соответствующей числу всех пользователей на Jira, договориться с админами, что они поставят его на сервере и будут поддерживать, и… невозможность его кастомизации под мои нужды, т.к. плагин будет общим для всех. А мне хотелось иметь инструмент, который можно использовать независимо от того, установлено что-то на сервере или нет, и менять, ни на кого не оглядываясь.
Необходимые оговорки
Чтобы не утяжелять статью, здесь не будет сказано о том, как сделать:
— авторизацию в Jira
— операции над карточками в QML с передачей вызова в JIRA — редактирование, смена статусов и исполнителей путем drag&drop и т.п.
— работа с фильтрами Jira
Если что-то из этого вам действительно интересно — отпишите об этом в комментариях. Не буду обещать, что немедленно сделаю и распишу в деталях, но, как сказал nmivan, «поставлю в план».
Терминология еще не устоялась, так issue в одних компаниях называют запросом, в других задачей, еще бывают тикеты и заявки. Для сущности filter, которым в Jira отбирают issues, тоже есть куча названий — фильтр, запрос, выборка, список.
Я буду использовать терминологию, принятую в локализованной Jira: issue буду называть запросом, а filter — списком.
Начало работы с Jira REST API
Типичный адрес запроса в веб-интерфейсе Jira выглядит так:
https://jira.mycompany.ru/browse/PROJECT-1234
Берем протокол и имя хоста, то есть с начала адреса до browse
, дописываем к нему rest/api/2/
— и у нас получается базовая часть адреса REST API
https://jira.mycompany.ru/rest/api/2/
Полное описание Jira REST API на сайте Atlassian. Там много всяких функций, которых с каждой версией становится все больше, но реально требуется знать лишь небольшое количество методов:
GET https://jira.mycompany.ru/rest/api/2/issue/PROJECT-1234
Получить запрос PROJECT-1234 — вернется JSON с полями запроса. Учтите, что для названия полей будут использоваться внутренние имена, а не те, что вы видите в веб-интерфейсе. Так поле «Статус тестирования» может оказаться customfield_10234
. Чтобы понять, какое поле какому соответствует, воспользуйтесь запросом /rest/api/2/issue
.
POST https://jira.mycompany.ru/rest/api/2/issue
Создать новый запрос. В теле вызова передается JSON с заполняемыми полями запроса. Те поля, что вы не передали, заполнятся значениями по умолчанию.
PUT https://jira.mycompany.ru/rest/api/2/issue/PROJECT-1234
Изменение (редактирование) полей в запросе. В теле вызова передается JSON, в котором есть два блока — «update» с инструкциями по изменению полей, и «fields» с новыми значениями полей.
Изменяемое поле должно быть только в одном из этих блоков.
{
"update": {
"summary":[
{"set":"Bug in business logic"}
],
"components":[{"set":""}],
"timetracking":[
{"edit":{"originalEstimate":"1w 1d","remainingEstimate":"4d"}}
],
"labels":[
{"add":"triaged"},
{"remove":"blocker"}]
},
"fields":{
"summary":"This is a shorthand for a set operation on the summary field",
"customfield_10010":1,
"customfield_10000":"This is a shorthand for a set operation on a text custom field"
}
}
GET https://jira.mycompany.ru/rest/api/2/search?jql=...
— получить список запросов, соответствующего условиям на языке JQL
{
expand: "schema,names",
startAt: 0,
maxResults: 10,
total: 738,
issues: [{
expand: "operations,versionedRepresentations,editmeta,changelog,renderedFields",
id: "947068",
self: "https://jira.atlassian.com/rest/api/2/issue/947068",
key: "JRASERVER-66937",
fields: {
customfield_18232: null,
...
POST https://jira.mycompany.ru/rest/api/2/search
— тоже самое для сложных условий, не умещающихся в строку URL
GET https://jira.mycompany.ru/rest/api/2/field
— получить описания всех полей, которые могут использоваться в запросах.
Пока хватит на первое время.
Поскольку мы ничего менять и редактировать пока не собираемся, то работать будем анонимно, с запросами на сервере Jira в Atlassian, в проекте «JIRA Server (including JIRA Core)», то есть, фигурально выражаясь, в Самом Главном Проекте Jira. Тем более, что там тоже есть наши люди:
Первым делом рекомендую зайти в веб-интерфейс проекта и сделать поиск запросов по какому-либо условию, например:
project = JRASERVER and updated <= -1w ORDER BY updated DESC
Это нужно для того, чтобы убедиться, что вы запрос составили правильно — если это не так, то веб-интерфейс вам скажет.
Условие копируем и подставляем в параметр jql функции search, получится такой URL:
https://jira.atlassian.com/rest/api/2/search?jql=project = JRASERVER and updated <= -1w ORDER BY updated DESC
Открываем его в браузере и получаем JSON. JSON сохраняем браузером в файл с расширением .json, открываем его в Qt Creator — оказывается, что весь файл в одной длинной строке, а затем, следите за руками — форматируем его как QML
Сохраните его под другим именем. C полученным файлом удобнее работать, находить в нем нужные поля и смотреть, в какой структуре находится нужное значение. Оригинальный файл нам пригодится в качестве тестового источника, чтобы лишний раз не ходить на сервер Atlassian.
Имеет смысл также получить список всех полей через запрос rest/api/2/field
, чтобы определять, под каким идентификатором числится нужное вам поле.
Создаем проект в Qt Creator
Для создания проекта в Qt Creator воспользуемся стандартным шаблоном «Qt Quick Control Application».
Получится проект, состоящий из main.cpp и main.qml в файле ресурсов qml.qrc.
import QtQuick 2.3
import QtQuick.Controls 1.2
ApplicationWindow {
id: applicationWindow1
visible: true
width: 649
height: 480
title: qsTr("Hello World")
menuBar: MenuBar {
Menu {
title: qsTr("File")
MenuItem {
text: qsTr("&Open")
onTriggered: console.log("Open action triggered");
}
MenuItem {
text: qsTr("Exit")
onTriggered: Qt.quit();
}
}
}
}
Их мы трогать пока не будем, займемся более насущными проблемами.
Рисуем дизайн карточки с запросом
Создаем новый файл IssueCard.qml, визард по умолчанию закинет его в файл ресурсов.
Дизайн карточки, которой будет отображаться запрос, я сначала по быстрому накидал в режиме дизайнера Qt Creator, затем доработал QML вручную.
Кстати, дизайнер QML относительно неплох, особенно по сравнению с первой версией. Наглядно показывается и легко меняется binding положения элементов, автоматом подтягивает компоненты из других qml-файлов в проекте. Почти не падал — всего два раза валил QtCreator, когда я пытался задать градиент (ничего страшного не случилось — автосохранение работает), и еще не смог пережевать DelegateModel — наверное, среду стоило обновить. У дизайнера QML, как и у дизайнера Qt Widgets, есть функция предпросмотра:
В результате получился QML карточки с запросом, файл IssueCard.qml
import QtQuick 2.0
import "methods.js" as JS
Rectangle {
id: rectangle1
color: "#f1dada"
radius: 10
gradient: Gradient {
GradientStop {
position: 0.00;
color: "#f5f2d8";
}
GradientStop {
position: 1.00;
color: "#ffffff";
}
}
border.color: "#abfdf4"
width: 300
height: 150
Text {
id: keyText
text: "JIRASERVER-1001"
property string url: ""
anchors.top: parent.top
anchors.topMargin: 8
anchors.left: parent.left
anchors.leftMargin: 8
font.bold: true
font.pixelSize: 14
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Qt.openUrlExternally(parent.url)
}
}
Text {
id: summaryText
y: 51
height: 42
color: "#002f7b"
text: "Create a Global permission for Auditing teams to have full read only access to the instance"
anchors.right: parent.right
anchors.rightMargin: 8
anchors.left: parent.left
anchors.leftMargin: 8
wrapMode: Text.WordWrap
font.pixelSize: 15
textFormat: Text.PlainText
}
Image {
id: priorityImage
x: 276
width: 16
height: 16
anchors.top: parent.top
anchors.topMargin: 9
anchors.right: parent.right
anchors.rightMargin: 8
source: "minor.svg"
}
Image {
id: typeImage
x: 276
width: 16
height: 16
anchors.top: parent.top
anchors.topMargin: 9
anchors.right: priorityImage.left
anchors.rightMargin: 4
source: ""
}
Text {
id: dateText
x: 198
y: 31
color: "#949090"
text: "13.03.2018 17:11"
anchors.right: parent.right
anchors.rightMargin: 8
font.pixelSize: 12
}
Text {
id: creatorText
y: 31
color: "#949090"
text: "Chung Park Chan"
anchors.left: parent.left
anchors.leftMargin: 8
font.pixelSize: 12
}
Text {
id: assigneeText
x: 218
y: 128
text: "Kiran Shekhar"
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
anchors.rightMargin: 8
anchors.right: parent.right
font.pixelSize: 12
}
}
Для заполнения карточки по запросу добавим новое свойство (property) issue. Свойство даст нам возможность передавать в карточку запрос со всем его содержимым извне за одно присвоение.
property var issue: null
И в сигнале на его изменение напишем код, разбирающий значения и распихивающий их по нужным визуальным компонентам.
onIssueChanged: {
var self = JS.getValue(issue,"self")
var re = new RegExp("(https*://[^/]+/).+")
var key = JS.getValue(issue,"key")
var url = self.replace(re,'$1')+'browse/'+key
keyText.text = key
keyText.url = url
summaryText.text = JS.getValue(issue,"fields/summary")
dateText.text = (new Date(JS.getValue(issue,"fields/created"))).toLocaleString()
creatorText.text = JS.getValue(issue,"fields/creator/displayName")
var v = JS.getValue(issue,"fields/assignee/displayName")
assigneeText.text = v === null ? "(no assigned)" : v
var img = JS.getValue(issue,"fields/priority/iconUrl")
var txt = JS.getValue(issue,"fields/priority/name")
priorityImage.source = typeof img == 'undefined' || img === null ? "" : img
img = JS.getValue(issue,"fields/issuetype/iconUrl")
typeImage.source = typeof img == 'undefined' || img === null ? "" : img
}
Как видите, здесь я часто использую функцию JS.getValue, я ее написал для упрощения выборки значения из сложной структуры JSON (если оно там есть), хотя сама функция довольно проста:
function getValue(json, path)
{
var arr = path.split('/');
for(var i=0; i<arr.length && json; i++) {
json = json[arr[i]];
}
return json;
}
Функция лежит в файле methods.js, подключенном в начале IssueCard.qml
Описываем колонку карточек
Теперь нужно карточки организовать в прокручиваемую по вертикали колонку. Прокрутка очень удобна, когда карточек много. Для прокрутки нужен ListView. Среди примеров, идущих в комплекте с Qt есть пример «QML Dynamic View Ordering Tutorial 3 — Moving Dragged Items», в нём dynamicview.qml — это практически то, что нам нужно, копируем его в проект под именем KanbanColumn.qml.
Только нужно сделать пару доработок
1) Добавить к колонке заголовок и сделать у объекта верхнего уровня свойство, чтобы присваивать название колонки извне.
Rectangle {
id: root
// новое свойство
property string title: ""
... // остальной код
// Заголовок столбца
Rectangle {
id: titleRect
anchors {
top: parent.top
left: parent.left
right: parent.right
margins: 2
}
color: "#cfe5ff"
height: titleText.height+10
Text {
id: titleText
text: root.title
font.bold: true
horizontalAlignment: Text.AlignHCenter
font.pointSize: 12
anchors.centerIn: parent
}
}
}
2) Так как карточка запроса у нас теперь отдельный цельный объект, то заменяем вывод, сделанный в примере через Column и несколько Text, на наш IssueCard
Rectangle {
id: content
...
width: dragArea.width; height: column.implicitHeight + 4
color: dragArea.held ? "lightsteelblue" : "white"
Behavior on color { ColorAnimation { duration: 100 } }
radius: 2
...
Column {
id: column
anchors { fill: parent; margins: 2 }
Text { text: 'Name: ' + name }
Text { text: 'Type: ' + type }
Text { text: 'Age: ' + age }
Text { text: 'Size: ' + size }
}
}
Item {
id: content
...
width: dragArea.width; height: card.height + 4
...
IssueCard {
id: card
issue: issueRecord
anchors { fill: parent; margins: 2 }
}
// Закрашивание карточки при перетаскивании мышью
Rectangle {
anchors.fill: parent
color: "lightsteelblue"
visible: dragArea.held // показывать только при перетаскивании
opacity: 0.5
}
}
С колонкой дизайнер нам не поможет, потому что он не переваривает DelegateModel. С другой стороны, нам не особо он и нужен, всё можно сделать вручную.
Окно для доски
Теперь нужно собрать колонку в общее окно. Создаем файл KanbanWindow.qml, в нем дизайнером размещаем нужные поля.
В простейшем виде получается так:
import QtQuick 2.0
import QtQuick.Controls 1.2
Rectangle {
id: rectangle1
width: 640
height: 480
color: "#e0edf6"
clip: true
Item {
id: row1
anchors {
top: parent.top
left: parent.left
right: parent.right
margins: 4
}
height: queryTE.height
TextField {
id: queryTE
text: "file:///C:/Projects/qml/search.json"
anchors.rightMargin: 4
anchors.right: goButton.left
anchors.left: parent.left
anchors.leftMargin: 0
}
Button {
id: goButton
text: qsTr("Go")
anchors.right: parent.right
onClicked: JS.readIssues(queryTE.text)
}
}
ListView {
anchors{
top: row1.bottom
bottom: parent.bottom
right: parent.right
left: parent.left
margins: 4
}
orientation: ListView.Horizontal
clip: true
}
}
В ListView надо указать, в свойстве delegate
, что элементы модели будут показываться в виде колонок KanbanColumn, в каждую из которых надо передать список запросов, назовем его issueList
. Также создадим пустую модель и тоже дадим ей имя model
.
Rectangle {
property var mainModel: []
...
ListView {
...
model: ListModel { id: model }
delegate: KanbanColumn {
anchors.top: parent.top
anchors.bottom: parent.bottom
// 'groupName'
title: groupName
issues: issueList
}
}
}
Выше я еще создал свойство mainModel
— оно нам послужит для временного хранения данных.
И не забыть вставить KanbanWindow в окно приложения:
ApplicationWindow {
id: applicationWindow1
visible: true
width: 649
height: 480
title: qsTr("Hello World")
...
KanbanWindow {
anchors.fill: parent
}
}
Пишем код для вызова REST API
Самое время сделать код, который будет получать список запросов из Jira и заполнять модели в QML.
В QML имеется, хоть и ограниченная, но поддержка XMLHttpRequest и JSON-парсер (на хабре есть подробная статья BlackRaven86). Поэтому у нас есть всё, чтобы написать обращение к серверу и разбор ответа.
function readIssuesSimple(queryUrl)
{
var doc = new XMLHttpRequest();
doc.onreadystatechange = function() {
if (doc.readyState == XMLHttpRequest.DONE) {
var data = JSON.parse(doc.responseText);
mainModel = data["issues"]
model.clear()
var list = mainModel
// группируем запросы по исполнителям
var gPath = "fields/assignee/displayName"
var models = {}
for(var i in list) {
var item = list[i]
var g = getValue(item, gPath)
if(!(g in models))
models[g] = []
models[g].push({ issueRecord: item } )
}
// собрали списки запросов, передаем их в модель QML
// модель будет содержать столько записей, сколько найдено групп
for(g in models) {
var iss = models[g]
if(g === null)
g = '(null)'
// здесь 'model' - имя модели в QML
model.append({
groupName: g,
issueList: iss
});
}
}
}
doc.open("GET", queryUrl);
doc.send();
}
Функция запрашивает с сервера (или из локального файла) список запросов, парсит json из ответа, группирует запросы по исполнителям и заполняет модель в QML.
Подключаем функцию к кнопке
Button {
id: goButton
text: qsTr("Go")
anchors.right: parent.right
onClicked: JS.readIssuesSimple(queryTE.text)
}
И проверяем работу:
В принципе, доска готова. Далее можно заниматься ее улучшениями и развитием.
Чуть не забыл — когда вы попробуете указать URL к настоящему серверу Jira, например, такой:
https://jira.atlassian.com/rest/api/2/search?maxResults=50&jql=project = JRASERVER and updated <= -1w and assignee is not empty ORDER BY updated ASC
и вы под Windows, то у вас, скорее всего, ничего не получится. Проблема в SSL — Qt Creator, запуская программу под отладчиком, не прописывает в окружении путь к библиотекам OpenSSL. Скопируйте libeay32.dll и ssleay32.dll к созданному экзешнику и наслаждайтесь.
LocalStorage для сохранения и восстановления параметров
Чтобы не вводить каждый раз URL к серверу Jira, стоит сохранять введенную строку и восстанавливать ее при запуске. И да, QML умеет в LocalStorage.
Напишем функции чтения и сохранения параметров.
function loadSettings()
{
var dbConn = LocalStorage.openDatabaseSync("JKanban", "1.0", "", 1000000);
dbConn.transaction(
function(tx) {
// Create the database if it doesn't already exist
tx.executeSql('CREATE TABLE IF NOT EXISTS Settings(skey TEXT, svalue TEXT)');
var rs = tx.executeSql('select skey, svalue from Settings')
var r = ""
var c = rs.rows.length
for(var i = 0; i < rs.rows.length; i++) {
var skey = rs.rows.item(i).skey
var svalue = rs.rows.item(i).svalue
if(skey === 'query')
queryTE.text = svalue
}
}
)
}
function saveSetting(skey, svalue)
{
var dbConn = LocalStorage.openDatabaseSync("JKanban", "1.0", "", 1000000);
dbConn.transaction(
function(tx)
{
tx.executeSql('delete from Settings where skey = ?', [ skey ]);
tx.executeSql('INSERT INTO Settings VALUES(?, ?)', [ skey, svalue ]);
}
)
}
Добавим вызов сохранения параметров…
function readIssuesSimple(queryUrl)
{
saveSetting('query',queryUrl)
… и их восстановление при создании KanbanWindow
Rectangle {
id: rectangle1
width: 640
height: 480
color: "#e0edf6"
clip: true
Component.onCompleted: JS.loadSettings()
....
Добавляем варианты группировки
Сделав группировку по исполнителям, логично сделать возможность выбора и других вариантов группировки — по статусу, по приоритету и так далее. Так появилась панелька параметров группировки KanbanParams.qml.
import QtQuick 2.0
import QtQuick.Controls 1.2
import QtQuick.LocalStorage 2.0
import "methods.js" as JS
Item {
width: 480
height: cbGroupField.height
property alias groupVariant: cbGroupField.currentIndex
property string groupValuePath: cbGroupField.model.get(cbGroupField.currentIndex).namePath
property alias groupList: groupsTE.text
Text {
id: label
height: cbGroupField.height
text: qsTr("Группировать:")
verticalAlignment: Text.AlignVCenter
}
ComboBox {
id: cbGroupField
anchors { left: label.right; leftMargin: 4 }
model: ListModel {
ListElement {
text: qsTr("по статусам")
namePath: "fields/status/name"
}
ListElement {
text: qsTr("по исполнителям")
namePath: "fields/assignee/displayName"
}
ListElement {
text: qsTr("по создателям")
namePath: "fields/creator/displayName"
}
ListElement {
text: qsTr("по типам запросов")
namePath: "fields/issuetype/name"
}
ListElement {
text: qsTr("по приоритетам")
namePath: "fields/priority/name"
}
}
}
TextField {
id: groupsTE
text: ''
anchors {
right: buttonGroups.left
rightMargin: 4
left: cbGroupField.right
leftMargin: 4
}
}
Button {
id: buttonGroups
text: qsTr("Перерисовать")
anchors.right: parent.right
onClicked: JS.repaintKanban()
}
}
Как видите, здесь ComboBox содержит модель с возможными вариантами группировки, и в каждом элементе прописан путь в JSON к значению, которое будет использоваться для определения группы. Таким образом количество вариантов группировок по желанию можно расширить.
На верхнем уровне определены свойства, два из которых — алиасы к внутренним значениям. Алиасы нужны, чтобы можно было присвоить нужное значение, начитанное из LocalStorage. Что же касается свойства groupValuePath:
property string groupValuePath: cbGroupField.model.get(cbGroupField.currentIndex).namePath
то оно просто возвращает путь к значению для текущего варианта группировки.
Вставляем KanbanParams в KanbanWindow и у нас получается такое окошко:
Я не буду подробно расписывать, как обрабатываются параметры, потому что мне надоело писать эту статью, смотрите в коде.
Что дальше?
Получившейся доской уже можно пользоваться для просмотра текущей ситуации с запросами, но можно ее улучшить:
1. Сделать сортировку карточек в столбцах. Например, по приоритету запроса. И цветовую дифференциацию штанов, пардон, запросов по приоритетам и типам запросов. Я пробовал — очень удобно, рекомендую.
2. Сделать перетаскивание карточек между столбцами с присвоением значения, соответствующего новому столбцу. Кстати, статус таким образом не изменить, поскольку в Jira статус меняется не присвоением, а переходом (transition).
3. Сделать ввод новых запросов прямо в доске.
4. Для предыдущих двух пунктов потребуется авторизация. Надо делать.
5. Поскольку здесь нет ничего, кроме QML, проект можно собрать под Android и iOS — должно работать без переделок.
Код выложен на GitHub.
Автор: sshmakov