· 6 years ago · Sep 12, 2019, 09:55 AM
1// ==UserScript==
2// @name p4u-worklogger-unicorn-com
3// @description JIRA work log in UU
4// @version 2.2.2
5// @namespace https://uuos9.plus4u.net/
6// @author bubblefoil
7// @license MIT
8// @require https://code.jquery.com/jquery-3.2.1.min.js
9// @grant GM_xmlhttpRequest
10// @grant GM_setValue
11// @grant GM_getValue
12// @connect jira.unicorn.com
13// @match https://uuos9.plus4u.net/uu-specialistwtmg01-main/*
14// @run-at document-idle
15// ==/UserScript==
16
17//Test issue - FBLI-7870
18const jiraUrl = 'https://jira.unicorn.com';
19const jiraBrowseIssue = jiraUrl + "/browse";
20const jiraRestApiUrl = jiraUrl + '/rest/api/2';
21const jiraRestApiUrlIssue = jiraRestApiUrl + '/issue';
22const jiraIssueKeyPattern = /([A-Z]+-\d+)/;
23
24class PageCheck {
25
26 isWorkLogFormPage() {
27 //Check that the work log page is loaded by querying for some expected elements
28 if (document.title !== 'Working Time Management') {
29 console.log("Judging by the page title, this does not seem to be the Working Time Management app. Exiting extension script.");
30 return false;
31 }
32 return true;
33 }
34}
35
36const jiraIssueLoaderAnimation = `
37<style>
38 .progress-spinner {
39 width: 16px;
40 height: 16px;
41 -webkit-animation: spin 2s linear infinite; /* Safari */
42 animation: spin 1.5s linear infinite;
43 }
44
45 /* Safari */
46 @-webkit-keyframes spin {
47 0% {
48 -webkit-transform: rotate(0deg);
49 }
50 100% {
51 -webkit-transform: rotate(360deg);
52 }
53 }
54
55 @keyframes spin {
56 0% {
57 transform: rotate(0deg);
58 }
59 100% {
60 transform: rotate(360deg);
61 }
62 }
63</style>
64<svg class="progress-spinner" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 75.76 75.76">
65 <defs>
66 <style>.cls-2 { fill: #2684ff; } .cls-3 { fill: url(#linear-gradient); } .cls-4 { fill: url(#linear-gradient-2); }</style>
67 <linearGradient id="linear-gradient" x1="34.64" y1="15.35" x2="19" y2="30.99" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient>
68 <linearGradient id="linear-gradient-2" x1="38.78" y1="60.28" x2="54.39" y2="44.67" xlink:href="#linear-gradient"/>
69 </defs>
70 <title>Connecting to Jira...</title>
71 <g id="Layer_2">
72 <g id="Blue">
73 <path class="cls-2" d="M72.4,35.76,39.8,3.16,36.64,0h0L12.1,24.54h0L.88,35.76A3,3,0,0,0,.88,40L23.3,62.42,36.64,75.76,61.18,51.22l.38-.38L72.4,40A3,3,0,0,0,72.4,35.76ZM36.64,49.08l-11.2-11.2,11.2-11.2,11.2,11.2Z"/>
74 <path class="cls-3" d="M36.64,26.68A18.86,18.86,0,0,1,36.56.09L12.05,24.59,25.39,37.93,36.64,26.68Z"/>
75 <path class="cls-4" d="M47.87,37.85,36.64,49.08a18.86,18.86,0,0,1,0,26.68h0L61.21,51.19Z"/>
76 </g>
77 </g>
78</svg>
79`;
80
81/**
82 * Enhances the work log table.
83 */
84class LogTableDecorator {
85
86 /**
87 * Finds the JIRA issue references in the work descriptions in the work log table
88 * and replaces them with links.
89 */
90 static findAndLinkifyJiraIssues() {
91 const logTableNodes = document.querySelectorAll('#table-tsitems td.htsItemStyle div.hts_object');
92 const hasTextNodes = (p) => Array.from(p.childNodes).find(n => n.nodeType === 3);
93 Array.from(logTableNodes)
94 .filter(hasTextNodes)
95 .forEach(node => this.replaceIssueByLink(node));
96 }
97
98 static replaceIssueByLink(element) {
99 const issueKeyPatternGlobal = new RegExp(jiraIssueKeyPattern, "g");
100 element.innerHTML = element.innerHTML
101 .replace(issueKeyPatternGlobal, `<a href="${jiraBrowseIssue}/$1" target="_blank">$1</a>`);
102 }
103}
104
105let pageCheck = new PageCheck();
106if (!pageCheck.isWorkLogFormPage()) {
107 // noinspection JSAnnotator
108 return;
109}
110const wtmMessage = {
111 cs: {
112 'wtm.table.day-range.label': 'ČAS MEZI DNY:',
113 'wtm.month.prev.title': 'Předchozí měsíc',
114 'wtm.month.next.title': 'Následující měsíc',
115 },
116 en: {
117 'wtm.table.day-range.label': 'TIME BETWEEN DAYS:',
118 'wtm.month.prev.title': 'Previous month',
119 'wtm.month.next.title': 'Next month',
120 }
121};
122
123const _t = function (messageCode) {
124 if (!messageCode) {
125 console.warn('Invalid I18N message code: ', messageCode);
126 return '?';
127 }
128 const getBundle = function () {
129 const language = WtmWorktableModel.language();
130 if (wtmMessage.hasOwnProperty(language)) {
131 return wtmMessage[language];
132 }
133 return wtmMessage.cs
134 };
135 const bundle = getBundle();
136 if (!bundle.hasOwnProperty(messageCode)) {
137 console.warn(`I18N message "${messageCode}" is not defined for "${WtmWorktableModel.language()}`);
138 return messageCode;
139 }
140 return bundle[messageCode];
141};
142
143class WtmDateTime {
144
145 /**
146 * Returns parsed date as an array of fields: [day, month, year]. Months are counted from 1.
147 * @param {string} selectedDate
148 * @return {number[]}
149 */
150 static parseDate(selectedDate) {
151 const dateParts = selectedDate.split(/[.\/]/);
152 const dateFields = dateParts.length === 3 && dateParts.map(Number) || [NaN, NaN, NaN];
153 if (WtmWorktableModel.language() === 'cs') {
154 return dateFields;
155 } else {
156 const [month, day, year] = dateFields;
157 return [day, month, year];
158 }
159 }
160
161 static parseDateTime(selectedDate, selectedTime) {
162 const [day, month, year] = this.parseDate(selectedDate);
163 const [hour, minute] = selectedTime.split(':').map(Number);
164 return new Date(year, month - 1, day, hour, minute, 0, 0);
165 }
166}
167
168/**
169 * Access methods to the WTM time table view.
170 */
171class WtmWorktableModel {
172
173 static language() {
174 return document.getElementsByClassName("uu5-bricks-language-selector-code-text")[0].textContent;
175 }
176
177 static monthlyDetailTopTimeColumn() {
178 return document.querySelector('.uu5-common-div .uu-specialistwtm-worker-monthly-detail-top-time-column');
179 }
180
181 static timeTable() {
182 return document.querySelector('table.uu5-bricks-table-table');
183 }
184
185 /**
186 * Reads the day of month from a time table row.
187 * @param {HTMLTableRowElement} tableRow
188 * @return {number|NaN} Day of month, 0 - 31, or NaN.
189 */
190 static getDay(tableRow) {
191 const dateCellText = tableRow.cells[1].innerText;
192 const dateFields = WtmDateTime.parseDate(dateCellText);
193 return dateFields[0];
194 }
195
196 /**
197 * Reads logged working time in minutes from a time table row.
198 * @param {HTMLTableRowElement} tableRow
199 * @return {number|NaN} Minutes of work, or NaN.
200 */
201 static getTimeInMinutes(tableRow) {
202 const dateCellText = tableRow.cells[2].innerText;
203 const match = dateCellText.match(/(\d\d)[:](\d\d)/);
204 return match && 60 * Number(match[1]) + Number(match[2]) || NaN;
205 }
206
207 /**
208 * Filters table rows by given range of days of month.
209 * @param {number} dayFrom
210 * @param {number} dayTo
211 * @return {Promise<HTMLTableRowElement[]>}
212 */
213 static rowsBetweenDays(dayFrom, dayTo) {
214 return new Promise(resolve => {
215 const timeTable = WtmWorktableModel.timeTable();
216 const firstDay = Math.min(dayFrom, dayTo);
217 const lastDay = Math.max(dayFrom, dayTo);
218 const rowsInRange = [].filter.call(timeTable.rows, (row, idx) => {
219 return idx > 0 && firstDay <= WtmWorktableModel.getDay(row) && WtmWorktableModel.getDay(row) <= lastDay;
220 });
221 resolve(rowsInRange);
222 })
223 }
224
225 /**
226 *
227 * @param dayFrom
228 * @param dayTo
229 * @return {Promise<number>} Sum of time in selected day range in minutes.
230 */
231 static minutesBetween(dayFrom, dayTo) {
232 return this
233 .rowsBetweenDays(dayFrom, dayTo)
234 .then((rows) => new Promise(resolve => {
235 const minutesTotal = rows
236 .map((row) => WtmWorktableModel.getTimeInMinutes(row))
237 .reduce((acc, minutes) => acc + minutes, 0);
238 resolve(minutesTotal);
239 }));
240 }
241}
242
243/**
244 * Takes care of Time table extension view.
245 */
246class WtmWorktableView {
247
248 constructor() {
249 }
250
251 worktableSumViewShow() {
252 if (document.getElementById('wtt-time-range-form')) {
253 console.log('WTM Extension: Work table already enhanced.');
254 this.updateSum();
255 return;
256 }
257 console.log('WTM Extension: enhancing work table');
258 const today = new Date();
259 const dayOfWeek = (today.getDay() + 6) % 7;
260 const lastMonday = Math.max(today.getDate() - dayOfWeek, 1);
261 const nextSunday = Math.min(lastMonday + 6, 31);
262 WtmWorktableModel.monthlyDetailTopTimeColumn()
263 .insertAdjacentHTML(
264 'beforeend',
265 `<div id="wtt-time-range-form" class="uu5-common-div uu-specialistwtm-worker-monthly-detail-top-time-column">
266 <span class="uu5-bricks-span uu5-bricks-lsi-item uu5-bricks-lsi uu-specialistwtm-worker-monthly-detail-top-total-time-label" style="width: max-content; min-width: 8em;">${_t('wtm.table.day-range.label')}</span>
267 <input class="uu5-bricks-text uu5-common-text uu-specialistwtm-worker-monthly-detail-table-form-date" type="number" id="wtt-day-from" value="${lastMonday}" min="1" max="31" style="width: 4em; margin: 0.25em">
268 <input class="uu5-bricks-text uu5-common-text uu-specialistwtm-worker-monthly-detail-table-form-date" type="number" id="wtt-day-to" value="${nextSunday}" min="1" max="31" style="width: 4em; margin: 0.25em">
269 <span id="wtt-time-in-range-sum" class="uu5-bricks-span uu-specialistwtm-worker-monthly-detail-top-total-time">${WtmWorktableView.formatToHours(0)}</span>
270 </div>`
271 );
272 this.getDayFromInput().onchange = () => this.updateSum();
273 this.getDayFromInput().onclick = () => this.updateSum();
274 this.getDayToInput().onchange = () => this.updateSum();
275 this.getDayToInput().onclick = () => this.updateSum();
276 this.updateSum().catch((e) => console.warn(e));
277 }
278
279 getDayToInput() {
280 return document.getElementById('wtt-day-to');
281 }
282
283 getDayFromInput() {
284 return document.getElementById('wtt-day-from');
285 }
286
287 async updateSum() {
288 const dFrom = Number(this.getDayFromInput().value);
289 const dTo = Number(this.getDayToInput().value);
290 document.getElementById('wtt-time-in-range-sum').innerText = '-h';
291 const minutesInRange = await WtmWorktableModel.minutesBetween(dFrom, dTo);
292 document.getElementById('wtt-time-in-range-sum').innerText = WtmWorktableView.formatToHours(minutesInRange);
293 }
294
295 static formatToHours(minutes) {
296 return ` ${Number(Math.round(minutes / 60 * 100) / 100).toLocaleString(WtmWorktableModel.language())}h`;
297 }
298}
299
300class WtmDialog {
301
302 static descArea() {
303 return document.getElementsByTagName("textarea")[0];
304 }
305
306 static datePicker() {
307 return document.getElementsByName("date")[0]
308 .lastChild
309 .firstChild
310 .firstChild
311
312 }
313
314 static timeFrom() {
315 return document.getElementsByName("timeFrom")[0]
316 .lastChild
317 .firstChild
318 .firstChild;
319 }
320
321 static timeTo() {
322 return document.getElementsByName("timeTo")[0]
323 .lastChild
324 .firstChild
325 .firstChild;
326 }
327
328 static artifactField() {
329 return document.getElementsByName("subject")[0]
330 .lastChild
331 .firstChild
332 .firstChild;
333 }
334
335 static dateFrom() {
336 return WtmDateTime.parseDateTime(this.datePicker().value, this.timeFrom().value);
337 }
338
339 static dateTo() {
340 return WtmDateTime.parseDateTime(this.datePicker().value, this.timeTo().value);
341 }
342
343 static getDurationSeconds() {
344 const dateFrom = WtmDialog.dateFrom();
345 const dateTo = WtmDialog.dateTo();
346 if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) {
347 return 0;
348 }
349 const durationMillis = dateTo - dateFrom;
350 return durationMillis > 0 ? durationMillis / 1000 : 0;
351 }
352
353 /** Returns the OK button. It is an <a> element containing a structure of spans. */
354 static buttonNextItem() {
355 return WtmDialog.highRateNode().parentElement
356 .lastChild
357 .firstChild
358 .firstChild
359 .firstChild;
360 }
361
362 /** Returns the 'Next item' button. It is an <a> element containing a structure of spans, or null in case of work log update. */
363 static buttonOk() {
364 return WtmDialog.highRateNode().parentElement
365 .lastChild
366 .lastChild
367 .firstChild
368 .firstChild;
369 }
370
371 static registerKeyboardShortcuts() {
372 WtmDialog.buttonOk().title = "Ctrl + Enter";
373 $(document).on("keydown", e => {
374 if (e.keyCode === 13 && e.ctrlKey) {
375 WtmDialog.buttonOk().click();
376 }
377 });
378 }
379
380 /** Adds mnemonics (access keys) to the form buttons. */
381 static registerAccessKeys() {
382 this.addMnemonic(document.getElementById('form-btn-next-day_label'), "n");
383 this.addMnemonic(document.getElementById('form-btn-next_label'), "p");
384 }
385
386 /**
387 *
388 * @param {HTMLElement} element
389 * @param {string} key
390 */
391 static addMnemonic(element, key) {
392 if (element && key && key.length === 1 && element.innerText.indexOf(key) > 0) {
393 element.innerHTML = element.innerText.replace(key, `<u>${key}</u>`);
394 element.accessKey = key;
395 }
396 }
397
398 static highRateNode() {
399 return document.getElementsByName("highRate")[0];
400 }
401}
402
403/**
404 * JIRA API connector.
405 */
406class Jira4U {
407
408 constructor() {
409 }
410
411 static tryParseIssue(desc) {
412 console.log(`Parsing description: ${desc}`);
413 if (typeof desc !== "string") {
414 return new WorkDescription();
415 }
416 return WorkDescription.parse(desc);
417 }
418
419 /**
420 * @param {string} key JIRA issue key string
421 * @param {Function} onload
422 * @param {Function} onerror
423 * @param {?Function} onprogress Optional loading progress callback
424 */
425 loadIssue(key, onload, onerror, onprogress) {
426 // noinspection JSUnresolvedFunction
427 GM_xmlhttpRequest(
428 {
429 method: 'GET',
430 headers: {"Accept": "application/json"},
431 url: jiraRestApiUrlIssue.concat("/", key),
432 onreadystatechange: onprogress || function (res) {
433 console.log("Request state: " + res.readyState);
434 },
435 onload: onload,
436 onerror: onerror
437 }
438 );
439 }
440
441 /**
442 * @param {string} workInfo.key JIRA issue key string.
443 * @param {Date} workInfo.started The date/time the work on the issue started.
444 * @param {number} workInfo.duration in seconds.
445 * @param {string} workInfo.comment The work log comment.
446 * @param {Function} [workInfo.onSuccess] Callback to be invoked on response from JIRA.
447 * @param {Function} [workInfo.onError] Callback to be invoked in case the JIRA request fails.
448 * @param {Function} [workInfo.onReadyStateChange] Callback to be invoked when the request state changes.
449 */
450 logWork(workInfo) {
451 console.log(`Sending a work log request. Issue=${workInfo.key}, Time spent=${workInfo.duration}minutes, Comment="${workInfo.comment}"`);
452 // noinspection JSUnresolvedFunction
453 GM_xmlhttpRequest(
454 {
455 method: 'POST',
456 headers: {
457 "Content-Type": "application/json",
458 //Disable the cross-site request check on the JIRA side
459 "X-Atlassian-Token": "nocheck",
460 //Previous header does not work for requests from a web browser
461 "User-Agent": "xx"
462 },
463 data: `{
464 "timeSpentSeconds": ${workInfo.duration},
465 "started": "${this.toIsoString(workInfo.started)}",
466 "comment": "${workInfo.comment}"
467 }`,
468 url: jiraRestApiUrlIssue.concat("/", workInfo.key, "/worklog"),
469 onreadystatechange: workInfo.onReadyStateChange,
470 onload: workInfo.onSuccess,
471 onerror: workInfo.onError
472 }
473 );
474 }
475
476 /**
477 * Converts a date to a proper ISO formatted string, which contains milliseconds and the zone offset suffix.
478 * No other date formats are recognized by JIRA.
479 * @param {Date} date Valid Date object to be formatted.
480 * @returns {string}
481 */
482 toIsoString(date) {
483 let offset = -date.getTimezoneOffset(),
484 offsetSign = offset >= 0 ? '+' : '-',
485 pad = function (num) {
486 const norm = Math.floor(Math.abs(num));
487 return (norm < 10 ? '0' : '') + norm;
488 };
489 return date.getFullYear()
490 + '-' + pad(date.getMonth() + 1)
491 + '-' + pad(date.getDate())
492 + 'T' + pad(date.getHours())
493 + ':' + pad(date.getMinutes())
494 + ':' + pad(date.getSeconds())
495 + '.' + String(date.getUTCMilliseconds()).padStart(3, "0").substr(0, 3)
496 + offsetSign + pad(offset / 60) + pad(offset % 60);
497 }
498
499}
500
501/**
502 * JIRA issue visualisation functions.
503 */
504class IssueVisual {
505 constructor() {
506 this._jiraLogWorkEnabledValue = "p4u.jira.worklog.enabled";
507 const $parsedJiraIssue = $("#parsedJiraIssue");
508 if ($parsedJiraIssue.length === 0) {
509 this.addToForm();
510 }
511 this._jiraLogWorkEnabled = document.getElementById("jiraLogWorkEnabled");
512 this._issue = null;
513 }
514
515 /**
516 * Adds jira issue container to the form.
517 */
518 addToForm() {
519 // noinspection JSUnresolvedFunction
520 const logWorkEnabled = GM_getValue(this._jiraLogWorkEnabledValue, true);
521 const checked = logWorkEnabled ? `checked="checked"` : '';
522 console.log("Adding JIRA Visual into form");
523 // noinspection CssUnknownTarget
524
525 const transition = "-webkit-transition: width 0.25s; transition-delay: 0.5s;";
526 const trackerStyle = "width: 100%; border-collapse: collapse; height: 0.75em; margin-top: 0.4em;";
527
528 //.uu5-forms-label uu5-forms-input-m
529 const jiraBarNode = document.createElement('div');
530 jiraBarNode.innerHTML = (`
531 <div>
532 <div>
533 <input type="checkbox" id="jiraLogWorkEnabled" ${checked} accesskey="j" style=" margin-bottom: 3px; vertical-align: bottom; ">
534 <label for="jiraLogWorkEnabled" class="uu5-forms-input-m">Vykázat na <u>J</u>IRA issue</label>
535 </div>
536 <div>
537 <span id="parsedJiraIssue" class="uu5-forms-input-m"></span>
538 </div>
539 </div>
540 <div>
541 <div>
542 <table id="jiraWorkTrackerOriginal" style="${trackerStyle}">
543 <tbody>
544 <tr>
545 <td class="workTracker wtl" id="jiraOrigEstimate" title="Původní odhad:" style="background-color: #89AFD7; padding: 0; ${transition} width: 0;"></td>
546 <td class="workTracker wtr" id="jiraRemainEstimate" title="Zbývající odhad:" style="background-color: #ec8e00; padding: 0; ${transition} width: 0;"></td>
547 <td class="workTracker wt" title="Původní odhad" style="background-color: #cccccc; padding: 0; ${transition} width: 100%"></td>
548 </tr>
549 </tbody>
550 </table>
551 <table id="jiraWorkTrackerLogged" style="${trackerStyle}">
552 <tbody>
553 <tr>
554 <td class="workTracker wtl" id="jiraWorkLogged" title="Vykázáno:" style="background-color: #51a825; padding: 0; ${transition} width: 0;"></td>
555 <td class="workTracker wtn" id="jiraWorkLogging" title="Nový výkaz" style="background-color: #51A82580; padding: 0; /*${transition}*/ width: 0"></td>
556 <td class="workTracker wtr" id="jiraWorkRemainTotal" title="Zbývá" style="background-color: #cccccc; padding: 0; /*${transition} */width: 100%"></td>
557 </tr>
558 </tbody>
559 </table>
560 </div>
561 <div>
562 <span id="parsedJiraIssue"></span>
563 </div>
564 </div>
565 `);
566 IssueVisual.insertAfter(jiraBarNode, WtmDialog.highRateNode());
567 const logWorkEnableCheckbox = document.getElementById("jiraLogWorkEnabled");
568 logWorkEnableCheckbox.onclick = () => {
569 // noinspection JSUnresolvedFunction
570 GM_setValue(this._jiraLogWorkEnabledValue, logWorkEnableCheckbox.checked);
571 this.trackWork();//To reset the added work log on the tracker
572 };
573 }
574
575 static insertAfter(newNode, referenceNode) {
576 referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
577 }
578
579 isJiraLogWorkEnabled() {
580 return this._jiraLogWorkEnabled.checked;
581 }
582
583 /**
584 * Display a loaded JIRA issue in the form as a link.
585 * @param issue The JIRA issue object as fetched from JIRA rest API
586 * @param {string} issue.key The key of the JIRA issue, e.g. XYZ-1234
587 * @param {string} issue.fields.summary The JIRA issue summary, i.e. the title of the ticket.
588 */
589 showIssue(issue) {
590 this._issue = issue;
591 IssueVisual.$jiraIssueSummary().empty().append(`<a href="${jiraBrowseIssue}/${issue.key}" target="_blank">${issue.key} - ${issue.fields.summary}</a>`);
592 this.trackWork();
593 }
594
595 showIssueLoadingProgress() {
596 IssueVisual.$jiraIssueSummary().empty().append(`${jiraIssueLoaderAnimation}`);
597 this.trackWork();
598 }
599
600 /**
601 * Sets the currently displayed JIRA issue to null and resets all the visualisation.
602 */
603 resetIssue() {
604 this._issue = null;
605 IssueVisual.resetWorkTracker();
606 }
607
608 /**
609 * Display a visual work log time tracker of the current JIRA issue in the form.
610 */
611 trackWork() {
612 if (this._issue) {
613 const orig = this._issue.fields.timetracking.originalEstimateSeconds || 0;
614 const remain = this._issue.fields.timetracking.remainingEstimateSeconds || 0;
615 const logged = this._issue.fields.timetracking.timeSpentSeconds || 0;
616 const added = this.isJiraLogWorkEnabled() ? WtmDialog.getDurationSeconds() : 0;
617 const total = Math.max(orig + remain, logged + added);
618 const percentOfTotal = (x) => total > 0 ? x / total * 100 : 0;
619 const setWidth = (id, w) => {
620 document.getElementById(id).style.width = `${Math.round(w)}%`;
621 };
622 const setTitle = (id, t) => {
623 const e = document.getElementById(id);
624 e.title = e.title.split(':')[0] + ': ' + t || "0h";
625 e.alt = e.title;
626 };
627 setWidth('jiraOrigEstimate', percentOfTotal(orig));
628 setTitle('jiraOrigEstimate', this._issue.fields.timetracking.originalEstimate);
629 setWidth('jiraRemainEstimate', percentOfTotal(remain));
630 setTitle('jiraRemainEstimate', this._issue.fields.timetracking.remainingEstimate);
631 setWidth('jiraWorkLogged', percentOfTotal(logged));
632 setTitle('jiraWorkLogged', this._issue.fields.timetracking.timeSpent);
633 setWidth('jiraWorkLogging', percentOfTotal(added));
634 const remainTotal = 100 - percentOfTotal(logged + added);
635 setWidth('jiraWorkRemainTotal', remainTotal);
636 const remainCell = document.getElementById('jiraWorkRemainTotal');
637 remainCell.style.display = (remainTotal === 0 && percentOfTotal(added) !== 0) ? "none" : null;//Chrome renders zero width as 1px
638 }
639 else
640 IssueVisual.resetWorkTracker();
641 }
642
643 static resetWorkTracker() {
644 document.getElementById('jiraOrigEstimate').style.width = `0%`;
645 }
646
647 /**
648 * The default content to be displayed when no issue has been loaded.
649 */
650 showIssueDefault() {
651 IssueVisual.$jiraIssueSummary().empty().append(`<span>Zadejte kód JIRA Issue na začátek Popisu činnosti.</span>`);
652 this.resetIssue();
653 }
654
655 issueLoadingFailed(responseDetail) {
656 this.resetIssue();
657 let responseErr = responseDetail.response;
658 let key = responseDetail.key;
659 if (responseErr.status === 401) {
660 IssueVisual.$jiraIssueSummary().empty().append(`JIRA autentifikace selhala. <a href="${jiraBrowseIssue}/${key}" target="_blank">Přihlaste se do JIRA.</a>`);
661 return;
662 }
663 if (responseErr.status === 404
664 && responseErr.responseHeaders
665 && responseErr.responseHeaders.match(/content-type:\sapplication\/json/) != null) {
666 let error = JSON.parse(responseErr.responseText);
667 if (error.errorMessages) {
668 IssueVisual.$jiraIssueSummary().empty().append(`<span>Nepodařilo se načíst issue ${key}. Chyba: ${error.errorMessages.join(", ")}.</span>`);
669 return;
670 }
671 }
672 IssueVisual.$jiraIssueSummary().empty().append(`<span>Něco se přihodilo. Budete muset vykázat do JIRA ručně.</span>`);
673 }
674
675 static $jiraIssueSummary() {
676 return $(document.getElementById("parsedJiraIssue"));
677 }
678
679}
680
681/**
682 * Container for a JIRA issue key + description. It can construct itself by parsing the issue key from work description.
683 */
684class WorkDescription {
685
686 constructor(issueKey = null, descriptionText = "") {
687 this._issueKey = issueKey;
688 this._descriptionText = descriptionText;
689 }
690
691 static parse(workDescriptionText) {
692 if (typeof workDescriptionText === "string") {
693 let segments = workDescriptionText.match(jiraIssueKeyPattern);
694 if (segments != null) {
695 let key = segments[1];
696 return new WorkDescription(key, workDescriptionText.replace(key, "").trim());
697 }
698 }
699 return new WorkDescription();
700 }
701
702 get issueKey() {
703 return this._issueKey;
704 }
705
706 get descriptionText() {
707 return this._descriptionText;
708 }
709}
710
711class FlowBasedConfiguration {
712
713 static resolveArtefact(jiraIssue) {
714 if (FlowBasedConfiguration.isFlowBasedJira(jiraIssue)) {
715 if (FlowBasedConfiguration.isIdccProject(jiraIssue)) {
716 if (jiraIssue.type === "Change Request") {
717 return "UNI-BT:USYE.IDCC/CR";
718 } else {
719 return "UNI-BT:USYE.IDCC/IDCC_MAINSCOPE";
720 }
721 } else {
722 if (jiraIssue.projectCode) {
723 if (jiraIssue.projectCode.startsWith("UNI-BT:")) {
724 return jiraIssue.projectCode;
725 } else {
726 return "UNI-BT:" + jiraIssue.projectCode;
727 }
728 }
729 }
730 }
731 return null;
732 }
733
734
735 static isFlowBasedJira(jiraIssue) {
736 return jiraIssue.issueKeyPrefix === "FBLI" || jiraIssue.issueKeyPrefix === "FBCE";
737 }
738
739 static isIdccProject(jiraIssue) {
740 return jiraIssue.system === "FB IDCC";
741 }
742}
743
744/**
745 * Wraps the rest of the script, mainly the steps that are executed when the document is loaded.
746 */
747class P4uWorklogger {
748
749 constructor() {
750 // Initialize the page decoration.
751 this.issueVisual = null;
752 this.jira4U = new Jira4U();
753 this._previousDesctiptionValue = null;
754 this._previousIssue = null;
755 }
756
757 workLogFormShow() {
758 this.issueVisual = new IssueVisual();
759 this._previousDesctiptionValue = WtmDialog.descArea().value;
760 this._previousIssue = Jira4U.tryParseIssue(this._previousDesctiptionValue);
761 this.doTheMagic();
762 }
763
764 doTheMagic() {
765 this.issueVisual.showIssueDefault();
766
767 const updateWorkTracker = () => this.issueVisual.trackWork();
768 WtmDialog.timeFrom().onblur = updateWorkTracker;
769 WtmDialog.timeTo().onblur = updateWorkTracker;
770
771 //In case of a Work log update, there may already be some work description.
772 if (WtmDialog.descArea().value) {
773 const wd = Jira4U.tryParseIssue(WtmDialog.descArea().value);
774 this.loadJiraIssue(wd);
775 }
776
777 //Intercept form's confirmation buttons.
778 this.extendButtons();
779 }
780
781 extendButtons() {
782 //The callback function cannot be used directly because the context of 'this' in the callback would be the event target.
783 WtmDialog.buttonOk().onclick = () => this.writeWorkLogToJiraIfEnabled();
784 WtmDialog.buttonNextItem().onclick = () => this.writeWorkLogToJiraIfEnabled();
785 WtmDialog.registerKeyboardShortcuts();
786 WtmDialog.registerAccessKeys();
787 }
788
789 writeWorkLogToJiraIfEnabled() {
790 console.debug(new Date().toISOString(), 'Adding a work log item.');
791 if (this.issueVisual.isJiraLogWorkEnabled()) {
792 this.writeWorkLogToJira();
793 }
794 }
795
796 static fillArtefactIfNeeded(rawJiraIssue) {
797 const artefactField = WtmDialog.artifactField();
798 if (!artefactField.value) {
799 let jiraIssue = P4uWorklogger.mapToHumanJiraIssue(rawJiraIssue);
800 let artefact = FlowBasedConfiguration.resolveArtefact(jiraIssue);
801 if (artefact) {
802 artefactField.value = artefact;
803 //Let the form notice the value update, otherwise the artifact is not submitted
804 artefactField.focus();
805 artefactField.blur();
806 }
807 }
808 }
809
810 static mapToHumanJiraIssue(rawJiraIssue) {
811 let humanReadableIssue = {};
812 const fieldValue = (field) => field ? field.value : null;
813 humanReadableIssue.projectCode = fieldValue(rawJiraIssue.fields.customfield_10174);
814 humanReadableIssue.system = fieldValue(rawJiraIssue.fields.customfield_12271);
815 humanReadableIssue.type = rawJiraIssue.fields.issuetype.name;
816 humanReadableIssue.issueKeyPrefix = rawJiraIssue.fields.project.key;
817 return humanReadableIssue;
818 }
819
820 writeWorkLogToJira() {
821 const wd = Jira4U.tryParseIssue(WtmDialog.descArea().value);
822 if (!wd.issueKey) {
823 return;
824 }
825 const durationSeconds = WtmDialog.getDurationSeconds();
826 if (durationSeconds <= 0) {
827 return 0;
828 }
829 const dateFrom = WtmDialog.dateFrom();
830 console.log(`Logging ${durationSeconds} minutes of work on ${wd.issueKey}`);
831 this.jira4U.logWork({
832 key: wd.issueKey,
833 started: dateFrom,
834 duration: durationSeconds,
835 comment: wd.descriptionText,
836 onSuccess: (res) => {
837 console.info("Work was successfully logged to JIRA.", JSON.parse(res.responseText));
838 //The buttons are probably refreshed. They loose listeners after adding a worklog.
839 setTimeout(() => this.extendButtons(), 500);
840 },
841 onError: (err) => {
842 console.warn("Failed to log work to JIRA. ", err);
843 },
844 onReadyStateChange: function (res) {
845 console.debug("Log work request state changed to: " + res.readyState);
846 }
847 });
848 }
849
850 checkWorkDescriptionChanged(description) {
851 if (this._previousDesctiptionValue !== description) {
852 this._previousDesctiptionValue = description;
853 this.workDescriptionChanged(description);
854 } else console.debug("No description change")
855 }
856
857 /**
858 * @param {string} description The new work description value
859 */
860 workDescriptionChanged(description) {
861 const wd = Jira4U.tryParseIssue(description);
862 if (this._previousIssue.issueKey === null || this._previousIssue.issueKey !== wd.issueKey) {
863 this._previousIssue = wd;
864 this.loadJiraIssue(wd);
865 }
866 }
867
868 loadJiraIssue(wd) {
869 if (wd.issueKey) {
870 let key = wd.issueKey;
871 console.log("JIRA issue key recognized: ", key);
872 this.jira4U.loadIssue(wd.issueKey, response => {
873 console.log(`Loading of issue ${key} completed.`);
874 //Getting into the onload function does not actually mean the status was OK
875 if (response.status === 200) {
876 console.log(`Issue ${key} loaded successfully.`);
877 let rawJiraIssue = JSON.parse(response.responseText);
878 this.issueVisual.showIssue(rawJiraIssue);
879 P4uWorklogger.fillArtefactIfNeeded(rawJiraIssue);
880 } else {
881 console.log(`Failed to load issue ${key}. Status: ${response.status}`);
882 this.issueVisual.issueLoadingFailed({key, response});
883 }
884 }, responseErr => {
885 console.log(`Failed to load issue ${key}. Status: ${responseErr.status}`);
886 this.issueVisual.issueLoadingFailed({key, response: responseErr});
887 }, progress => {
888 if (progress.readyState === 1) {
889 this.issueVisual.showIssueLoadingProgress();
890 }
891 console.log(`Loading jira issue ${key}, state: ${progress.readyState}`);
892 });
893 } else {
894 this.issueVisual.showIssueDefault();
895 }
896 }
897}
898
899/**
900 * Adds month selection buttons.
901 */
902class MonthSelector {
903
904 static getMonthSelectorContainer() {
905 return document.querySelector('.uu-specialistwtm-worker-monthly-detail-top-change-month-dropdown');
906 }
907
908 static getMonthSelector() {
909 return this.getMonthSelectorContainer().firstElementChild;
910 }
911
912 static getMonthSelectorButton() {
913 return this.getMonthSelector().querySelector('button');
914 }
915
916 static getSelectedMonthValue() {
917 return this.getMonthSelectorButton().querySelector('.uu-specialistwtm-worker-monthly-detail-top-month-dropdown-value').innerText;
918 }
919
920 install() {
921 if (MonthSelector.getMonthSelectorContainer().querySelector('span.uu-specialistwtm-worker-monthly-detail-top-back-icon')) {
922 return;
923 }
924 const createArrow = (direction) => {
925 const arrow = document.createElement('span');
926 arrow.classList.add('uu5-bricks-icon', 'uu-specialistwtm-worker-monthly-detail-top-back-icon', 'mdi', 'mdi-chevron-' + direction);
927 return arrow;
928 };
929
930 /**
931 * Creates the month switching callback, which is called after the dropdown menu is shown.
932 * The menu is a div containing an UL element. This list is searched for the current month by the displayed text.
933 * Index of the selected list item is updated and the neighbor item is clicked.
934 *
935 * @param selectedMonthText
936 * @return {function(*): Function}
937 */
938 const selectMonth = (selectedMonthText) => (monthIndexFn) => () => {
939 const dropDown = MonthSelector.getMonthDropDown();
940 if (!dropDown) {
941 console.warn('Month drop-down menu does not exist.');
942 return;
943 }
944 const selectedMonthIndex = Array
945 .from(dropDown.children)
946 .findIndex(li => li.innerText.trim() === selectedMonthText);
947 if (selectedMonthIndex < 0) {
948 console.debug('Cannot find selected month:', selectedMonthText);
949 return;//May leave the menu opened? It may actually be desirable as a fallback scenario.
950 }
951 const newMonthIndex =
952 Math.max(0,
953 Math.min(dropDown.children.length - 1,
954 monthIndexFn(selectedMonthIndex)))
955 || selectedMonthIndex;
956 dropDown.children[newMonthIndex].firstChild.click();//LI contains an A element
957 };
958
959 const arrowClickHandler = (monthIdxUpdateFn) => (event) => {
960 console.trace('WTM Extension', 'Click:', event);
961 //Show the months dropdown
962 MonthSelector.getMonthSelectorButton().click();
963 //Allow browser to render the menu, then click desired month
964 setTimeout(selectMonth(MonthSelector.getSelectedMonthValue())(monthIdxUpdateFn), 0);
965 };
966
967 const arrowLeft = createArrow('left');
968 arrowLeft.onclick = arrowClickHandler(i => i + 1);//Months are in the reversed order
969 arrowLeft.title = _t('wtm.month.prev.title');
970
971 const arrowRight = createArrow('right');
972 arrowRight.onclick = arrowClickHandler(i => i - 1);
973 arrowRight.title = _t('wtm.month.next.title');
974
975 const monthSelector = MonthSelector.getMonthSelector();
976 monthSelector.insertBefore(arrowLeft, monthSelector.firstChild);
977 monthSelector.appendChild(arrowRight);
978 }
979
980 static getMonthDropDown() {
981 return MonthSelector.getMonthSelectorContainer().querySelector('ul.uu5-bricks-dropdown-menu-list');
982 }
983}
984
985const workLogger = new P4uWorklogger();
986const wtmWorktableView = new WtmWorktableView();
987const monthSelector = new MonthSelector();
988
989class WtmDomObserver {
990
991 constructor() {
992 this.observeOptions = {
993 attributes: false,
994 characterData: false,
995 childList: true,
996 subtree: true,
997 attributeOldValue: false,
998 characterDataOldValue: false,
999 };
1000 this.mutationObserver = null;
1001 this.pageReadyMutationOberver = null;
1002 }
1003
1004 observe() {
1005 const hasAddedNodes = (mutation) => mutation.addedNodes.length > 0;
1006 const isWorkDescription = (mutation) => mutation.target.type === 'textarea' && mutation.target.name === 'description';
1007 const isWorkLogForm = (mutation) => affectsNodesWithClass(mutation, 'uu5-bricks-modal-body', 'uu-specialistwtm-create-timesheet-item-modal-container');
1008 const isWorkTable = (mutation) => affectsNodesWithClass(mutation, 'uu-specialistwtm-worker-monthly-detail-container', 'uu-specialistwtm-worker-monthly-detail-table');
1009
1010 const affectsNodesWithClass = (mutation, targetNodeClass, childNodeClass) => {
1011 if (!mutation.target.classList.contains(targetNodeClass)) {
1012 return false;
1013 }
1014 for (const childNode of mutation.target.childNodes) {
1015 if (childNode.classList.contains(childNodeClass)) {
1016 return true;
1017 }
1018 }
1019 return false;
1020 };
1021
1022 this.mutationObserver = new MutationObserver(function (mutations) {
1023 mutations
1024 // .filter(hasAddedNodes)
1025 .forEach((mutation) => {
1026 // console.log(mutation); //I expect to use this functionality frequently
1027 if (isWorkDescription(mutation)) {
1028 workLogger.checkWorkDescriptionChanged(mutation.target.textContent);
1029 }
1030 if (isWorkLogForm(mutation)) {
1031 workLogger.workLogFormShow();
1032 } else if (mutation.target.classList.contains('uu-specialistwtm-create-timesheet-item-buttons-save')) {
1033 console.debug('Buttons changed, re-applying extension.');
1034 workLogger.extendButtons();
1035 }
1036 if (isWorkTable(mutation)) {
1037 wtmWorktableView.worktableSumViewShow();
1038 }
1039
1040 if (MonthSelector.getMonthSelectorContainer()) {
1041 monthSelector.install();
1042 }
1043 });
1044 });
1045
1046 //During page loading, there are tons of mutations. This observer is active until the main page is added, then it disconnects and activates the actual observer.
1047 this.pageReadyMutationOberver = new MutationObserver(function (mutations) {
1048 const isMainPageAddition = (mutation) => hasAddedNodes(mutation) && mutation.type === 'childList' && mutation.target.matches('div.uu5-common-div.uu5-bricks-page-system-layer.plus4u5-app-page-system-layer-wrapper');
1049 if (mutations.some(isMainPageAddition)) {
1050 swapObservers();
1051 }
1052 });
1053
1054 let swapObservers = () => {
1055 if (this.pageReadyMutationOberver) {
1056 this.pageReadyMutationOberver.disconnect();
1057 }
1058 this.mutationObserver.observe(document.body, this.observeOptions);
1059 };
1060 this.pageReadyMutationOberver.observe(document.body, this.observeOptions);
1061 }
1062}
1063
1064const brickObserver = new WtmDomObserver();
1065brickObserver.observe();