Здесь я расскажу, как сделать канбан-доску для проекта в Jira, пользуясь только QML и JavaScript. С небольшими доработками вместо Jira вы можете использовать любой другой трекер, имеющий REST API.
https://jira.mycompany.ru/browse/PROJECT-1234
browse
, дописываем к нему rest/api/2/
— и у нас получается базовая часть адреса REST APIhttps://jira.mycompany.ru/rest/api/2/
GET https://jira.mycompany.ru/rest/api/2/issue/PROJECT-1234
customfield_10234
. Чтобы понять, какое поле какому соответствует, воспользуйтесь запросом /rest/api/2/issue
.POST https://jira.mycompany.ru/rest/api/2/issue
PUT https://jira.mycompany.ru/rest/api/2/issue/PROJECT-1234
{
"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
— тоже самое для сложных условий, не умещающихся в строку URLGET https://jira.mycompany.ru/rest/api/2/field
— получить описания всех полей, которые могут использоваться в запросах. project = JRASERVER and updated <= -1w ORDER BY updated DESC
rest/api/2/field
, чтобы определять, под каким идентификатором числится нужное вам поле.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();
}
}
}
}
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 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
}
function getValue(json, path)
{
var arr = path.split('/');
for(var i=0; i<arr.length && json; i++) {
json = json[arr[i]];
}
return json;
}
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
}
}
}
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
}
}
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
}
}
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
— оно нам послужит для временного хранения данных.ApplicationWindow {
id: applicationWindow1
visible: true
width: 649
height: 480
title: qsTr("Hello World")
...
KanbanWindow {
anchors.fill: parent
}
}
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();
}
Button {
id: goButton
text: qsTr("Go")
anchors.right: parent.right
onClicked: JS.readIssuesSimple(queryTE.text)
}
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)
Rectangle {
id: rectangle1
width: 640
height: 480
color: "#e0edf6"
clip: true
Component.onCompleted: JS.loadSettings()
....
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()
}
}
property string groupValuePath: cbGroupField.model.get(cbGroupField.currentIndex).namePath
К сожалению, не доступен сервер mySQL