├── .eslintignore
├── screenshots
├── popup.png
└── add-worklog-sample.gif
├── jest.config.integration.json
├── jest.config.json
├── chrome-extension
├── img
│ ├── spinner-small.gif
│ ├── ic_error_black_24dp.png
│ ├── open_in_new_window.png
│ ├── ic_delete_black_24dp.png
│ ├── ic_cloud_done_black_24dp.png
│ ├── icon-time-task-512x512.png
│ ├── ic_cloud_upload_black_24dp.png
│ └── ic_cloud_upload_black_48dp.png
├── js
│ ├── open-popup.js
│ ├── content.js
│ ├── gtm-head.js
│ ├── popup.js
│ ├── gtm-tags.js
│ ├── update-script.js
│ ├── jira-parser.js
│ ├── options.js
│ ├── model.js
│ ├── controller.js
│ ├── jira-helper.js
│ └── view.js
├── temp-popup.html
├── manifest.json
├── options.html
├── lib
│ ├── mediator.min.js
│ └── axios.min.js
├── styles.css
└── popup.html
├── jest.config.unit.json
├── .editorconfig
├── .vscode
├── settings.json
└── launch.json
├── tests
├── unit
│ ├── get-rest-issue.json
│ ├── update-script.unit.spec.js
│ ├── jira-helper.unit.spec.js
│ └── jira-parser.unit.spec.js
└── integration
│ ├── browser-manager.int.spec.js
│ ├── browser-manager.js
│ ├── jira-mock.js
│ ├── test-puppeteer.js
│ ├── uitest.int.spec.js
│ └── jira-extension-functions.js
├── CHANGELOG.md
├── .eslintrc.json
├── scripts
├── sync-version.js
├── package.js
├── build-for-test.js
└── deploy.js
├── LICENSE.md
├── .gitignore
├── .circleci
└── config.yml
├── package.json
└── README.md
/.eslintignore:
--------------------------------------------------------------------------------
1 | chrome-extension/lib
2 | chrome-extension/js/gtm-head.js
--------------------------------------------------------------------------------
/screenshots/popup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/screenshots/popup.png
--------------------------------------------------------------------------------
/jest.config.integration.json:
--------------------------------------------------------------------------------
1 | {
2 | "testEnvironment": "jsdom",
3 | "testRegex": "int.spec.js"
4 | }
--------------------------------------------------------------------------------
/jest.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "testEnvironment": "jsdom",
3 | "reporters": [ "default", "jest-junit" ]
4 | }
--------------------------------------------------------------------------------
/screenshots/add-worklog-sample.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/screenshots/add-worklog-sample.gif
--------------------------------------------------------------------------------
/chrome-extension/img/spinner-small.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/spinner-small.gif
--------------------------------------------------------------------------------
/chrome-extension/img/ic_error_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/ic_error_black_24dp.png
--------------------------------------------------------------------------------
/chrome-extension/img/open_in_new_window.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/open_in_new_window.png
--------------------------------------------------------------------------------
/chrome-extension/img/ic_delete_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/ic_delete_black_24dp.png
--------------------------------------------------------------------------------
/jest.config.unit.json:
--------------------------------------------------------------------------------
1 | {
2 | "testEnvironment": "jsdom",
3 | "testRegex": "unit.spec.js",
4 | "reporters": [ "default", "jest-junit" ]
5 | }
--------------------------------------------------------------------------------
/chrome-extension/img/ic_cloud_done_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/ic_cloud_done_black_24dp.png
--------------------------------------------------------------------------------
/chrome-extension/img/icon-time-task-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/icon-time-task-512x512.png
--------------------------------------------------------------------------------
/chrome-extension/img/ic_cloud_upload_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/ic_cloud_upload_black_24dp.png
--------------------------------------------------------------------------------
/chrome-extension/img/ic_cloud_upload_black_48dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alfeugds/jiraworklogtool/HEAD/chrome-extension/img/ic_cloud_upload_black_48dp.png
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.enable": true,
3 | "eslint.run":"onSave",
4 | "eslint.trace.server": "messages",
5 | "eslint.options": {
6 | "configFile": ".eslintrc.json"
7 | },
8 | }
--------------------------------------------------------------------------------
/chrome-extension/js/open-popup.js:
--------------------------------------------------------------------------------
1 | /* global chrome */
2 | var popupWindow = window.open(
3 | chrome.runtime.getURL('popup.html'),
4 | 'Jira Worklog Tool',
5 | 'width=610,height=500'
6 | )
7 | popupWindow.focus()
8 | window.close()
9 |
--------------------------------------------------------------------------------
/chrome-extension/js/content.js:
--------------------------------------------------------------------------------
1 | /* global chrome */
2 | const { id } = chrome.runtime
3 | console.log('content script!', { id })
4 |
5 | const idElem = document.createElement('input')
6 | idElem.setAttribute('id', 'jiraworklog_id')
7 | idElem.setAttribute('value', id)
8 | idElem.setAttribute('type', 'hidden')
9 |
10 | document.body.appendChild(idElem)
11 |
--------------------------------------------------------------------------------
/tests/unit/get-rest-issue.json:
--------------------------------------------------------------------------------
1 | {
2 | "worklogs": [
3 | {
4 | "author": {
5 | "key": "hue@br.com"
6 | },
7 | "comment": "tech onboarding",
8 | "started": "2018-03-26T06:00:00.000+0000",
9 | "timeSpent": "1h 50m",
10 | "id": "55829"
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG.md
2 |
3 | ## 0.4.5 (unreleased)
4 |
5 | Infra:
6 |
7 | - running UI tests in CircleCI
8 | - getting extension ID dynamically to run UI tests
9 | - changing build strategy for extension, generate manifest dynamically for UI tests
10 | - add test result in circleci with jest junit
11 |
12 | Tests:
13 |
14 | - improved UI tests consistency
15 | - refactor UI test spec to reuse functions
--------------------------------------------------------------------------------
/chrome-extension/js/gtm-head.js:
--------------------------------------------------------------------------------
1 | (function (w, d, s, l, i) {
2 | w[l] = w[l] || []; w[l].push({ 'gtm.start':
3 | new Date().getTime(),
4 | event: 'gtm.js' }); var f = d.getElementsByTagName(s)[0]
5 | var j = d.createElement(s); var dl = l != 'dataLayer' ? '&l=' + l : ''; j.async = true; j.src =
6 | 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f)
7 | })(window, document, 'script', 'dataLayer', 'GTM-MZ6T8MS')
8 |
--------------------------------------------------------------------------------
/chrome-extension/temp-popup.html:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 | Jira Worklog Helper
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Launch Program",
11 | "program": "${workspaceFolder}/tests/integration/test-puppeteer.js"
12 | }
13 | ]
14 | }
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true,
5 | "jest": true
6 | },
7 | "extends": [
8 | "standard"
9 | ],
10 | "globals": {
11 | "Atomics": "readonly",
12 | "SharedArrayBuffer": "readonly",
13 | "axios": true,
14 | "fail": false
15 | },
16 | "parserOptions": {
17 | "ecmaVersion": 2018
18 | },
19 | "rules": {
20 | "no-useless-escape": "warn",
21 | "indent": ["error", 2]
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/scripts/sync-version.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const semver = require('semver');
3 |
4 | // Write package.json's versino to the src/manifest.json
5 | const packageJson = require('../package');
6 |
7 | const validVersion = semver.valid(packageJson.version);
8 | console.log(validVersion);
9 |
10 | const manifestLocation = '../chrome-extension/manifest';
11 | const manifest = require(manifestLocation);
12 | manifest.version = validVersion;
13 |
14 | fs.writeFileSync('chrome-extension/manifest.json', JSON.stringify(manifest, null, ' '));
--------------------------------------------------------------------------------
/tests/integration/browser-manager.int.spec.js:
--------------------------------------------------------------------------------
1 | const browserManager = require('./browser-manager')
2 |
3 | describe('Browser Manager', () => {
4 | test('Get browser instance with extension installed', async () => {
5 | try {
6 | const browser = await browserManager.getBrowser()
7 | const { popupUrl } = await browserManager.getExtensionInfo(browser)
8 |
9 | expect(browser.newPage).not.toBeNull()
10 | expect(popupUrl).toMatch(/chrome-extension:\/\/\w{32}\/popup.html/)
11 |
12 | await browser.close()
13 | } catch (e) {
14 | fail(e)
15 | }
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/chrome-extension/js/popup.js:
--------------------------------------------------------------------------------
1 | /* global updateScript */
2 | var Mediator = window.Mediator || {}
3 | var View = window.View || {}
4 |
5 | document.addEventListener('DOMContentLoaded', () => {
6 | // TODO: refactor debug log
7 | var DEBUG = true
8 | if (!DEBUG) {
9 | if (!window.console) window.console = {}
10 | var methods = ['log', 'debug', 'warn', 'info']
11 | for (var i = 0; i < methods.length; i++) {
12 | console[methods[i]] = function () {}
13 | }
14 | }
15 |
16 | window.mediator = new Mediator()
17 |
18 | // palliative solution for storage sync issue
19 | updateScript.run().then(() => {
20 | View.Main.init()
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/chrome-extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Jira Worklog Tool",
4 | "version": "0.5.1",
5 | "author": "alfeugds",
6 | "description": "This extension allows the user to log the work in Jira easily.",
7 | "homepage_url": "https://github.com/alfeugds/jiraworklogtool",
8 | "icons": {
9 | "128": "img/icon-time-task-512x512.png"
10 | },
11 | "options_page": "options.html",
12 | "action": {
13 | "default_icon": "img/icon-time-task-512x512.png",
14 | "default_popup": "temp-popup.html",
15 | "default_title": "Log Work"
16 | },
17 | "permissions": [
18 | "storage"
19 | ],
20 | "host_permissions": [
21 | "*://*/*"
22 | ],
23 | "content_security_policy": {
24 | "extension_pages": "script-src 'self'; object-src 'self'"
25 | }
26 | }
--------------------------------------------------------------------------------
/scripts/package.js:
--------------------------------------------------------------------------------
1 | // const AdmZip = require('adm-zip');
2 |
3 | // // Archive the extension folder into 'chrome-extension.zip'
4 | // const zip = new AdmZip();
5 | // zip.addLocalFolder('chrome-extension');
6 | // zip.writeZip('chrome-extension.zip');
7 |
8 | var file_system = require('fs');
9 | var archiver = require('archiver');
10 |
11 | var output = file_system.createWriteStream('chrome-extension.zip');
12 | var archive = archiver('zip');
13 |
14 | output.on('close', function () {
15 | console.log(archive.pointer() + ' total bytes');
16 | console.log('archiver has been finalized and the output file descriptor has closed.');
17 | });
18 |
19 | archive.on('error', function(err){
20 | throw err;
21 | });
22 |
23 | archive.pipe(output);
24 | archive.glob('**/*', { cwd: 'chrome-extension' });
25 | archive.finalize();
--------------------------------------------------------------------------------
/scripts/build-for-test.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const fs = require('fs-extra')
3 | const path = require('path');
4 |
5 | const SRC_DIR = path.join(process.cwd(), 'chrome-extension')
6 | const DIST_DIR = path.join(process.cwd(), 'dist', 'ui-test', 'chrome-extension')
7 |
8 | const manifest = require('../chrome-extension/manifest.json')
9 |
10 | const addContentPermission = (manifest) => {
11 | return {
12 | ...manifest,
13 | "content_scripts": [
14 | {
15 | "matches": [
16 | ""
17 | ],
18 | "js": ["js/content.js"]
19 | }
20 | ],
21 | }
22 | }
23 |
24 | fs.removeSync(DIST_DIR)
25 |
26 | fs.ensureDirSync(DIST_DIR)
27 |
28 | fs.copySync(SRC_DIR, DIST_DIR)
29 |
30 | console.info('adding content_scripts in manifest.json for testing purposes')
31 | const testManifest = addContentPermission(manifest)
32 |
33 | const testManifestPath = path.join(DIST_DIR, 'manifest.json')
34 |
35 | fs.writeFileSync(testManifestPath, JSON.stringify(testManifest, null, 2))
36 |
--------------------------------------------------------------------------------
/chrome-extension/js/gtm-tags.js:
--------------------------------------------------------------------------------
1 | (function (dataLayer) {
2 | var xhrOpen = window.XMLHttpRequest.prototype.open
3 | var xhrSend = window.XMLHttpRequest.prototype.send
4 | window.XMLHttpRequest.prototype.open = function () {
5 | this.method = arguments[0]
6 | this.url = arguments[1]
7 | return xhrOpen.apply(this, [].slice.call(arguments))
8 | }
9 | window.XMLHttpRequest.prototype.send = function () {
10 | var xhr = this
11 | var intervalId = window.setInterval(function () {
12 | if (xhr.readyState !== 4) {
13 | return
14 | }
15 | try {
16 | var data = {
17 | event: 'ajaxSuccess',
18 | eventCategory: 'AJAX',
19 | eventAction: 'jira',
20 | eventLabel: xhr.method
21 | }
22 | dataLayer.push(data)
23 | } catch (e) {
24 | console.debug('GTM error', e)
25 | } finally {
26 | clearInterval(intervalId)
27 | }
28 | }, 10)
29 | return xhrSend.apply(this, [].slice.call(arguments))
30 | }
31 | })(window.dataLayer)
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Alfeu Santos
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/integration/browser-manager.js:
--------------------------------------------------------------------------------
1 | const puppeteer = require('puppeteer')
2 | const CRX_PATH = `${process.cwd()}/dist/ui-test/chrome-extension/`
3 |
4 | module.exports = (() => {
5 | return {
6 | getBrowser: async () => {
7 | return puppeteer.launch({
8 | headless: false, // extensions only supported in full chrome.
9 | args: [
10 | `--disable-extensions-except=${CRX_PATH}`,
11 | `--load-extension=${CRX_PATH}`,
12 | '--user-agent=PuppeteerAgent'
13 | ]
14 | })
15 | },
16 | getExtensionInfo: async (browser) => {
17 | const page = await browser.newPage()
18 |
19 | // go to any page, so that the content.js can inject the extension id in a new element
20 | await page.goto('https://www.google.com/')
21 | const extensionId = await page.evaluate(() => document.getElementById('jiraworklog_id').value)
22 | const popupUrl = `chrome-extension://${extensionId}/popup.html`
23 | const optionsUrl = `chrome-extension://${extensionId}/options.html`
24 |
25 | console.log({ popupUrl })
26 | return {
27 | extensionId,
28 | popupUrl,
29 | optionsUrl
30 | }
31 | }
32 | }
33 | })()
34 |
--------------------------------------------------------------------------------
/chrome-extension/js/update-script.js:
--------------------------------------------------------------------------------
1 | /* global chrome */
2 | var updateScript = (function () {
3 | function saveOptions (jiraOptions) {
4 | return new Promise(resolve => {
5 | chrome.storage.sync.set(
6 | {
7 | jiraOptions: jiraOptions
8 | },
9 | function () {
10 | resolve()
11 | }
12 | )
13 | })
14 | }
15 | function getOptions () {
16 | return new Promise(resolve => {
17 | chrome.storage.sync.get(
18 | {
19 | jiraOptions: {}
20 | },
21 | function (options) {
22 | resolve(options.jiraOptions)
23 | }
24 | )
25 | })
26 | }
27 | function removePassword () {
28 | var getPromise = getOptions()
29 | var savePromise = getPromise.then((jiraOptions) => {
30 | jiraOptions.password = ''
31 | return saveOptions(jiraOptions)
32 | })
33 | return savePromise
34 | }
35 | return {
36 | run: () => {
37 | // Check whether new version is installed
38 | var thisVersion = chrome.runtime.getManifest().version
39 | console.log(`app version: ${thisVersion}`)
40 | return removePassword().then(() => {
41 | return Promise.resolve()
42 | })
43 | }
44 | }
45 | })()
46 |
47 | if (typeof module !== 'undefined') { module.exports = updateScript }
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/*
2 | !.vscode/settings.json
3 | !.vscode/tasks.json
4 | !.vscode/launch.json
5 | !.vscode/extensions.json
6 |
7 | # Logs
8 | logs
9 | *.log
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # Runtime data
15 | pids
16 | *.pid
17 | *.seed
18 | *.pid.lock
19 |
20 | # Directory for instrumented libs generated by jscoverage/JSCover
21 | lib-cov
22 |
23 | # Coverage directory used by tools like istanbul
24 | coverage
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Typescript v1 declaration files
46 | typings/
47 |
48 | # Optional npm cache directory
49 | .npm
50 |
51 | # Optional eslint cache
52 | .eslintcache
53 |
54 | # Optional REPL history
55 | .node_repl_history
56 |
57 | # Output of 'npm pack'
58 | *.tgz
59 |
60 | # Yarn Integrity file
61 | .yarn-integrity
62 |
63 | # dotenv environment variables file
64 | .env
65 | oauth.txt
66 |
67 | # package output
68 | chrome-extension.zip
69 |
70 | #extension key
71 | chrome-extension.crx
72 | chrome-extension.pem
73 |
74 | # jest junit
75 | junit.xml
76 |
77 | # dist/build
78 |
79 | dist/
80 |
--------------------------------------------------------------------------------
/tests/unit/update-script.unit.spec.js:
--------------------------------------------------------------------------------
1 | /* global test jest expect describe beforeEach */
2 | const updateScript = require('../../chrome-extension/js/update-script')
3 |
4 | describe('Update Script', () => {
5 | beforeEach(() => {
6 | // arrange
7 | global.options = {
8 | jiraOptions: {
9 | jiraUrl: 'https://whatever.com',
10 | user: 'someuser@gmail.com',
11 | password: 'pwd',
12 | token: 'tkn'
13 | }
14 | }
15 | global.chrome = {
16 | runtime: {
17 | getManifest: jest.fn(function () {
18 | return {
19 | version: '0.2.3'
20 | }
21 | })
22 | },
23 | storage: {
24 | sync: {
25 | set: jest.fn((options, callback) => {
26 | global.options = options
27 | callback()
28 | }),
29 | get: jest.fn((jiraOptions, callback) => {
30 | callback(global.options)
31 | })
32 | }
33 | }
34 | }
35 | global.console = {
36 | log: jest.fn()
37 | }
38 | })
39 |
40 | test('should run update storage script upon extension update', (done) => {
41 | // act
42 | // console.log('before', options);
43 | expect(global.options.jiraOptions.password).toBe('pwd')
44 | updateScript.run().then(() => {
45 | // assert
46 |
47 | expect(console.log).toHaveBeenCalledWith('app version: 0.2.3')
48 | expect(global.chrome.runtime.getManifest).toHaveBeenCalled()
49 | expect(global.options.jiraOptions.token).toBe('tkn')
50 | expect(global.options.jiraOptions.password).toBe('')
51 |
52 | done()
53 | })
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: cimg/node:14.19.3-browsers
6 | steps:
7 | - checkout
8 | - run:
9 | name: Install Headless Chrome dependencies
10 | command: |
11 | sudo apt-get install -yq \
12 | gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \
13 | libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \
14 | libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 \
15 | libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates \
16 | fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget || sudo apt-get -f install
17 | - restore_cache:
18 | name: Restore Node Package Cache
19 | keys:
20 | # when lock file changes, use increasingly general patterns to restore cache
21 | - node-v3-{{ .Branch }}-{{ checksum "package-lock.json" }}
22 | - node-v3-{{ .Branch }}-
23 | - node-v3-
24 | - run:
25 | name: Install Dependencies
26 | command: npm i
27 | - save_cache:
28 | name: Save Node Package Cache
29 | paths:
30 | - ~/project/node_modules
31 | key: node-v2-{{ .Branch }}-{{ checksum "package-lock.json" }}
32 | - run: npm run lint
33 | - run:
34 | name: Run tests with JUnit as reporter
35 | command: xvfb-run -a npm run test -- --ci --reporters=default --reporters=jest-junit
36 | environment:
37 | JEST_JUNIT_OUTPUT_DIR: ./reports/junit/
38 | - store_test_results:
39 | path: ./reports/junit/
40 | - store_artifacts:
41 | path: ./reports/junit
42 |
--------------------------------------------------------------------------------
/chrome-extension/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Jira Worklog Tool Options
6 |
7 |
32 |
33 |
34 |
35 |
Configure Jira Connection
36 |
Make sure you are logged in jira in this browser, then configure your Jira Hostname and press Test Connection.
37 |
Jira Hostname
38 |
39 |
40 |
Test Connection
41 |
42 |
43 |
Basic Authentication*
44 |
*You probably don't need it. Only fill it if the Jira API URL is different from the Jira Website you use, or if connection doesn't work even after logging in on Jira through Chrome.
45 |
User Name
46 |
47 |
48 |
Password
49 |
50 |
51 |
App Token*
52 |
*Fill only if required by your Jira API. Ask your IT department for assistance.
53 |
Token
54 |
55 |
56 |
57 |
Save
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jira-log",
3 | "version": "0.5.1",
4 | "description": "",
5 | "main": "jira-parser.js",
6 | "directories": {
7 | "test": "tests"
8 | },
9 | "scripts": {
10 | "unit": "jest -c jest.config.unit.json",
11 | "integration": "jest -c jest.config.integration.json",
12 | "test": "npm run build-test && jest -c jest.config.json",
13 | "test:watch": "jest -c jest.config.unit.json --watch",
14 | "test:coverage": "jest -c jest.config.unit.json --coverage",
15 | "lint": "npm run lint:code && npm run lint:tests",
16 | "lint:code": "eslint --config .eslintrc.json --ignore-path .eslintignore chrome-extension/**/*.js --fix --quiet",
17 | "lint:tests": "eslint --config .eslintrc.json --ignore-path .eslintignore tests/**/*.spec.js --fix --quiet",
18 | "package": "node scripts/package",
19 | "deploy": "npm run test && npm run sync-version && npm run package && node scripts/deploy",
20 | "major": "npm version --no-git-tag-version major && npm run sync-version",
21 | "minor": "npm version --no-git-tag-version minor && npm run sync-version",
22 | "patch": "npm version --no-git-tag-version patch && npm run sync-version",
23 | "sync-version": "node scripts/sync-version",
24 | "build-test": "node scripts/build-for-test",
25 | "ui-test-playground": "npm run build-test && node tests/integration/test-puppeteer"
26 | },
27 | "author": "Alfeu Santos ",
28 | "license": "MIT",
29 | "jest": {
30 | "testEnvironment": "jsdom"
31 | },
32 | "devDependencies": {
33 | "archiver": "^2.1.1",
34 | "chrome-webstore-upload": "^0.2.2",
35 | "dotenv": "^4.0.0",
36 | "eslint": "^6.8.0",
37 | "eslint-config-standard": "^13.0.1",
38 | "eslint-plugin-import": "^2.18.2",
39 | "eslint-plugin-node": "^9.1.0",
40 | "eslint-plugin-promise": "^4.2.1",
41 | "eslint-plugin-standard": "^4.0.0",
42 | "fs-extra": "^10.0.0",
43 | "hoek": "4.2.1",
44 | "jest": "^26.6.3",
45 | "jest-cli": "^26.6.3",
46 | "jest-junit": "^12.2.0",
47 | "puppeteer": "^1.19.0",
48 | "sshpk": "1.14.1"
49 | },
50 | "repository": "https://github.com/alfeugds/jiraworklogtool.git",
51 | "dependencies": {
52 | "axios": "^0.21.4",
53 | "axios-mock-adapter": "^1.15.0",
54 | "retire": "^1.6.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/tests/integration/jira-mock.js:
--------------------------------------------------------------------------------
1 | const defaultSuccessfulResponse = {
2 | status: 200,
3 | contentType: 'application/json;charset=UTF-8',
4 | headers: {
5 | // 'x-ausername': 'hue@br.com',
6 | 'x-aaccountid': 'some:token'
7 | }
8 | }
9 | const defaultFailedResponse = {
10 | status: 404
11 | }
12 | const search = {
13 | successfulGETRequestWithTwoItems: Object.assign({
14 | body: JSON.stringify({
15 | issues: [{
16 | 'key': 'CMS-123'
17 | },{
18 | 'key': 'CMS-456'
19 | }]
20 | })
21 | }, defaultSuccessfulResponse)
22 | //TODO: mock //https://jira.com/rest/api/2/search?fields=fields,key&jql=worklogDate=%272018-01-01%27%20AND%20worklogAuthor=currentUser()
23 | };
24 | const items = {
25 | "CMS-123": {
26 | "worklogs":[
27 | {
28 | "author":{
29 | // "key":"hue@br.com",
30 | "accountId": "some:token"
31 | },
32 | "comment":"tech onboarding",
33 | "started":"2018-01-01T06:00:00.000+0000",
34 | "timeSpent":"1h 50m",
35 | "id":"55829"
36 | }
37 | ]
38 | },
39 | "CMS-456": {
40 | "worklogs":[
41 | {
42 | "author":{
43 | // "key":"hue@br.com",
44 | "accountId": "some:token"
45 | },
46 | "comment":"tech onboarding 2",
47 | "started":"2018-01-01T06:00:00.000+0000",
48 | "timeSpent":"2h 50m",
49 | "id":"45645"
50 | }
51 | ]
52 | }
53 | }
54 |
55 | module.exports = {
56 | getResponse: (request) => {
57 | if(!request.url().includes('https://jira.com/'))
58 | return defaultFailedResponse;
59 | //search
60 | if (request.url().includes('rest/api/2/search?fields=fields,key&jql='))
61 | return search.successfulGETRequestWithTwoItems;
62 |
63 | let match;
64 | if(match = request.url().match('rest/api/2/issue/([^/]+)/worklog')){
65 | let item = match[1]
66 | return Object.assign( {body: JSON.stringify(items[item])}, defaultSuccessfulResponse);
67 | }
68 |
69 | return {
70 | status: 404
71 | };
72 | }
73 | };
--------------------------------------------------------------------------------
/chrome-extension/js/jira-parser.js:
--------------------------------------------------------------------------------
1 | var JiraParser = (function () {
2 | const timeRegex = new RegExp('(\\d+[m]|\\d+[d](?:(?:\\s\\d+[h])?(?:\\s\\d+[m])?)?|\\d+[h](?:\\s\\d+[m])?)')
3 | const hoursAndMinutesRegex = new RegExp('^' + timeRegex.source + '$')
4 | const jiraRegex = new RegExp('([a-zA-Z0-9][a-zA-Z0-9_-]*?-\\d+)')
5 | const jiraNumberRegex = new RegExp('^' + jiraRegex.source + '$')
6 | const worklogTextLineRegex = new RegExp('\\b' + jiraRegex.source + '?\\b.*?\\b' + timeRegex.source + '\\b[\\s\\-_;,]*(.+)$')
7 |
8 | function timeSpentToHours (timeSpent) {
9 | let result = 0
10 | let match
11 | if (timeSpent.indexOf('d') > -1) {
12 | match = /\b(\d+)d\b/.exec(timeSpent)
13 | if (match) {
14 | var d = match[1]
15 | result += parseFloat(d.replace('d', '')) * 8
16 | }
17 | }
18 | if (timeSpent.indexOf('h') > -1) {
19 | match = /\b(\d+)h\b/.exec(timeSpent)
20 | if (match) {
21 | var h = match[1]
22 | result += parseFloat(h.replace('h', ''))
23 | }
24 | }
25 | if (timeSpent.indexOf('m') > -1) {
26 | match = /\b(\d+)m\b/.exec(timeSpent)
27 | if (match) {
28 | var m = match[1]
29 | result += parseFloat(m.replace('m', '')) / 60
30 | }
31 | }
32 | return result
33 | }
34 |
35 | function isValidTimeSpentFormat (timeSpent) {
36 | if (hoursAndMinutesRegex.exec(timeSpent)) { return true } else { return false }
37 | }
38 |
39 | function parse (text) {
40 | let hoursAndMinutes = ''
41 | let jiraNumber = ''
42 | let worklog = text
43 |
44 | const matches = worklogTextLineRegex.exec(text)
45 |
46 | if (matches) {
47 | jiraNumber = matches[1] || ''
48 | hoursAndMinutes = matches[2] || ''
49 | worklog = matches[3] || worklog
50 | }
51 |
52 | const result = {
53 | timeSpent: hoursAndMinutes,
54 | jira: jiraNumber,
55 | comment: worklog
56 | }
57 | return result
58 | }
59 |
60 | function getInvalidFields (item) {
61 | var result = []
62 | if (!jiraNumberRegex.exec(item.jira)) {
63 | result.push('jira')
64 | }
65 | if (!isValidTimeSpentFormat(item.timeSpent)) {
66 | result.push('timeSpent')
67 | }
68 | if (!item.comment.trim()) {
69 | result.push('comment')
70 | }
71 | return result
72 | }
73 |
74 | return {
75 | parse: parse,
76 | timeSpentToHours: timeSpentToHours,
77 | isValidTimeSpentFormat: isValidTimeSpentFormat,
78 | getInvalidFields: getInvalidFields
79 | }
80 | })()
81 |
82 | if (typeof module !== 'undefined') { module.exports = JiraParser }
83 |
--------------------------------------------------------------------------------
/chrome-extension/js/options.js:
--------------------------------------------------------------------------------
1 | var chrome = window.chrome || {}
2 | var JiraHelper = window.JiraHelper || {}
3 |
4 | // Saves options to chrome.storage
5 | function saveOptions (options) {
6 | // make sure to not save user password, as chrome storage is not encrypted (https://developer.chrome.com/apps/storage#using-sync).
7 | // The JESSIONID authentication cookie will be remembered by the browser once User clicks 'Test Connection' anyway,
8 | // and Jira will consider the JESSIONID cookie and ignore the basic auth settings for the requests.
9 | options.password = ''
10 |
11 | chrome.storage.sync.set(
12 | {
13 | jiraOptions: options
14 | },
15 | function () {
16 | // Update status to let user know options were saved.
17 | var status = document.getElementById('status')
18 | status.textContent = 'Options saved.'
19 | setTimeout(function () {
20 | status.textContent = ''
21 | }, 1500)
22 | }
23 | )
24 | }
25 |
26 | // Restores options state using the preferences
27 | // stored in chrome.storage.
28 | function restoreOptions () {
29 | chrome.storage.sync.get(
30 | {
31 | jiraOptions: {}
32 | },
33 | function (items) {
34 | restoreOptionsToInput(items.jiraOptions)
35 | }
36 | )
37 | }
38 |
39 | var jiraUrlInput, userInput, passwordInput, tokenInput
40 |
41 | function restoreOptionsToInput (options) {
42 | jiraUrlInput.value = options.jiraUrl || ''
43 | userInput.value = options.user || ''
44 | passwordInput.value = options.password || ''
45 | tokenInput.value = options.token || ''
46 | }
47 |
48 | function getOptionsFromInput () {
49 | return {
50 | jiraUrl: jiraUrlInput.value,
51 | user: userInput.value,
52 | password: passwordInput.value,
53 | token: tokenInput.value
54 | }
55 | }
56 |
57 | document.addEventListener('DOMContentLoaded', () => {
58 | restoreOptions()
59 | jiraUrlInput = document.getElementById('jiraUrl')
60 | userInput = document.getElementById('user')
61 | passwordInput = document.getElementById('password')
62 | tokenInput = document.getElementById('token')
63 |
64 | document.getElementById('save').addEventListener('click', () => {
65 | saveOptions(getOptionsFromInput())
66 | })
67 | document.getElementById('testConnection').addEventListener('click', () => {
68 | var jiraOptions = getOptionsFromInput()
69 | console.log(jiraOptions)
70 | JiraHelper.testConnection(jiraOptions)
71 | .then(result => {
72 | console.info('connection successful', result)
73 | saveOptions(getOptionsFromInput())
74 | alert('Connection [OK]')
75 | })
76 | .catch(error => {
77 | console.error('connection failed', error)
78 | alert('Connection [FAILED]. Please double-check the options.')
79 | })
80 | })
81 | })
82 |
--------------------------------------------------------------------------------
/scripts/deploy.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 |
6 | /* In order for the deploy script to work, a .env file must be created.
7 | 1- acces OAuth client console and get cliend id and secret
8 | 2- access https://accounts.google.com/o/oauth2/auth?response_type=code&scope=https://www.googleapis.com/auth/chromewebstore&client_id=113688225649-ttrnqlh7f18d9cngcsu4e4erp0226jpp.apps.googleusercontent.com&redirect_uri=urn:ietf:wg:oauth:2.0:oob
9 | 3- get token
10 | 4- run command below in a browser console(ref: https://github.com/DrewML/chrome-webstore-upload/blob/master/How%20to%20generate%20Google%20API%20keys.md):
11 | copy(`curl "https://accounts.google.com/o/oauth2/token" -d "client_id=${encodeURIComponent(prompt('Enter your clientId'))}&client_secret=${encodeURIComponent(prompt('Enter your clientSecret'))}&code=${encodeURIComponent(prompt('Enter your authcode'))}&grant_type=authorization_code&redirect_uri=urn:ietf:wg:oauth:2.0:oob"`);alert('The curl has been copied. Paste it into your terminal.')
12 | 5- run copied curl
13 | 6- update .env file
14 | */
15 |
16 | const webStore = require('chrome-webstore-upload')({
17 | extensionId: 'pekbjnkonfmgjfnbpmindidammhgmjji',
18 | clientId: process.env.CHROME_CLIENT_ID,
19 | clientSecret: process.env.CHROME_CLIENT_SECRET,
20 | refreshToken: process.env.CHROME_REFRESH_TOKEN
21 | });
22 |
23 | console.log('Deploying to Chrome Web Store...');
24 | console.log(`CHROME_CLIENT_ID: ${process.env.CHROME_CLIENT_ID}`);
25 | console.log(`CHROME_CLIENT_SECRET: ${process.env.CHROME_CLIENT_SECRET}`);
26 | console.log(`CHROME_REFRESH_TOKEN: ${process.env.CHROME_REFRESH_TOKEN}`);
27 |
28 | function getToken(){
29 | const token = process.env.CHROME_ACCESS_TOKEN;
30 | if(token)
31 | return Promise.resolve(token);
32 | else {
33 | console.log('fetching token...');
34 | return webStore.fetchToken();
35 | }
36 | }
37 | (async () => {
38 | try{
39 | const token = await getToken();
40 | //upload
41 | const zipPath = path.join(__dirname, '../chrome-extension.zip');
42 | const myZipFile = fs.createReadStream(zipPath);
43 | try{
44 | console.log('uploading...');
45 | let res = await webStore.uploadExisting(myZipFile, token);
46 |
47 | console.log('publishing...');
48 | const target = 'default';
49 | res = await webStore.publish(target, token);
50 | console.log('Chrome publish complete!', res);
51 |
52 | } catch (err) {
53 | console.error('Chrome upload failed: ', err);
54 | process.exitCode = 1;
55 | }
56 | }catch(err) {
57 | console.error('fetching token failed: ', err);
58 | process.exitCode = 1;
59 | };
60 | })();
--------------------------------------------------------------------------------
/tests/integration/test-puppeteer.js:
--------------------------------------------------------------------------------
1 | const puppeteer = require('puppeteer')
2 | const browserManager = require('./browser-manager')
3 |
4 | const CRX_PATH = `${process.cwd()}/chrome-extension/`
5 | const jiraMock = require('./jira-mock')
6 | const CHROME_EXTENSION_URL = 'chrome-extension://ehkgicpgemphledafbkdenjjekkogbmk/'
7 |
8 | console.log(jiraMock);
9 |
10 | (async () => {
11 | const POPUP_PAGE = `${CHROME_EXTENSION_URL}popup.html`
12 | const browser = await browserManager.getBrowser()
13 | const { popupUrl } = await browserManager.getExtensionInfo(browser)
14 | // ... do some testing ...
15 |
16 | // const extensionPage = await browser.newPage();
17 | // await extensionPage.goto('chrome://extensions/')
18 |
19 | const page = await browser.newPage()
20 | page.on('dialog', async dialog => {
21 | console.log(dialog.message())
22 | await dialog.accept()
23 | })
24 |
25 | await page.setRequestInterception(true)
26 | page.on('request', request => {
27 | if (request.url().includes('chrome-extension://')) {
28 | request.continue()
29 | } else {
30 | request.respond(jiraMock.getResponse(request))
31 | }
32 | })
33 | await page.goto(popupUrl)
34 |
35 | // await page.goto(POPUP_PAGE)
36 | // click buttons, test UI elements, etc.
37 | const errorMessage = await page.evaluate(() => document.querySelector('.error_status h2').textContent)
38 | console.log(errorMessage)
39 | await page.waitFor(200)
40 | await page.click('h2>a')
41 | await page.waitFor(1000)
42 |
43 | const pages = await browser.pages()
44 | optionsPage = pages.filter(p => p.url().includes('options.html'))[0]
45 | optionsPage.on('dialog', async dialog => {
46 | console.log(dialog.message())
47 | await dialog.accept()
48 | })
49 |
50 | await optionsPage.setRequestInterception(true)
51 | optionsPage.on('request', request => {
52 | console.log(request)
53 | request.respond(jiraMock.getResponse(request))
54 | })
55 |
56 | await optionsPage.waitFor(500)
57 | await optionsPage.type('#jiraUrl', 'https://jira.com')
58 | await optionsPage.click('#testConnection')
59 | await optionsPage.click('#save')
60 | // await optionsPage.reload();
61 | // await page.click('h2>a');
62 | await page.bringToFront()
63 | await page.waitFor(100)
64 | await page.reload()
65 | await page.type('#worklogDate', '01/01/2018')
66 | await page.waitFor(100)
67 | async function getValueArrayFromInputs (page, selector) {
68 | return page.evaluate((selector) =>
69 | Array.from(document.querySelectorAll(selector))
70 | .map((i) => i.value), selector)
71 | }
72 | const jiraNumberArray = await getValueArrayFromInputs(page, 'input[name=jira]')
73 | console.log(jiraNumberArray)
74 | const timeSpentArray = await getValueArrayFromInputs(page, 'input[name=timeSpent]')
75 | console.log(timeSpentArray)
76 | const commentArray = await getValueArrayFromInputs(page, 'input[name=comment]')
77 | console.log(commentArray)
78 |
79 | // await browser.close();
80 | })()
81 |
--------------------------------------------------------------------------------
/chrome-extension/js/model.js:
--------------------------------------------------------------------------------
1 | /* global mediator chrome */
2 | window.Model = {}
3 | window.Model.WorklogModel = (function (JiraParser) {
4 | var items = []
5 | var totalHours = 0.0
6 |
7 | function addAll (newItems) {
8 | newItems.forEach(function (item) {
9 | item.status = 'new'
10 | }, this)
11 |
12 | items = items.concat(newItems)
13 | mediator.trigger('model.workloglist.updated', items)
14 | updateTotalHours()
15 | }
16 |
17 | function getItems () {
18 | return items
19 | }
20 |
21 | function updateItemsFromJira (newItems) {
22 | items = items.filter(item => {
23 | return item.status !== 'saved' && item.status !== 'edited' && item.status !== 'deleted'
24 | })
25 |
26 | items = items.concat(newItems)
27 | mediator.trigger('model.workloglist.updated', items)
28 | updateTotalHours()
29 | }
30 |
31 | function updateItemsWithLocalData (persistedItems) {
32 | items = items.filter(item => {
33 | return item.status !== 'new'
34 | })
35 |
36 | items = items.concat(persistedItems)
37 | updateTotalHours()
38 | }
39 |
40 | function updateTotalHours () {
41 | var total = 0.0
42 | for (var i = 0; i < items.length; i++) {
43 | var worklog = items[i]
44 | total += JiraParser.timeSpentToHours(worklog.timeSpent)
45 | }
46 | totalHours = total
47 | mediator.trigger('modal.totalHours.update', totalHours)
48 | }
49 |
50 | function clearItems () {
51 | items = []
52 | }
53 |
54 | function getTotalHours () {
55 | updateTotalHours()
56 | return totalHours
57 | }
58 |
59 | function persistUnsavedWorklogToLocal (date, worklogs) {
60 | return new Promise((resolve) => {
61 | getUnsavedWorklogFromLocal().then(persistedWorklogs => {
62 | // save only new items
63 | worklogs = worklogs.filter(item => {
64 | return item.status === 'new'
65 | })
66 |
67 | worklogs.forEach(function (item) {
68 | item.started = date
69 | }, this)
70 |
71 | persistedWorklogs = persistedWorklogs.filter(function (item) {
72 | return item.started !== date
73 | }, this)
74 |
75 | persistedWorklogs = persistedWorklogs.concat(worklogs)
76 | // ...
77 | chrome.storage.local.set({
78 | worklogs: persistedWorklogs
79 | }, () => {
80 | resolve()
81 | })
82 | })
83 | })
84 | }
85 |
86 | function getUnsavedWorklogFromLocal (date) {
87 | return new Promise((resolve) => {
88 | chrome.storage.local.get({
89 | worklogs: []
90 | }, result => {
91 | var worklogs = result.worklogs
92 | if (date) {
93 | worklogs = worklogs.filter(item => {
94 | return item.started === date
95 | })
96 | }
97 | console.log('getUnsavedWorklogFromLocal() result:', worklogs)
98 | resolve(worklogs)
99 | })
100 | })
101 | }
102 |
103 | return {
104 | addAll: addAll,
105 | getItems: getItems,
106 | getTotalHours: getTotalHours,
107 | updateItemsFromJira: updateItemsFromJira,
108 | getUnsavedWorklogFromLocal: getUnsavedWorklogFromLocal,
109 | persistUnsavedWorklogToLocal: persistUnsavedWorklogToLocal,
110 | updateItemsWithLocalData: updateItemsWithLocalData,
111 | clearItems: clearItems
112 | }
113 | })(window.JiraParser)
114 |
--------------------------------------------------------------------------------
/tests/integration/uitest.int.spec.js:
--------------------------------------------------------------------------------
1 | const browserManager = require('./browser-manager')
2 | const {
3 | getOptionsPage,
4 | getPopupPage,
5 | makeSureJiraUrlIsConfigured
6 | } = require('./jira-extension-functions')
7 |
8 | describe('UI Test', () => {
9 | describe('popup', () => {
10 | let browser
11 | let extensionInfo
12 | beforeEach(async () => {
13 | // browser must be initialized
14 | browser = await browserManager.getBrowser()
15 | extensionInfo = extensionInfo || await browserManager.getExtensionInfo(browser)
16 | })
17 | afterEach(async () => {
18 | await browser.close()
19 | })
20 | test('Loads successfully with no worklogs', async (done) => {
21 | try {
22 | const popup = await getPopupPage(browser, extensionInfo)
23 | const errorMessage = await popup.getErrorMessage()
24 | expect(errorMessage).toEqual('Please go to Options and make sure you are logged in Jira, and the Jira Hostname is correct.')
25 | await popup.clickOptionsPage()
26 | await popup.wait()
27 |
28 | const optionsPage = await getOptionsPage(browser)
29 | await optionsPage.setValidJiraUrl()
30 | await optionsPage.clickOnTestconnection()
31 | const connectionResultMessage = await optionsPage.waitForTestConnectionResult()
32 | expect(connectionResultMessage).toEqual('Connection [OK]')
33 | done()
34 | } catch (e) {
35 | fail(e)
36 | done()
37 | }
38 | })
39 | test('Loads successfully with some worklogs', async done => {
40 | try {
41 | await makeSureJiraUrlIsConfigured(browser, extensionInfo)
42 | const popupPage = await getPopupPage(browser, extensionInfo)
43 | await popupPage.wait()
44 | await popupPage.setWorklogDate('01/01/2018')
45 | const savedJiraArray = await popupPage.getJiraArray()
46 | const commentArray = await popupPage.getCommentArray()
47 | const timeSpentArray = await popupPage.getTimeSpentArray()
48 | expect(savedJiraArray).toEqual(['CMS-123', 'CMS-456'])
49 | expect(timeSpentArray).toEqual(['1h 50m', '2h 50m'])
50 | expect(commentArray).toEqual(['tech onboarding', 'tech onboarding 2'])
51 |
52 | done()
53 | } catch (e) {
54 | fail(e)
55 | done()
56 | }
57 | })
58 | test('Adds some worklogs from text', async done => {
59 | // given I have the extension opened and with jira configured
60 | await makeSureJiraUrlIsConfigured(browser, extensionInfo)
61 | const popup = await getPopupPage(browser, extensionInfo)
62 | await popup.wait()
63 | // and I select the worklog date as 01/01/2018
64 | await popup.setWorklogDate('01/01/2018')
65 | // then I see the total worklog as 4.67h
66 | let totalWorklog = await popup.getTotalWorklog()
67 | expect(totalWorklog).toEqual('4.67h')
68 | // when I write down the following worklog
69 | const worklogText = '30m some worklog'
70 | await popup.setWorklogText(worklogText)
71 | // then I should see the worklogs in the worklog table
72 | const commentArray = await popup.getCommentArray()
73 | expect(commentArray.sort()).toEqual(['tech onboarding', 'tech onboarding 2', 'some worklog'].sort())
74 | // and I should see the total worklog time updated to 5.17h
75 | totalWorklog = await popup.getTotalWorklog()
76 | expect(totalWorklog).toEqual('5.17h')
77 |
78 | done()
79 | })
80 | test.todo('POSTs some worklogs')
81 | test.todo('PUTs some worklogs')
82 | test.todo('DELETEs some worklogs')
83 | })
84 | })
85 |
--------------------------------------------------------------------------------
/chrome-extension/lib/mediator.min.js:
--------------------------------------------------------------------------------
1 | !function(a,b){"use strict";"function"==typeof define&&define.amd?define("mediator-js",[],function(){return a.Mediator=b(),a.Mediator}):"undefined"!=typeof exports?exports.Mediator=b():a.Mediator=b()}(this,function(){"use strict";function a(){var a=function(){return(0|65536*(1+Math.random())).toString(16).substring(1)};return a()+a()+"-"+a()+"-"+a()+"-"+a()+"-"+a()+a()+a()}function b(c,d,e){return this instanceof b?(this.id=a(),this.fn=c,this.options=d,this.context=e,this.channel=null,void 0):new b(c,d,e)}function c(a,b){return this instanceof c?(this.namespace=a||"",this._subscribers=[],this._channels={},this._parent=b,this.stopped=!1,void 0):new c(a)}function d(){return this instanceof d?(this._channels=new c(""),void 0):new d}return b.prototype={update:function(a){a&&(this.fn=a.fn||this.fn,this.context=a.context||this.context,this.options=a.options||this.options,this.channel&&this.options&&void 0!==this.options.priority&&this.channel.setPriority(this.id,this.options.priority))}},c.prototype={addSubscriber:function(a,c,d){var e=new b(a,c,d);return c&&void 0!==c.priority?(c.priority=c.priority>>0,c.priority<0&&(c.priority=0),c.priority>=this._subscribers.length&&(c.priority=this._subscribers.length-1),this._subscribers.splice(c.priority,0,e)):this._subscribers.push(e),e.channel=this,e},stopPropagation:function(){this.stopped=!0},getSubscriber:function(a){var b=0,c=this._subscribers.length;for(c;c>b;b++)if(this._subscribers[b].id===a||this._subscribers[b].fn===a)return this._subscribers[b]},setPriority:function(a,b){var e,f,g,h,c=0,d=0;for(d=0,h=this._subscribers.length;h>d&&this._subscribers[d].id!==a&&this._subscribers[d].fn!==a;d++)c++;e=this._subscribers[c],f=this._subscribers.slice(0,c),g=this._subscribers.slice(c+1),this._subscribers=f.concat(g),this._subscribers.splice(b,0,e)},addChannel:function(a){this._channels[a]=new c((this.namespace?this.namespace+":":"")+a,this)},hasChannel:function(a){return this._channels.hasOwnProperty(a)},returnChannel:function(a){return this._channels[a]},removeSubscriber:function(a){var b=this._subscribers.length-1;if(!a)return this._subscribers=[],void 0;for(b;b>=0;b--)(this._subscribers[b].fn===a||this._subscribers[b].id===a)&&(this._subscribers[b].channel=null,this._subscribers.splice(b,1))},publish:function(a){var e,g,h,b=0,c=this._subscribers.length,d=!1;for(c;c>b;b++)d=!1,e=this._subscribers[b],this.stopped||(g=this._subscribers.length,void 0!==e.options&&"function"==typeof e.options.predicate?e.options.predicate.apply(e.context,a)&&(d=!0):d=!0),d&&(e.options&&void 0!==e.options.calls&&(e.options.calls--,e.options.calls<1&&this.removeSubscriber(e.id)),e.fn.apply(e.context,a),h=this._subscribers.length,c=h,h===g-1&&b--);this._parent&&this._parent.publish(a),this.stopped=!1}},d.prototype={getChannel:function(a,b){var c=this._channels,d=a.split(":"),e=0,f=d.length;if(""===a)return c;if(d.length>0)for(f;f>e;e++){if(!c.hasChannel(d[e])){if(b)break;c.addChannel(d[e])}c=c.returnChannel(d[e])}return c},subscribe:function(a,b,c,d){var e=this.getChannel(a||"",!1);return c=c||{},d=d||{},e.addSubscriber(b,c,d)},once:function(a,b,c,d){return c=c||{},c.calls=1,this.subscribe(a,b,c,d)},getSubscriber:function(a,b){var c=this.getChannel(b||"",!0);return c.namespace!==b?null:c.getSubscriber(a)},remove:function(a,b){var c=this.getChannel(a||"",!0);return c.namespace!==a?!1:(c.removeSubscriber(b),void 0)},publish:function(a){var b=this.getChannel(a||"",!0);if(b.namespace!==a)return null;var c=Array.prototype.slice.call(arguments,1);c.push(b),b.publish(c)}},d.prototype.on=d.prototype.subscribe,d.prototype.bind=d.prototype.subscribe,d.prototype.emit=d.prototype.publish,d.prototype.trigger=d.prototype.publish,d.prototype.off=d.prototype.remove,d.Channel=c,d.Subscriber=b,d.version="0.9.8",d});
2 |
--------------------------------------------------------------------------------
/chrome-extension/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 10px;
3 | }
4 |
5 | h1 {
6 | font-size: 15px;
7 | }
8 |
9 | .container {
10 | align-items: center;
11 | justify-content: space-between;
12 | padding: 10px;
13 | max-height: 548px;
14 | max-width: 768px;
15 | margin: 0 auto;
16 | }
17 |
18 | .container>* {
19 | margin: 10px 0;
20 | }
21 |
22 | .container>*:first-child {
23 | margin-top: 0px;
24 | }
25 |
26 | .container.container--hidden {
27 | display: none;
28 | }
29 |
30 | .worklog-items-table {
31 | border-collapse: collapse;
32 | border-spacing: 0;
33 | width: 100%;
34 | }
35 |
36 | .worklog-items-table td {
37 | font-family: Arial, sans-serif;
38 | font-size: 14px;
39 | padding: 5px 5px;
40 | border-style: solid;
41 | border-width: 1px;
42 | overflow: hidden;
43 | word-break: normal;
44 | }
45 |
46 | .worklog-items-table th {
47 | font-family: Arial, sans-serif;
48 | font-size: 14px;
49 | font-weight: bold;
50 | padding: 10px 5px;
51 | border-style: solid;
52 | border-width: 1px;
53 | overflow: hidden;
54 | word-break: normal;
55 | }
56 |
57 | .worklog-items-table .table-line {
58 | vertical-align: top
59 | }
60 |
61 | .worklog-items-table .jira-number-column-item {
62 | max-width: 70px;
63 | }
64 |
65 | .worklog-items-table .time-spent-column-item {
66 | max-width: 90px;
67 | }
68 |
69 | .worklog-items-table .comment-column-item {
70 | min-width: 300px;
71 | max-width: 500px;
72 | }
73 |
74 | .worklog-items-table input {
75 | width: 100%;
76 | font-size: 12px;
77 | }
78 | .worklog-textarea
79 | {
80 | width: 100%;
81 | font-size: 12px;
82 | min-height: 70px;
83 | font-size: 10px;
84 | }
85 |
86 | .worklog.worklog--edited {
87 | background-color: #ffffd2;
88 | }
89 |
90 | .worklog.worklog--invalid {
91 | background-color: #ff9b9b;
92 | }
93 |
94 | .worklog.worklog--saved {
95 | background-color: #b1ffb8;
96 | }
97 |
98 | .worklog.worklog--deleted {
99 | background-color: #ff9b9b;
100 | }
101 |
102 | .worklog input.input--invalid{
103 | border-color: #ff2323;
104 | }
105 |
106 | .hidden {
107 | display: none;
108 | }
109 |
110 | #loading {
111 | position: fixed;
112 | margin: auto;
113 | background-color: rgba(0, 0, 0, 0.28);
114 | top: 0px;
115 | left: 0px;
116 | width: 100%;
117 | height: 100%;
118 | }
119 |
120 | #loading img {
121 | margin: 200px auto;
122 | display: block;
123 | }
124 |
125 |
126 | .delete-button{
127 | display: inline-block;
128 | margin: auto;
129 | width: 20px;
130 | border: 1px solid #ddd;
131 | border-bottom: 1px solid #aaa;
132 | border-right: 1px solid #aaa;
133 | background-color: #fff;
134 | background-repeat: no-repeat;
135 | background-position: center;
136 | font-size: 14px;
137 | height: 19px;
138 | line-height: 1.5em;
139 | background-image: url(/img/ic_delete_black_24dp.png);
140 | }
141 |
142 | .open-link-button{
143 | display: inline-block;
144 | margin: auto;
145 | width: 20px;
146 | border: 1px solid #ddd;
147 | border-bottom: 1px solid #aaa;
148 | border-right: 1px solid #aaa;
149 | background-color: #fff;
150 | background-repeat: no-repeat;
151 | background-position: center;
152 | font-size: 14px;
153 | height: 19px;
154 | line-height: 1.5em;
155 | background-image: url(/img/open_in_new_window.png);
156 | background-size: cover;
157 | }
158 |
159 | .link-disabled {
160 | pointer-events: none;
161 | }
162 |
163 | .save-button {
164 | margin: 10px 0;
165 | width: 70px;
166 | height: 50px;
167 | border: 1px solid #ddd;
168 | border-bottom: 1px solid #aaa;
169 | border-right: 1px solid #aaa;
170 | background-color: #fff;
171 | background-repeat: no-repeat;
172 | background-position: center;
173 | font-size: 14px;
174 | line-height: 1.5em;
175 | background-image: url(/img/ic_cloud_upload_black_48dp.png);
176 | }
177 |
178 | .error_status {
179 | padding: 20px;
180 | }
181 |
182 | .error_status h2 {
183 | white-space: initial;
184 | width: 400px;
185 | }
186 |
--------------------------------------------------------------------------------
/tests/integration/jira-extension-functions.js:
--------------------------------------------------------------------------------
1 | const jiraMock = require('./jira-mock')
2 |
3 | async function makeSureJiraUrlIsConfigured (browser, extensionInfo) {
4 | await openOptionsPage(browser, extensionInfo)
5 | const optionsPage = await getOptionsPage(browser)
6 | await optionsPage.setValidJiraUrl()
7 | await optionsPage.clickOnTestconnection()
8 | await optionsPage.waitForTestConnectionResult()
9 | await optionsPage.clickOnSave()
10 | }
11 |
12 | async function openOptionsPage (browser, extensionInfo) {
13 | const page = await browser.newPage()
14 | await page.waitFor(200)
15 | return page.goto(extensionInfo.optionsUrl)
16 | }
17 |
18 | async function getPopupPage (browser, extensionInfo) {
19 | async function getValueArrayFromInputs (page, selector) {
20 | return page.evaluate((selector) =>
21 | Array.from(document.querySelectorAll(selector))
22 | .map((i) => i.value), selector)
23 | }
24 |
25 | const page = await browser.newPage()
26 |
27 | // ignore error dialog
28 | await page.on('dialog', async dialog => {
29 | await dialog.accept()
30 | })
31 |
32 | await page.setRequestInterception(true)
33 | await page.on('request', request => {
34 | if (request.url().includes('chrome-extension://')) { request.continue() } else { request.respond(jiraMock.getResponse(request)) }
35 | })
36 |
37 | await page.goto(extensionInfo.popupUrl)
38 |
39 | await page.waitFor(300)
40 |
41 | return {
42 | getErrorMessage: async () => {
43 | const errorMessage = await page.evaluate(() => document.querySelector('.error_status h2').textContent)
44 | return errorMessage
45 | },
46 | clickOptionsPage: async () => {
47 | await page.click('h2>a')
48 | return page.waitFor(100)
49 | },
50 | wait: async () => {
51 | return page.waitFor(100)
52 | },
53 | setWorklogDate: async (date) => {
54 | await page.type('#worklogDate', date || '01/01/2018')
55 | await page.waitFor(100)
56 | await page.click('#getWorklogButton')
57 | return page.waitFor(300)
58 | },
59 | getJiraArray: async () => {
60 | return getValueArrayFromInputs(page, 'input[name=jira]')
61 | },
62 | getTimeSpentArray: async () => {
63 | return getValueArrayFromInputs(page, 'input[name=timeSpent]')
64 | },
65 | getCommentArray: async () => {
66 | return getValueArrayFromInputs(page, 'input[name=comment]')
67 | },
68 | getWorklogText: async () => {
69 | return page.evaluate(() => document.querySelector('#worklog').value)
70 | },
71 | setWorklogText: async worklogText => {
72 | await page.waitFor(100)
73 | await page.type('#worklog', worklogText)
74 | await page.waitFor(100)
75 | await page.click('#addWorklogs')
76 | return page.waitFor(100)
77 | },
78 | getTotalWorklog: async () => {
79 | await page.waitFor(200)
80 | return page.evaluate(() => document.querySelector('#totalHours').textContent)
81 | }
82 | }
83 | }
84 |
85 | async function getOptionsPage (browser) {
86 | const pages = await browser.pages()
87 | let dialogDeferred = null
88 | let dialogPromise = null
89 | const page = pages.filter(p => p.url().includes('options.html'))[0]
90 |
91 | // intercept all requests
92 | await page.setRequestInterception(true)
93 | page.on('request', request => {
94 | if (request.url().includes('chrome-extension://')) { request.continue() } else { request.respond(jiraMock.getResponse(request)) }
95 | })
96 |
97 | page.on('dialog', async dialog => {
98 | const dialogMessage = dialog.message()
99 | await dialog.accept()
100 | dialogDeferred.resolve(dialogMessage)
101 | })
102 |
103 | return {
104 | setValidJiraUrl: async () => {
105 | await page.waitFor(100)
106 | await page.type('#jiraUrl', 'https://jira.com')
107 | return page.waitFor(300)
108 | },
109 | clickOnTestconnection: async () => {
110 | dialogPromise = new Promise((resolve, reject) => {
111 | dialogDeferred = {
112 | resolve: resolve,
113 | reject: reject
114 | }
115 | })
116 | await page.click('#testConnection')
117 | return page.waitFor(100)
118 | },
119 | clickOnSave: async () => {
120 | await page.click('#save')
121 | return page.waitFor(300)
122 | },
123 | waitForTestConnectionResult: async () => {
124 | await page.waitFor(100)
125 | return dialogPromise
126 | }
127 | }
128 | }
129 |
130 | module.exports = {
131 | makeSureJiraUrlIsConfigured,
132 | openOptionsPage,
133 | getPopupPage,
134 | getOptionsPage,
135 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Jira Worklog Tool [Archived]
2 |
3 | > [!WARNING]
4 | > This repo is no longer being maintained and is currently archived.
5 | > Please see the below fork(s) for an actively maintained version of the tool.
6 | > If you are interested in maintaining your own version, feel free to fork it. Send your actively maintained fork to `alfeu.gds+jiraworklogtool@gmail.com` and I can link it in this README for future reference.
7 |
8 | # Active forks:
9 |
10 | | Fork | Chrome Web Store Listing |
11 | | ------------- | ------------- |
12 | | https://github.com/DiegoVAReis/jiraworkloghelper | [link](https://chromewebstore.google.com/detail/jira-worklog-helper/ghaalgapncgkjoecjdkcodieakmcdllc) |
13 |
14 |
15 | A simple Chrome Extension that allows adding worklogs in Jira easily.
16 | Logging your time in Jira doesn't need to be a pain anymore. If you already keep track of your tasks in a TODO list from a text file, then all you need to do is to adapt your list items to the below intuitive format:
17 |
18 | ```
19 | - -
20 | ```
21 | You can separate the fields with comma, semi-colon, dash, or even a simple white space. See some examples below:
22 |
23 | ```
24 | JIRA-123 - 1h 30m - working on stuff
25 | JIRA-222 - 45m - developing that amazing feature in the website
26 | DEV-456 2h 10m fixing bugs in my Pull Request
27 | 1m updating my worklog in Jira!
28 | ```
29 | You can also omit the Jira # and time spent and add it later.
30 |
31 | ## Current Features
32 |
33 | * Bulk insert worklogs in Jira;
34 | * Converts your task list from text format to a worklog format Jira understands;
35 | * Log your time in Jira issues without the need to open Jira;
36 | * Add, edit and delete worklogs directly from the Chrome Extension;
37 | * Keep track of how many hours you already spent in the tasks;
38 | * Supports SAML and Basic Authentication with Jira app token.
39 |
40 | ## Getting Started
41 | Before using it, you need to do two things:
42 | - Make sure you are logged in to your Jira instance in Chrome. The extension leverages the existing authentication cookie when it is present in the browser;
43 | - Open the **Options** page and configure the **Jira Hostname**, which needs to point to the API services*. For example: **`https://jira.atlassian.com`**.
44 |
45 | After that, click **Test Connection** to make sure the extension can reach Jira correctly. If so, click **Save** and you are good to go.
46 |
47 | If by only providing the Jira Hostname the connection fails, you'll need to configure the **Basic Authentication** with your user and password. Also, depending on the authentication method of the Jira API, you'll also need to provide an app token. If that's the case, please consult your IT department to get one.
48 |
49 | *_The extension uses the **Jira Hostname** to build the URL and API calls to the Jira instance like this: **`https://jira.atlassian.com/`**`rest/api/2/search`._
50 |
51 | ## Some Images
52 |
53 | 
54 |
55 | See it in action:
56 | 
57 |
58 | ## Built With
59 |
60 | Jira Worklog Tool's major implementation was built with vanilla Javascript. Below are the list of libraries used to help building it:
61 |
62 | * [Mediator](https://github.com/ajacksified/Mediator.js) - A light utility class to help implement the Mediator pattern for easy eventing
63 |
64 | ## Contributions
65 |
66 | If you find any issues or have ideas for new features, feel free to open an issue in Github, or even contribute with a new Pull Request!
67 |
68 | ## License
69 |
70 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details
71 |
72 | # Development
73 |
74 | install dependencies:
75 |
76 | ````sh
77 | npm i
78 | ````
79 |
80 | run tests (unit and UI):
81 |
82 | ````sh
83 | npm run tests
84 | ````
85 |
86 | UI test playground:
87 |
88 | ````sh
89 | npm run ui-test-playground
90 | ````
91 | ## Running UI tests in Windows' wsl 2
92 |
93 | - install xvfb with
94 |
95 | ````sh
96 | sudo apt install xvfb
97 | ````
98 |
99 | - install Headless Chrome dependencies (see [.circleci/config.yml](.circleci/config.yml) for reference).
100 |
101 | - run the tests with `xvfb-run`:
102 |
103 | ````sh
104 | xvfb-run npm run test
105 | ````
106 |
107 | ----
108 |
109 | Old Chrome Web Store listing for historical purposes (not working anymore): https://chrome.google.com/webstore/detail/jira-worklog-tool/pekbjnkonfmgjfnbpmindidammhgmjji
110 |
111 | [](https://circleci.com/gh/alfeugds/jiraworklogtool)
112 |
--------------------------------------------------------------------------------
/chrome-extension/js/controller.js:
--------------------------------------------------------------------------------
1 | window.Controller = window.Controller || {}
2 | window.Controller.LogController = (function (JiraHelper, Model, JiraParser) {
3 | 'use strict'
4 | function init () {
5 | return JiraHelper.init()
6 | }
7 |
8 | function getWorklogsByDay (worklogDate) {
9 | return new Promise((resolve, reject) => {
10 | var p = Model.WorklogModel.getUnsavedWorklogFromLocal(worklogDate)
11 | p.then(items => {
12 | Model.WorklogModel.clearItems()
13 | Model.WorklogModel.updateItemsWithLocalData(items)
14 | JiraHelper.getWorklog(worklogDate)
15 | .then(worklogItems => {
16 | worklogItems.forEach(item => {
17 | item.jiraUrl = JiraHelper.getJiraUrl(item.jira)
18 | })
19 | Model.WorklogModel.updateItemsFromJira(worklogItems)
20 | resolve()
21 | })
22 | .catch(error => {
23 | reject(error)
24 | })
25 | .then(() => {})
26 | })
27 | })
28 | }
29 |
30 | function getFromText (worklogItemsText) {
31 | var arr = worklogItemsText.split('\n')
32 | var result = []
33 | for (var i = 0; i < arr.length; i++) {
34 | var worklogText = arr[i]
35 | if (worklogText && worklogText.trim()) {
36 | result.push(JiraParser.parse(worklogText))
37 | }
38 | }
39 | return result
40 | }
41 |
42 | function bulkInsert (worklogItemsText) {
43 | return new Promise((resolve) => {
44 | var worklogItems = getFromText(worklogItemsText)
45 | Model.WorklogModel.addAll(worklogItems)
46 | resolve()
47 | })
48 | }
49 |
50 | function save (items, date) {
51 | return new Promise((resolve) => {
52 | console.log(items)
53 | var promises = []
54 | var i = items.length
55 | while (i--) {
56 | var item = items[i]
57 |
58 | // ignore invalid items
59 | if (item.status !== 'deleted' && getInvalidFields(item).length) { continue }
60 |
61 | var promise
62 | switch (item.status) {
63 | case 'saved':
64 | console.log('item already saved', item)
65 | break
66 | case 'invalid':
67 | break
68 | case 'edited':
69 | promise = JiraHelper.updateWorklog(item)
70 | promise
71 | .then(item => {
72 | items.splice(items.indexOf(item), 1)
73 | console.log('item update', item)
74 | })
75 | .catch(error => {
76 | console.error('controller.save update', error, item)
77 | })
78 | .then(() => {})
79 | promises.push(promise)
80 | break
81 | case 'new':
82 | promise = JiraHelper.logWork(item, date)
83 | promise
84 | .then(item => {
85 | items.splice(items.indexOf(item), 1)
86 | console.log('item inserted', item)
87 | })
88 | .catch(error => {
89 | console.error('controller.save insert', error, item)
90 | })
91 | .then(() => {})
92 | promises.push(promise)
93 | break
94 | case 'deleted':
95 | promise = JiraHelper.deleteWorklog(item)
96 | promise
97 | .then(item => {
98 | items.splice(items.indexOf(item), 1)
99 | console.log('item deleted', item)
100 | })
101 | .catch(error => {
102 | console.error('controller.save delete', error, item)
103 | })
104 | .then(() => {})
105 | promises.push(promise)
106 | break
107 | default:
108 | console.log('item ignored', item)
109 | break
110 | }
111 | }
112 |
113 | Promise.all(promises).then(() => {
114 | persistUnsavedData(date, items).then(() => {
115 | resolve()
116 | })
117 | }).catch(error => {
118 | // persistUnsavedData(date, items).then(() => {
119 | // reject(error);
120 | // })
121 | console.log('after save error', error)
122 | resolve()
123 | })
124 | })
125 | }
126 |
127 | function persistUnsavedData (date, items) {
128 | return Model.WorklogModel.persistUnsavedWorklogToLocal(date, items)
129 | .then(() => {
130 | Model.WorklogModel.clearItems()
131 | Model.WorklogModel.updateItemsWithLocalData(items)
132 | })
133 | }
134 |
135 | function getInvalidFields (worklog) {
136 | return JiraParser.getInvalidFields(worklog)
137 | }
138 |
139 | return {
140 | getWorklogsByDay: getWorklogsByDay,
141 | bulkInsert: bulkInsert,
142 | persistUnsavedData: persistUnsavedData,
143 | save: save,
144 | init: init,
145 | getInvalidFields: getInvalidFields
146 | }
147 | })(window.JiraHelper, window.Model, window.JiraParser)
148 |
--------------------------------------------------------------------------------
/chrome-extension/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Jira Worklog Helper
6 |
7 |
8 |
9 |
11 |
12 |
13 |
14 |
15 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
36 |
37 |
38 |
Jira Worklog Tool
39 |
44 |
101 |
Total: 2h
102 |
103 |
104 |
Paste your formatted worklog here
105 |
106 |
107 |
108 |
Add Worklogs
109 |
110 |
111 |
112 |
113 |
Please go to Options and make sure you are logged in Jira, and the Jira Hostname is correct.
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
--------------------------------------------------------------------------------
/tests/unit/jira-helper.unit.spec.js:
--------------------------------------------------------------------------------
1 | var axios = require('axios')
2 | var mockAxios = axios.create()
3 | var MockAdapter = require('axios-mock-adapter')
4 | // This sets the mock adapter on the default instance
5 | var mock = new MockAdapter(mockAxios, { delayResponse: 10 })
6 |
7 | global.axios = mockAxios
8 |
9 | global.options = {
10 | jiraOptions: {
11 | jiraUrl: 'https://whatever.com',
12 | user: 'someuser@gmail.com',
13 | password: 'pwd',
14 | token: 'tkn'
15 | }
16 | }
17 |
18 | global.chrome = {
19 | storage: {
20 | sync: {
21 | set: jest.fn((options, callback) => {
22 | global.options = options
23 | callback()
24 | }),
25 | get: jest.fn((jiraOptions, callback) => {
26 | callback(global.options)
27 | })
28 | }
29 | }
30 | }
31 |
32 | function initJiraHelper () {
33 | // arrange
34 | mock.onGet(/rest\/api\/2\/search/)
35 | .replyOnce(200,
36 | {
37 | issues: [{
38 | key: 'cms-123'
39 | }]
40 | },
41 | {
42 | 'x-ausername': 'hue@br.com'
43 | }
44 | )
45 | global.options.jiraOptions.user = 'hue@br.com'
46 | // act
47 | return jiraHelper.init()
48 | }
49 |
50 | // module to test
51 | const jiraHelper = require('../../chrome-extension/js/jira-helper')
52 |
53 | describe('Jira API Helper', () => {
54 | beforeEach(() => {
55 |
56 | })
57 |
58 | afterEach(() => {
59 |
60 | })
61 |
62 | describe('testConnection', () => {
63 | test('testConnection works successfully with valid options', done => {
64 | // arrange
65 | const options = {
66 | jiraUrl: 'https://whatever.com',
67 | user: 'someuser@gmail.com',
68 | password: 'pwd',
69 | token: 'tkn'
70 | }
71 | mock.onGet(/rest\/api\/2\/search/)
72 | .replyOnce(200,
73 | {
74 | issues: [{
75 | key: 'cms-123'
76 | }]
77 | },
78 | {
79 | 'x-ausername': 'hue@br.com'
80 | }
81 | )
82 |
83 | // act
84 | jiraHelper.testConnection(options).then(result => {
85 | // assert
86 | expect(result).not.toBeNull()
87 | expect(result[0]).toEqual('cms-123')
88 | done()
89 | }).catch((e) => {
90 | fail(e)
91 | done()
92 | })
93 | })
94 |
95 | test('testConnection fails with invalid options', done => {
96 | // arrange
97 | const options = {
98 | jiraUrl: 'https://whatever.com',
99 | user: 'wrong@gmail.com',
100 | password: 'pwd',
101 | token: 'tkn'
102 | }
103 |
104 | mock.onGet(/rest\/api\/2\/search/)
105 | .replyOnce(401,
106 | {
107 | status: 401,
108 | statusText: 'invalid jira hostname',
109 | errorMessages: ['hue']
110 | }
111 | )
112 |
113 | jiraHelper.testConnection(options).then(result => {
114 | fail()
115 | done()
116 | })
117 | .catch(error => {
118 | expect(error.message).toEqual('Server response: 401(undefined): hue')
119 | done()
120 | })
121 | })
122 | })
123 | describe('init', () => {
124 | test('module is initiated successfully', done => {
125 | // arrange
126 | mock.onGet(/rest\/api\/2\/search/)
127 | .replyOnce(200,
128 | {
129 | issues: [{
130 | key: 'cms-123'
131 | }]
132 | },
133 | {
134 | 'x-ausername': 'hue@br.com'
135 | }
136 | )
137 | // act
138 | jiraHelper.init().then((result) => {
139 | // assert
140 | expect(result).toEqual(['cms-123'])
141 | done()
142 | }).catch(error => {
143 | fail(error)
144 | done()
145 | })
146 | })
147 | test.todo('module fails with invalid options')
148 | })
149 | describe('getWorklog', () => {
150 | beforeEach(done => {
151 | initJiraHelper().then(() => done())
152 | })
153 | test('returns 2 worklogs successfully', done => {
154 | mock.onGet(/rest\/api\/2\/search/)
155 | .replyOnce(config => {
156 | return [200,
157 | {
158 | issues: [
159 | {
160 | key: 'cms-123'
161 | },
162 | {
163 | key: 'cms-456'
164 | }
165 | ]
166 | },
167 | {
168 | 'x-ausername': 'hue@br.com'
169 | }]
170 | })
171 | mock.onGet(/rest\/api\/2\/issue\/cms-123\/worklog/)
172 | .replyOnce(config => {
173 | return [200,
174 | {
175 | worklogs: [
176 | {
177 | author: {
178 | key: 'hue@br.com'
179 | },
180 | comment: 'tech onboarding',
181 | started: '2018-03-26T06:00:00.000+0000',
182 | timeSpent: '1h 50m',
183 | id: '55829'
184 | }
185 | ]
186 | },
187 | {
188 | 'x-ausername': 'hue@br.com'
189 | }]
190 | })
191 | mock.onGet(/rest\/api\/2\/issue\/cms-456\/worklog/)
192 | .replyOnce(config => {
193 | return [200,
194 | {
195 | worklogs: [
196 | {
197 | author: {
198 | key: 'hue@br.com'
199 | },
200 | comment: 'tech onboarding 2',
201 | started: '2018-03-26T06:00:00.000+0000',
202 | timeSpent: '2h 50m',
203 | id: '45645'
204 | }
205 | ]
206 | },
207 | {
208 | 'x-ausername': 'hue@br.com'
209 | }]
210 | })
211 | jiraHelper.getWorklog('2018-03-26').then(result => {
212 | const firstWorklog = result[0]
213 | expect(firstWorklog.jira).toEqual('cms-123')
214 | expect(firstWorklog.timeSpent).toEqual('1h 50m')
215 | expect(firstWorklog.comment).toEqual('tech onboarding')
216 | expect(firstWorklog.status).toEqual('saved')
217 | expect(firstWorklog.started).toEqual('2018-03-26T06:00:00.000+0000')
218 | expect(firstWorklog.logId).toEqual('55829')
219 |
220 | const secondWorklog = result[1]
221 | expect(secondWorklog.timeSpent).toEqual('2h 50m')
222 | done()
223 | }).catch(e => {
224 | fail(e)
225 | done()
226 | })
227 | })
228 | test('returns zero worklogs', done => {
229 | // arrange
230 | mock.onGet(/rest\/api\/2\/search/)
231 | .replyOnce(config => {
232 | return [200,
233 | {
234 | issues: []
235 | },
236 | {
237 | 'x-ausername': 'hue@br.com'
238 | }]
239 | })
240 | jiraHelper.getWorklog('2018-03-26').then(result => {
241 | expect(result).toEqual([])
242 | done()
243 | }).catch(e => {
244 | fail(e)
245 | done()
246 | })
247 | })
248 | test('returns error', done => {
249 | jiraHelper.getWorklog('2018-03-26').then(result => {
250 | fail(result)
251 | done()
252 | }).catch(e => {
253 | expect(e.message).toEqual('Server response: 404(undefined): undefined')
254 | done()
255 | })
256 | })
257 | })
258 | describe('getDateInJiraFormat', () => {
259 | test('returns date in jira format', () => {
260 | const result = jiraHelper.getDateInJiraFormat('2020-01-01')
261 | expect(result).toMatch(/2020-01-\d{2}T\d{2}:\d{2}:00\.000\+0000/)
262 | })
263 | })
264 | describe('logWork', () => {
265 | test.todo('adds worklog successfully')
266 | test.todo('fails to add worklog due to wrong input')
267 | test.todo('fails to add worklog due to jira instance error')
268 | })
269 | describe('updateWorklog', () => {
270 | test.todo('updates worklog successfully')
271 | test.todo('fails to update worklog due to wrong input')
272 | test.todo('fails to update worklog due to jira instance error')
273 | })
274 | describe('deleteWorklog', () => {
275 | test.todo('deletes worklog successfully')
276 | test.todo('fails to delete worklog due to wrong input')
277 | test.todo('fails to delete worklog due to jira instance error')
278 | })
279 | describe('getJiraUrl', () => {
280 | beforeEach(done => {
281 | initJiraHelper().then(() => done())
282 | })
283 | test('returns jira url successfully when jira # is CMS-123', done => {
284 | const jiraUrl = jiraHelper.getJiraUrl('CMS-123')
285 | expect(jiraUrl).toEqual('https://whatever.com/browse/CMS-123')
286 |
287 | done()
288 | })
289 | test('returns jira url successfully when jira # is CMS-45678', done => {
290 | const jiraUrl = jiraHelper.getJiraUrl('CMS-45678')
291 | expect(jiraUrl).toEqual('https://whatever.com/browse/CMS-45678')
292 |
293 | done()
294 | })
295 | test('returns jira url successfully when jira # is long CMSSSS-321654987', done => {
296 | const jiraUrl = jiraHelper.getJiraUrl('CMSSSS-321654987')
297 | expect(jiraUrl).toEqual('https://whatever.com/browse/CMSSSS-321654987')
298 |
299 | done()
300 | })
301 | test('returns jira url successfully when jiraname has # CMS2020-123', done => {
302 | const jiraUrl = jiraHelper.getJiraUrl('CMS2020-123')
303 | expect(jiraUrl).toEqual('https://whatever.com/browse/CMS2020-123')
304 |
305 | done()
306 | })
307 | test('fails to return jira URL when jira # is empty', done => {
308 | const jiraUrl = jiraHelper.getJiraUrl('')
309 | expect(jiraUrl).toEqual('')
310 | done()
311 | })
312 | })
313 | })
314 |
--------------------------------------------------------------------------------
/chrome-extension/js/jira-helper.js:
--------------------------------------------------------------------------------
1 | (function (chrome) {
2 | var user = ''
3 | var headers = {
4 | 'content-type': 'application/json',
5 | 'cache-control': 'no-cache'
6 | }
7 | var jiraOptions = {}
8 |
9 | function searchForWorklogKeysByDate (worklogDate) {
10 | return new Promise((resolve, reject) => {
11 | var fields = 'fields=fields,key'
12 | var jql = `jql=worklogDate='${worklogDate}' AND worklogAuthor=currentUser()`
13 | var url = jiraOptions.jiraUrl + '/rest/api/2/search?' + fields + '&' + jql
14 |
15 | var config = {
16 | headers: headers,
17 | method: 'GET',
18 | url: url
19 | }
20 | request(config).then((response) => {
21 | var keys = []
22 | for (var i = 0; i < response.issues.length; i++) {
23 | var item = response.issues[i]
24 | keys.push(item.key)
25 | }
26 | resolve(keys)
27 | }).catch((error) => {
28 | reject(error)
29 | })
30 | })
31 | }
32 |
33 | function testConnection (options) {
34 | return new Promise((resolve, reject) => {
35 | var fields = 'fields=fields,key'
36 | var jql = `jql=worklogAuthor=currentUser()`
37 | var url = options.jiraUrl + '/rest/api/2/search?' + fields + '&' + jql
38 |
39 | if (options.user && options.password) {
40 | var b64 = btoa(`${options.user}:${options.password}`)
41 | headers.Authorization = `Basic ${b64}`
42 | }
43 | if (options.token) {
44 | headers.app_token = options.token
45 | }
46 |
47 | var config = {
48 | headers: headers,
49 | method: 'GET',
50 | url: url
51 | }
52 | // TODO: check if URL is valid before making the request
53 | request(config).then((response) => {
54 | var keys = []
55 | for (var i = 0; i < response.issues.length; i++) {
56 | var item = response.issues[i]
57 | keys.push(item.key)
58 | }
59 | resolve(keys)
60 | }).catch((error) => {
61 | reject(error)
62 | })
63 | })
64 | }
65 |
66 | function request (config) {
67 | return new Promise((resolve, reject) => {
68 | // console.log(config)
69 | axios(config).then(response => {
70 | // TODO: define better way to save user name, which will be used to filter the worklogs
71 |
72 | setUserFromHeader(response.headers)
73 | var data = response.data
74 | resolve(data)
75 | }).catch(axiosResponse => {
76 | const response = axiosResponse.response
77 | if (!response) {
78 | reject(new Error('Network error'))
79 | return
80 | }
81 | // console.log(response)
82 | if (response.status === 429) {
83 | reject(new Error(`Too many requests to Jira API. Please wait some seconds before making another request.\n\nServer response: ${response.status}(${response.statusText}): ${response.data.errorMessages[0]}`))
84 | return
85 | }
86 |
87 | reject(new Error(`Server response: ${response.status}(${response.statusText}): ${(response.data ? response.data.errorMessages : 'undefined')}`))
88 | })
89 | })
90 | }
91 |
92 | function setUserFromHeader (headers) {
93 | let userFromHeader
94 | if (headers['x-aaccountid']) { userFromHeader = headers['x-aaccountid'].toLowerCase() } else { userFromHeader = headers['x-ausername'].toLowerCase() }
95 |
96 | user = decodeURIComponent(userFromHeader)
97 | }
98 |
99 | function isWorklogFromUser (worklog) {
100 | const possibleUserIds = []
101 | possibleUserIds.push(worklog.author.accountId)
102 | possibleUserIds.push(worklog.author.key)
103 | possibleUserIds.push(worklog.author.name)
104 |
105 | for (const possibleUserId of possibleUserIds) {
106 | if (possibleUserId && possibleUserId.toLowerCase() === (user || jiraOptions.user)) {
107 | return true
108 | }
109 | }
110 |
111 | return false
112 | }
113 |
114 | function getDetailedWorklogFromIssue (key) {
115 | var url = `${jiraOptions.jiraUrl}/rest/api/2/issue/${key}/worklog`
116 | var config = {
117 | headers: headers,
118 | method: 'GET',
119 | url: url
120 | }
121 | return request(config)
122 | }
123 |
124 | function getWorklogObjects (key, worklogs) {
125 | return new Promise((resolve) => {
126 | // console.log(`key: ${key}`, worklogs);
127 | var worklogObjectArray = []
128 | worklogs.forEach((worklog) => {
129 | worklogObjectArray.push({
130 | jira: key,
131 | timeSpent: worklog.timeSpent,
132 | comment: worklog.comment,
133 | started: worklog.started,
134 | logId: worklog.id,
135 | status: 'saved'
136 | })
137 | })
138 | resolve(worklogObjectArray)
139 | })
140 | }
141 |
142 | function getDetailedWorklogs (keys, worklogDate) {
143 | return new Promise((resolve, reject) => {
144 | var promises = []
145 | var worklogsObjectArray = []
146 | keys.forEach((key) => {
147 | var responsePromise = getDetailedWorklogFromIssue(key)
148 | promises.push(responsePromise)
149 |
150 | responsePromise.then((response) => {
151 | // filter worklogs by 'started' date and user author
152 | var worklogs = response.worklogs.filter((worklog) => {
153 | return worklog.started.indexOf(worklogDate) > -1 &&
154 | isWorklogFromUser(worklog)
155 | })
156 | var promise = getWorklogObjects(key, worklogs)
157 | promises.push(promise)
158 | promise.then((arr) => {
159 | worklogsObjectArray = worklogsObjectArray.concat(arr)
160 | })
161 | })
162 | })
163 | Promise.all(promises).then(() => {
164 | resolve(worklogsObjectArray)
165 | })
166 | .catch(error => {
167 | reject(error)
168 | })
169 | })
170 | }
171 |
172 | function getWorklog (worklogDate) {
173 | return searchForWorklogKeysByDate(worklogDate).then((keys) => {
174 | return getDetailedWorklogs(keys, worklogDate)
175 | })
176 | }
177 |
178 | const zeroPad = (num, places) => String(num).padStart(places, '0')
179 |
180 | function getDateInJiraFormat (dateStr) {
181 | const date = new Date(dateStr + 'T18:00:00')
182 | const nowUTC = date.getUTCFullYear() + '-' +
183 | zeroPad((date.getUTCMonth() + 1), 2) + '-' +
184 | zeroPad(date.getUTCDate(), 2) + 'T' +
185 | zeroPad(date.getUTCHours(), 2) + ':' +
186 | zeroPad(date.getUTCMinutes(), 2) + ':' +
187 | zeroPad(date.getUTCSeconds(), 2) +
188 | '.000+0000'
189 | return nowUTC
190 | }
191 |
192 | function logWork (worklog, date) {
193 | worklog.started = getDateInJiraFormat(date)
194 |
195 | var url = `${jiraOptions.jiraUrl}/rest/api/2/issue/${worklog.jira}/worklog`
196 | var config = {
197 | headers: headers,
198 | method: 'POST',
199 | url: url,
200 | data: {
201 | started: worklog.started,
202 | comment: worklog.comment,
203 | timeSpent: worklog.timeSpent
204 | }
205 | }
206 | return request(config).then(() => {
207 | return Promise.resolve(worklog)
208 | }).catch(() => {
209 | return Promise.reject(worklog)
210 | })
211 | }
212 |
213 | function updateWorklog (worklog) {
214 | worklog = {
215 | comment: worklog.comment,
216 | jira: worklog.jira,
217 | logId: worklog.logId,
218 | timeSpent: worklog.timeSpent
219 | }
220 |
221 | var url = `${jiraOptions.jiraUrl}/rest/api/2/issue/${worklog.jira}/worklog/${worklog.logId}`
222 | var config = {
223 | headers: headers,
224 | method: 'PUT',
225 | url: url,
226 | data: {
227 | started: worklog.started,
228 | comment: worklog.comment,
229 | timeSpent: worklog.timeSpent
230 | }
231 | }
232 | return request(config).then(() => {
233 | return Promise.resolve(worklog)
234 | }).catch(() => {
235 | return Promise.reject(worklog)
236 | })
237 | }
238 |
239 | function deleteWorklog (worklog) {
240 | worklog = {
241 | comment: worklog.comment,
242 | jira: worklog.jira,
243 | logId: worklog.logId,
244 | timeSpent: worklog.timeSpent
245 | }
246 |
247 | var url = `${jiraOptions.jiraUrl}/rest/api/2/issue/${worklog.jira}/worklog/${worklog.logId}`
248 | var config = {
249 | headers: headers,
250 | method: 'DELETE',
251 | url: url
252 | }
253 | return request(config).then(() => {
254 | return Promise.resolve(worklog)
255 | }).catch(() => {
256 | return Promise.reject(worklog)
257 | })
258 | }
259 |
260 | function configureHeaders (jiraOptions) {
261 | if (jiraOptions.user && jiraOptions.password) {
262 | var b64 = btoa(`${jiraOptions.user}:${jiraOptions.password}`)
263 | headers.Authorization = `Basic ${b64}`
264 | }
265 | if (jiraOptions.token) {
266 | headers.app_token = jiraOptions.token
267 | }
268 | }
269 |
270 | function setJiraOptions (options) {
271 | jiraOptions = options
272 | configureHeaders(options)
273 | // console.log(jiraOptions);
274 | }
275 |
276 | function getJiraUrl (jiraNumber) {
277 | if (!jiraNumber) { return '' }
278 | return `${jiraOptions.jiraUrl}/browse/${jiraNumber}`
279 | }
280 |
281 | function init () {
282 | return new Promise((resolve, reject) => {
283 | chrome.storage.sync.get(
284 | {
285 | jiraOptions: {}
286 | },
287 | function (items) {
288 | // console.log(items);
289 | setJiraOptions(items.jiraOptions)
290 | testConnection(items.jiraOptions)
291 | .then(resolve)
292 | .catch(reject)
293 | }
294 | )
295 | })
296 | }
297 |
298 | window.JiraHelper = {
299 | init: init,
300 | getWorklog: getWorklog,
301 | logWork: logWork,
302 | updateWorklog: updateWorklog,
303 | deleteWorklog: deleteWorklog,
304 | testConnection: testConnection,
305 | getJiraUrl: getJiraUrl,
306 | getDateInJiraFormat
307 | }
308 | })(window.chrome)
309 |
310 | if (typeof module !== 'undefined') { module.exports = window.JiraHelper }
311 |
--------------------------------------------------------------------------------
/tests/unit/jira-parser.unit.spec.js:
--------------------------------------------------------------------------------
1 | const jiraParser = require('../../chrome-extension/js/jira-parser')
2 |
3 | describe('convert worklog text to jira log object', () => {
4 | test('worklog contains semicolon and dash', () => {
5 | const text = 'CMS-1234; 30m-working on stuff'
6 | const expected = {
7 | timeSpent: '30m',
8 | jira: 'CMS-1234',
9 | comment: 'working on stuff'
10 | }
11 | const result = jiraParser.parse(text)
12 | expect(result).toMatchObject(expected)
13 | })
14 |
15 | test('worklog contains space and comma', () => {
16 | const text = 'CMS-123 1h 30m ,testing - working on stuff'
17 | const expected = {
18 | timeSpent: '1h 30m',
19 | jira: 'CMS-123',
20 | comment: 'testing - working on stuff'
21 | }
22 | const result = jiraParser.parse(text)
23 | expect(result).toMatchObject(expected)
24 | })
25 |
26 | test('worklog contains only time spent and comment', () => {
27 | const text = '1h - [grooming] align with SM about grooming pending tasks and blocks / align with Devs the grooming tasks and story overview'
28 | const expected = {
29 | timeSpent: '1h',
30 | jira: '',
31 | comment: '[grooming] align with SM about grooming pending tasks and blocks / align with Devs the grooming tasks and story overview'
32 | }
33 | const result = jiraParser.parse(text)
34 | expect(result).toMatchObject(expected)
35 | })
36 |
37 | test('worklog contains long comment', () => {
38 | const text = 'PLANNING-2 1h 30m [grooming] align with SM about grooming pending tasks and blocks / align with Devs the grooming tasks and story overview'
39 | const expected = {
40 | timeSpent: '1h 30m',
41 | jira: 'PLANNING-2',
42 | comment: '[grooming] align with SM about grooming pending tasks and blocks / align with Devs the grooming tasks and story overview'
43 | }
44 | const result = jiraParser.parse(text)
45 | expect(result).toMatchObject(expected)
46 | })
47 |
48 | test('worklog contains only comment', () => {
49 | const text = 'working on dev stuff'
50 | const expected = {
51 | timeSpent: '',
52 | jira: '',
53 | comment: 'working on dev stuff'
54 | }
55 | const result = jiraParser.parse(text)
56 | expect(result).toMatchObject(expected)
57 | })
58 |
59 | test('worklog contains 1d as time spent, space and comma', () => {
60 | const text = 'CMS-123 1d 2h 30m ,testing - working on stuff'
61 | const expected = {
62 | timeSpent: '1d 2h 30m',
63 | jira: 'CMS-123',
64 | comment: 'testing - working on stuff'
65 | }
66 | const result = jiraParser.parse(text)
67 | expect(result).toMatchObject(expected)
68 | })
69 |
70 | test('worklog contains 1d and 30m without hour as time spent, space and comma', () => {
71 | const text = 'CMS_somejira-123 1d 30m ,testing - working on stuff'
72 | const expected = {
73 | timeSpent: '1d 30m',
74 | jira: 'CMS_somejira-123',
75 | comment: 'testing - working on stuff'
76 | }
77 | const result = jiraParser.parse(text)
78 | expect(result).toMatchObject(expected)
79 | })
80 | })
81 |
82 | describe('timeSpent in text format should return numeric hour', () => {
83 | describe('valid times', () => {
84 | test('2h', () => {
85 | const text = '2h'
86 | const expected = 2.0
87 | const result = jiraParser.timeSpentToHours(text)
88 | // assert
89 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
90 | })
91 |
92 | test('2h', () => {
93 | const text = '2h'
94 | const expected = 2.0
95 | const result = jiraParser.timeSpentToHours(text)
96 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
97 | })
98 |
99 | test('1h 30m', () => {
100 | const text = '1h 30m'
101 | const expected = 1.5
102 | const result = jiraParser.timeSpentToHours(text)
103 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
104 | })
105 |
106 | test('15m', () => {
107 | const text = '15m'
108 | const expected = 0.25
109 | const result = jiraParser.timeSpentToHours(text)
110 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
111 | })
112 |
113 | test('1h 45m', () => {
114 | const text = '1h 45m'
115 | const expected = 1.75
116 | const result = jiraParser.timeSpentToHours(text)
117 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
118 | })
119 |
120 | test('50m', () => {
121 | const text = '50m'
122 | const expected = 0.83
123 | const result = jiraParser.timeSpentToHours(text)
124 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
125 | })
126 |
127 | test('1d', () => {
128 | const text = '1d'
129 | const expected = 8.0
130 | const result = jiraParser.timeSpentToHours(text)
131 | // assert
132 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
133 | })
134 |
135 | test('1d 1h 30m', () => {
136 | const text = '1d 1h 30m'
137 | const expected = 9.5
138 | const result = jiraParser.timeSpentToHours(text)
139 | // assert
140 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
141 | })
142 |
143 | test('1d 30m', () => {
144 | const text = '1d 30m'
145 | const expected = 8.5
146 | const result = jiraParser.timeSpentToHours(text)
147 | // assert
148 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
149 | })
150 | })
151 |
152 | describe('invalid times', () => {
153 | test('50e', () => {
154 | const text = '50e'
155 | const expected = 0
156 | const result = jiraParser.timeSpentToHours(text)
157 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
158 | })
159 |
160 | test('50mk', () => {
161 | const text = '50mk'
162 | const expected = 0
163 | const result = jiraParser.timeSpentToHours(text)
164 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
165 | })
166 |
167 | test('huebr', () => {
168 | const text = 'huebr'
169 | const expected = 0
170 | const result = jiraParser.timeSpentToHours(text)
171 | expect(result.toFixed(2)).toEqual(expected.toFixed(2))
172 | })
173 | })
174 | })
175 |
176 | test('isValidTimeSpentFormat should validate timeSpent in text format', done => {
177 | // arrange
178 | const testDataList = [{
179 | '2h': true
180 | },
181 | {
182 | '1h 30m': true
183 | },
184 | {
185 | '15min': false
186 | },
187 | {
188 | '1h4m': false
189 | },
190 | {
191 | '50m': true
192 | },
193 | {
194 | '1h 50ma': false
195 | }
196 | ]
197 | // act
198 | testDataList.forEach(testData => {
199 | const text = Object.keys(testData)[0]
200 | const expected = testData[text]
201 | const result = jiraParser.isValidTimeSpentFormat(text)
202 | // assert
203 | expect(result).toEqual(expected)
204 | done()
205 | })
206 | })
207 |
208 | describe('validate fields', () => {
209 | describe('fields are incorrect', () => {
210 | test('timeSpent is invalid', () => {
211 | const item = {
212 | timeSpent: '30',
213 | jira: 'CMS-1234',
214 | comment: 'working on stuff'
215 | }
216 | const invalidFields = ['timeSpent']
217 |
218 | const result = jiraParser.getInvalidFields(item)
219 | expect(result).toMatchObject(invalidFields)
220 | })
221 |
222 | test('jira and timeSpent are invalid', () => {
223 | const item = {
224 | timeSpent: '30',
225 | jira: 'CMS',
226 | comment: 'working on stuff'
227 | }
228 | const invalidFields = ['jira', 'timeSpent']
229 |
230 | const result = jiraParser.getInvalidFields(item)
231 | expect(result).toMatchObject(invalidFields)
232 | })
233 |
234 | test('comment is invalid', () => {
235 | const item = {
236 | timeSpent: '1h 30m',
237 | jira: 'CMS-123',
238 | comment: ''
239 | }
240 | const invalidFields = ['comment']
241 |
242 | const result = jiraParser.getInvalidFields(item)
243 | expect(result).toMatchObject(invalidFields)
244 | })
245 |
246 | test('all fields are invalid', () => {
247 | const item = {
248 | jira: 'CMS-123a',
249 | timeSpent: '1h am',
250 | comment: ' '
251 | }
252 | const invalidFields = ['jira', 'timeSpent', 'comment']
253 |
254 | const result = jiraParser.getInvalidFields(item)
255 | expect(result).toMatchObject(invalidFields)
256 | })
257 |
258 | test('jira is invalid', () => {
259 | const item = {
260 | jira: '-2',
261 | timeSpent: '1h',
262 | comment: 'test'
263 | }
264 | const invalidFields = ['jira']
265 |
266 | const result = jiraParser.getInvalidFields(item)
267 | expect(result).toMatchObject(invalidFields)
268 | })
269 | })
270 |
271 | describe('fields are correct', () => {
272 | test('long jira number', () => {
273 | const item = {
274 | jira: 'MANAGEMENT-4',
275 | timeSpent: '1h',
276 | comment: 'test'
277 | }
278 | const invalidFields = []
279 |
280 | const result = jiraParser.getInvalidFields(item)
281 | expect(result).toMatchObject(invalidFields)
282 | })
283 |
284 | test('short comment', () => {
285 | const item = {
286 | jira: 'KIMOFR-24',
287 | timeSpent: '1h',
288 | comment: 'test'
289 | }
290 | const invalidFields = []
291 |
292 | const result = jiraParser.getInvalidFields(item)
293 | expect(result).toMatchObject(invalidFields)
294 | })
295 |
296 | test('jira number', () => {
297 | const item = {
298 | jira: 'PLANNING-2',
299 | timeSpent: '1h',
300 | comment: 'test'
301 | }
302 | const invalidFields = []
303 |
304 | const result = jiraParser.getInvalidFields(item)
305 | expect(result).toMatchObject(invalidFields)
306 | })
307 |
308 | test('short jira number', () => {
309 | const item = {
310 | jira: 'P-2',
311 | timeSpent: '1h',
312 | comment: 'test'
313 | }
314 | const invalidFields = []
315 |
316 | const result = jiraParser.getInvalidFields(item)
317 | expect(result).toMatchObject(invalidFields)
318 | })
319 |
320 | test('very long jira number', () => {
321 | const item = {
322 | jira: 'TESTINGLONGERJIRAKEY-123',
323 | timeSpent: '1h 45m',
324 | comment: 'test'
325 | }
326 | const invalidFields = []
327 |
328 | const result = jiraParser.getInvalidFields(item)
329 | expect(result).toMatchObject(invalidFields)
330 | })
331 |
332 | test('Testing Jirakey Name with Number', () => {
333 | const item = {
334 | jira: 'JIRA2020-123',
335 | timeSpent: '1h 45m',
336 | comment: 'test'
337 | }
338 | const invalidFields = []
339 |
340 | const result = jiraParser.getInvalidFields(item)
341 | expect(result).toMatchObject(invalidFields)
342 | })
343 |
344 | test('Testing Jirakey Name with Number', () => {
345 | const item = {
346 | jira: 'Test_Jira-123',
347 | timeSpent: '1h 45m',
348 | comment: 'test'
349 | }
350 | const invalidFields = []
351 |
352 | const result = jiraParser.getInvalidFields(item)
353 | expect(result).toMatchObject(invalidFields)
354 | })
355 | })
356 | })
357 |
--------------------------------------------------------------------------------
/chrome-extension/lib/axios.min.js:
--------------------------------------------------------------------------------
1 | /* axios v0.18.0 | (c) 2018 by Matt Zabriskie */
2 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.axios=t():e.axios=t()}(this,function(){return function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={exports:{},id:r,loaded:!1};return e[r].call(o.exports,o,o.exports,t),o.loaded=!0,o.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([function(e,t,n){e.exports=n(1)},function(e,t,n){"use strict";function r(e){var t=new s(e),n=i(s.prototype.request,t);return o.extend(n,s.prototype,t),o.extend(n,t),n}var o=n(2),i=n(3),s=n(5),u=n(6),a=r(u);a.Axios=s,a.create=function(e){return r(o.merge(u,e))},a.Cancel=n(23),a.CancelToken=n(24),a.isCancel=n(20),a.all=function(e){return Promise.all(e)},a.spread=n(25),e.exports=a,e.exports.default=a},function(e,t,n){"use strict";function r(e){return"[object Array]"===R.call(e)}function o(e){return"[object ArrayBuffer]"===R.call(e)}function i(e){return"undefined"!=typeof FormData&&e instanceof FormData}function s(e){var t;return t="undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&e.buffer instanceof ArrayBuffer}function u(e){return"string"==typeof e}function a(e){return"number"==typeof e}function c(e){return"undefined"==typeof e}function f(e){return null!==e&&"object"==typeof e}function p(e){return"[object Date]"===R.call(e)}function d(e){return"[object File]"===R.call(e)}function l(e){return"[object Blob]"===R.call(e)}function h(e){return"[object Function]"===R.call(e)}function m(e){return f(e)&&h(e.pipe)}function y(e){return"undefined"!=typeof URLSearchParams&&e instanceof URLSearchParams}function w(e){return e.replace(/^\s*/,"").replace(/\s*$/,"")}function g(){return("undefined"==typeof navigator||"ReactNative"!==navigator.product)&&("undefined"!=typeof window&&"undefined"!=typeof document)}function v(e,t){if(null!==e&&"undefined"!=typeof e)if("object"!=typeof e&&(e=[e]),r(e))for(var n=0,o=e.length;n
6 | * @license MIT
7 | */
8 | e.exports=function(e){return null!=e&&(n(e)||r(e)||!!e._isBuffer)}},function(e,t,n){"use strict";function r(e){this.defaults=e,this.interceptors={request:new s,response:new s}}var o=n(6),i=n(2),s=n(17),u=n(18);r.prototype.request=function(e){"string"==typeof e&&(e=i.merge({url:arguments[0]},arguments[1])),e=i.merge(o,{method:"get"},this.defaults,e),e.method=e.method.toLowerCase();var t=[u,void 0],n=Promise.resolve(e);for(this.interceptors.request.forEach(function(e){t.unshift(e.fulfilled,e.rejected)}),this.interceptors.response.forEach(function(e){t.push(e.fulfilled,e.rejected)});t.length;)n=n.then(t.shift(),t.shift());return n},i.forEach(["delete","get","head","options"],function(e){r.prototype[e]=function(t,n){return this.request(i.merge(n||{},{method:e,url:t}))}}),i.forEach(["post","put","patch"],function(e){r.prototype[e]=function(t,n,r){return this.request(i.merge(r||{},{method:e,url:t,data:n}))}}),e.exports=r},function(e,t,n){"use strict";function r(e,t){!i.isUndefined(e)&&i.isUndefined(e["Content-Type"])&&(e["Content-Type"]=t)}function o(){var e;return"undefined"!=typeof XMLHttpRequest?e=n(8):"undefined"!=typeof process&&(e=n(8)),e}var i=n(2),s=n(7),u={"Content-Type":"application/x-www-form-urlencoded"},a={adapter:o(),transformRequest:[function(e,t){return s(t,"Content-Type"),i.isFormData(e)||i.isArrayBuffer(e)||i.isBuffer(e)||i.isStream(e)||i.isFile(e)||i.isBlob(e)?e:i.isArrayBufferView(e)?e.buffer:i.isURLSearchParams(e)?(r(t,"application/x-www-form-urlencoded;charset=utf-8"),e.toString()):i.isObject(e)?(r(t,"application/json;charset=utf-8"),JSON.stringify(e)):e}],transformResponse:[function(e){if("string"==typeof e)try{e=JSON.parse(e)}catch(e){}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,validateStatus:function(e){return e>=200&&e<300}};a.headers={common:{Accept:"application/json, text/plain, */*"}},i.forEach(["delete","get","head"],function(e){a.headers[e]={}}),i.forEach(["post","put","patch"],function(e){a.headers[e]=i.merge(u)}),e.exports=a},function(e,t,n){"use strict";var r=n(2);e.exports=function(e,t){r.forEach(e,function(n,r){r!==t&&r.toUpperCase()===t.toUpperCase()&&(e[t]=n,delete e[r])})}},function(e,t,n){"use strict";var r=n(2),o=n(9),i=n(12),s=n(13),u=n(14),a=n(10),c="undefined"!=typeof window&&window.btoa&&window.btoa.bind(window)||n(15);e.exports=function(e){return new Promise(function(t,f){var p=e.data,d=e.headers;r.isFormData(p)&&delete d["Content-Type"];var l=new XMLHttpRequest,h="onreadystatechange",m=!1;if("undefined"==typeof window||!window.XDomainRequest||"withCredentials"in l||u(e.url)||(l=new window.XDomainRequest,h="onload",m=!0,l.onprogress=function(){},l.ontimeout=function(){}),e.auth){var y=e.auth.username||"",w=e.auth.password||"";d.Authorization="Basic "+c(y+":"+w)}if(l.open(e.method.toUpperCase(),i(e.url,e.params,e.paramsSerializer),!0),l.timeout=e.timeout,l[h]=function(){if(l&&(4===l.readyState||m)&&(0!==l.status||l.responseURL&&0===l.responseURL.indexOf("file:"))){var n="getAllResponseHeaders"in l?s(l.getAllResponseHeaders()):null,r=e.responseType&&"text"!==e.responseType?l.response:l.responseText,i={data:r,status:1223===l.status?204:l.status,statusText:1223===l.status?"No Content":l.statusText,headers:n,config:e,request:l};o(t,f,i),l=null}},l.onerror=function(){f(a("Network Error",e,null,l)),l=null},l.ontimeout=function(){f(a("timeout of "+e.timeout+"ms exceeded",e,"ECONNABORTED",l)),l=null},r.isStandardBrowserEnv()){var g=n(16),v=(e.withCredentials||u(e.url))&&e.xsrfCookieName?g.read(e.xsrfCookieName):void 0;v&&(d[e.xsrfHeaderName]=v)}if("setRequestHeader"in l&&r.forEach(d,function(e,t){"undefined"==typeof p&&"content-type"===t.toLowerCase()?delete d[t]:l.setRequestHeader(t,e)}),e.withCredentials&&(l.withCredentials=!0),e.responseType)try{l.responseType=e.responseType}catch(t){if("json"!==e.responseType)throw t}"function"==typeof e.onDownloadProgress&&l.addEventListener("progress",e.onDownloadProgress),"function"==typeof e.onUploadProgress&&l.upload&&l.upload.addEventListener("progress",e.onUploadProgress),e.cancelToken&&e.cancelToken.promise.then(function(e){l&&(l.abort(),f(e),l=null)}),void 0===p&&(p=null),l.send(p)})}},function(e,t,n){"use strict";var r=n(10);e.exports=function(e,t,n){var o=n.config.validateStatus;n.status&&o&&!o(n.status)?t(r("Request failed with status code "+n.status,n.config,null,n.request,n)):e(n)}},function(e,t,n){"use strict";var r=n(11);e.exports=function(e,t,n,o,i){var s=new Error(e);return r(s,t,n,o,i)}},function(e,t){"use strict";e.exports=function(e,t,n,r,o){return e.config=t,n&&(e.code=n),e.request=r,e.response=o,e}},function(e,t,n){"use strict";function r(e){return encodeURIComponent(e).replace(/%40/gi,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}var o=n(2);e.exports=function(e,t,n){if(!t)return e;var i;if(n)i=n(t);else if(o.isURLSearchParams(t))i=t.toString();else{var s=[];o.forEach(t,function(e,t){null!==e&&"undefined"!=typeof e&&(o.isArray(e)?t+="[]":e=[e],o.forEach(e,function(e){o.isDate(e)?e=e.toISOString():o.isObject(e)&&(e=JSON.stringify(e)),s.push(r(t)+"="+r(e))}))}),i=s.join("&")}return i&&(e+=(e.indexOf("?")===-1?"?":"&")+i),e}},function(e,t,n){"use strict";var r=n(2),o=["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"];e.exports=function(e){var t,n,i,s={};return e?(r.forEach(e.split("\n"),function(e){if(i=e.indexOf(":"),t=r.trim(e.substr(0,i)).toLowerCase(),n=r.trim(e.substr(i+1)),t){if(s[t]&&o.indexOf(t)>=0)return;"set-cookie"===t?s[t]=(s[t]?s[t]:[]).concat([n]):s[t]=s[t]?s[t]+", "+n:n}}),s):s}},function(e,t,n){"use strict";var r=n(2);e.exports=r.isStandardBrowserEnv()?function(){function e(e){var t=e;return n&&(o.setAttribute("href",t),t=o.href),o.setAttribute("href",t),{href:o.href,protocol:o.protocol?o.protocol.replace(/:$/,""):"",host:o.host,search:o.search?o.search.replace(/^\?/,""):"",hash:o.hash?o.hash.replace(/^#/,""):"",hostname:o.hostname,port:o.port,pathname:"/"===o.pathname.charAt(0)?o.pathname:"/"+o.pathname}}var t,n=/(msie|trident)/i.test(navigator.userAgent),o=document.createElement("a");return t=e(window.location.href),function(n){var o=r.isString(n)?e(n):n;return o.protocol===t.protocol&&o.host===t.host}}():function(){return function(){return!0}}()},function(e,t){"use strict";function n(){this.message="String contains an invalid character"}function r(e){for(var t,r,i=String(e),s="",u=0,a=o;i.charAt(0|u)||(a="=",u%1);s+=a.charAt(63&t>>8-u%1*8)){if(r=i.charCodeAt(u+=.75),r>255)throw new n;t=t<<8|r}return s}var o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";n.prototype=new Error,n.prototype.code=5,n.prototype.name="InvalidCharacterError",e.exports=r},function(e,t,n){"use strict";var r=n(2);e.exports=r.isStandardBrowserEnv()?function(){return{write:function(e,t,n,o,i,s){var u=[];u.push(e+"="+encodeURIComponent(t)),r.isNumber(n)&&u.push("expires="+new Date(n).toGMTString()),r.isString(o)&&u.push("path="+o),r.isString(i)&&u.push("domain="+i),s===!0&&u.push("secure"),document.cookie=u.join("; ")},read:function(e){var t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove:function(e){this.write(e,"",Date.now()-864e5)}}}():function(){return{write:function(){},read:function(){return null},remove:function(){}}}()},function(e,t,n){"use strict";function r(){this.handlers=[]}var o=n(2);r.prototype.use=function(e,t){return this.handlers.push({fulfilled:e,rejected:t}),this.handlers.length-1},r.prototype.eject=function(e){this.handlers[e]&&(this.handlers[e]=null)},r.prototype.forEach=function(e){o.forEach(this.handlers,function(t){null!==t&&e(t)})},e.exports=r},function(e,t,n){"use strict";function r(e){e.cancelToken&&e.cancelToken.throwIfRequested()}var o=n(2),i=n(19),s=n(20),u=n(6),a=n(21),c=n(22);e.exports=function(e){r(e),e.baseURL&&!a(e.url)&&(e.url=c(e.baseURL,e.url)),e.headers=e.headers||{},e.data=i(e.data,e.headers,e.transformRequest),e.headers=o.merge(e.headers.common||{},e.headers[e.method]||{},e.headers||{}),o.forEach(["delete","get","head","post","put","patch","common"],function(t){delete e.headers[t]});var t=e.adapter||u.adapter;return t(e).then(function(t){return r(e),t.data=i(t.data,t.headers,e.transformResponse),t},function(t){return s(t)||(r(e),t&&t.response&&(t.response.data=i(t.response.data,t.response.headers,e.transformResponse))),Promise.reject(t)})}},function(e,t,n){"use strict";var r=n(2);e.exports=function(e,t,n){return r.forEach(n,function(n){e=n(e,t)}),e}},function(e,t){"use strict";e.exports=function(e){return!(!e||!e.__CANCEL__)}},function(e,t){"use strict";e.exports=function(e){return/^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(e)}},function(e,t){"use strict";e.exports=function(e,t){return t?e.replace(/\/+$/,"")+"/"+t.replace(/^\/+/,""):e}},function(e,t){"use strict";function n(e){this.message=e}n.prototype.toString=function(){return"Cancel"+(this.message?": "+this.message:"")},n.prototype.__CANCEL__=!0,e.exports=n},function(e,t,n){"use strict";function r(e){if("function"!=typeof e)throw new TypeError("executor must be a function.");var t;this.promise=new Promise(function(e){t=e});var n=this;e(function(e){n.reason||(n.reason=new o(e),t(n.reason))})}var o=n(23);r.prototype.throwIfRequested=function(){if(this.reason)throw this.reason},r.source=function(){var e,t=new r(function(t){e=t});return{token:t,cancel:e}},e.exports=r},function(e,t){"use strict";e.exports=function(e){return function(t){return e.apply(null,t)}}}])});
9 | //# sourceMappingURL=axios.min.map
--------------------------------------------------------------------------------
/chrome-extension/js/view.js:
--------------------------------------------------------------------------------
1 | /* global Controller View mediator JiraHelper */
2 | window.View = window.View || {}
3 |
4 | window.View.Main = (function () {
5 | var worklogDateInput,
6 | getWorklogButton,
7 | worklogInput,
8 | addWorklogsButton,
9 | saveButton,
10 | totalHoursSpan
11 |
12 | function init () {
13 | setLoadingStatus(true)
14 |
15 | Controller.LogController.init().then(() => {
16 | View.Table.init()
17 |
18 | getWorklogButton = document.getElementById('getWorklogButton')
19 | worklogInput = document.getElementById('worklog')
20 | addWorklogsButton = document.getElementById('addWorklogs')
21 | saveButton = document.getElementById('save')
22 | totalHoursSpan = document.getElementById('totalHours')
23 |
24 | worklogDateInput = document.getElementById('worklogDate')
25 | // initialize date with today's date
26 | worklogDateInput.value = formatDate(new Date())
27 |
28 | mediator.on('modal.totalHours.update', totalHours => {
29 | totalHoursSpan.innerText =
30 | parseFloat(totalHours).toFixed(2) + 'h'
31 | })
32 |
33 | mediator.on('view.table.new-worklog.changed', () => {
34 | persistUnsavedData()
35 | .then(() => {
36 | console.log('persisted data locally.')
37 | })
38 | })
39 |
40 | mediator.on('view.table.worklog.changed', () => {
41 | persistUnsavedData()
42 | .then(() => {
43 | console.log('persisted data locally.')
44 | })
45 | })
46 |
47 | mediator.on('view.table.new-worklog.deleted', () => {
48 | persistUnsavedData()
49 | .then(() => {
50 | console.log('persisted data locally (deletion).')
51 | })
52 | })
53 |
54 | getWorklogButton.addEventListener('click', () => {
55 | setLoadingStatus(true)
56 | persistUnsavedData()
57 | .then(getWorklogItemsFromDate)
58 | .then(() => {
59 |
60 | }).catch(error => {
61 | console.warn(error)
62 | }).then(() => {
63 | setLoadingStatus(false)
64 | })
65 | })
66 |
67 | addWorklogsButton.addEventListener('click', () => {
68 | setLoadingStatus(true)
69 | Controller.LogController.bulkInsert(worklogInput.value).then(
70 | () => {
71 | worklogInput.value = ''
72 | mediator.trigger('view.table.new-worklog.changed', {})
73 | setLoadingStatus(false)
74 | }
75 | )
76 | })
77 |
78 | saveButton.addEventListener('click', () => {
79 | setLoadingStatus(true)
80 | var items = View.Table.getWorklogItems()
81 | Controller.LogController.save(items, worklogDateInput.value)
82 | .then(getWorklogItemsFromDate)
83 | .then(() => {
84 | alert('Worklog saved.')
85 | }).catch(error => {
86 | alert('Some items were not saved. Make sure the Jira numbers exist, and you are logged in Jira.')
87 | console.warn(error)
88 | }).then(() => {
89 | setLoadingStatus(false)
90 | })
91 | })
92 |
93 | worklogDateInput.addEventListener(
94 | 'input',
95 | () => {
96 | console.log('date changed: ' + worklogDateInput.value)
97 | setLoadingStatus(true)
98 | getWorklogItemsFromDate().then(() => {
99 |
100 | }).catch(error => {
101 | console.warn(error)
102 | }).then(() => {
103 | setLoadingStatus(false)
104 | })
105 | },
106 | true
107 | )
108 |
109 | getWorklogItemsFromDate().then(() => {
110 |
111 | }).catch(error => {
112 | console.warn(error)
113 | }).then(() => {
114 | setLoadingStatus(false)
115 | })
116 | })
117 | .catch(() => {
118 | document.getElementsByClassName('container')[0].classList.add('hidden')
119 | document.getElementsByClassName('error_status')[0].classList.remove('hidden')
120 | alert('Something went wrong. Please go to \'Options\' and make sure you are logged in Jira, and the Jira URL is correct.')
121 | setLoadingStatus(false)
122 | })
123 | }
124 |
125 | function persistUnsavedData () {
126 | var items = View.Table.getWorklogItems()
127 | return Controller.LogController.persistUnsavedData(worklogDateInput.value, items)
128 | }
129 |
130 | function getWorklogItemsFromDate () {
131 | var promise = Controller.LogController.getWorklogsByDay(
132 | worklogDateInput.value
133 | )
134 | promise
135 | .then(() => { })
136 | .catch(error => {
137 | alert(`Something went wrong.\n\n${error}`)
138 | })
139 | return promise
140 | }
141 |
142 | function formatDate (date) {
143 | var d = date
144 | var month = '' + (d.getMonth() + 1)
145 | var day = '' + d.getDate()
146 | var year = d.getFullYear()
147 |
148 | if (month.length < 2) month = '0' + month
149 | if (day.length < 2) day = '0' + day
150 |
151 | return [year, month, day].join('-')
152 | }
153 |
154 | function setLoadingStatus (isLoading) {
155 | if (isLoading) {
156 | document.getElementById('loading').classList.remove('hidden')
157 | } else {
158 | document.getElementById('loading').classList.add('hidden')
159 | }
160 | }
161 |
162 | return {
163 | init: init,
164 | setLoadingStatus: setLoadingStatus
165 | }
166 | })()
167 |
168 | window.View.Table = (function () {
169 | var table, tbody
170 | var originalWorklogItems = []
171 |
172 | var worklogTableRowTemplate = `
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 | `
188 |
189 | var statusClassList = {
190 | saved: 'worklog--saved',
191 | invalid: 'worklog--invalid',
192 | edited: 'worklog--edited',
193 | deleted: 'worklog--deleted'
194 | }
195 |
196 | function getStatusClass (status) {
197 | return statusClassList[status]
198 | }
199 |
200 | function addRow (worklogItem) {
201 | var row = worklogTableRowTemplate
202 | .replace('{{jiraNumber}}', worklogItem.jira)
203 | .replace('{{timeSpent}}', worklogItem.timeSpent)
204 | .replace('{{comment}}', worklogItem.comment)
205 | .replace('{{status}}', worklogItem.status)
206 | .replace('{{logId}}', worklogItem.logId)
207 | .replace('{{status-class}}', getStatusClass(worklogItem.status))
208 | .replace('{{jiraUrl}}', worklogItem.jiraUrl)
209 | .replace('{{link-disabled}}', worklogItem.jiraUrl ? '' : 'link-disabled')
210 | tbody.innerHTML += row
211 | }
212 |
213 | function clearRows () {
214 | var newTbody = document.createElement('tbody')
215 | tbody.parentNode.replaceChild(newTbody, tbody)
216 | tbody = newTbody
217 | }
218 |
219 | function populateWorklogTable (worklogItems) {
220 | clearRows()
221 |
222 | for (var i = 0; i < worklogItems.length; i++) {
223 | var worklogItem = worklogItems[i]
224 | updateJiraUrl(worklogItem)
225 | addRow(worklogItem)
226 | }
227 | }
228 |
229 | function getWorklogFromRow (row) {
230 | var status = row.getAttribute('data-status')
231 | var logId = row.getAttribute('data-id')
232 | var jira = row.querySelector('[name=jira]').value
233 | var timeSpent = row.querySelector('[name=timeSpent]').value
234 | var comment = row.querySelector('[name=comment]').value
235 | // var jira = row.get
236 | // ...
237 | return {
238 | status: status,
239 | jira: jira,
240 | timeSpent: timeSpent,
241 | comment: comment,
242 | logId: logId
243 | }
244 | }
245 |
246 | function validateInput (worklog, row) {
247 | var invalidFields = Controller.LogController.getInvalidFields(worklog)
248 | updateWorklogRowInputStatus(row, invalidFields)
249 | }
250 |
251 | function updateWorklogRowInputStatus (row, invalidFields) {
252 | var inputs = row.querySelectorAll('input[type=text]')
253 | inputs.forEach(input => {
254 | input.classList.remove('input--invalid')
255 | })
256 | if (invalidFields && invalidFields.length) {
257 | invalidFields.forEach(invalidFieldName => {
258 | row.querySelector(`[name=${invalidFieldName}]`).classList.add('input--invalid')
259 | })
260 | }
261 | }
262 |
263 | function getWorklogItems () {
264 | var items = []
265 |
266 | for (var i = 0, row; (row = tbody.rows[i]); i++) {
267 | items.push(getWorklogFromRow(row))
268 | }
269 | return items
270 | }
271 |
272 | function updateWorklogRowStatus (row, newStatus) {
273 | var newStatusClass = getStatusClass(newStatus)
274 | row.classList.remove('worklog--saved')
275 | row.classList.remove('worklog--edited')
276 | row.classList.remove('worklog--deleted')
277 | row.classList.add(newStatusClass)
278 | row.setAttribute('data-status', newStatus)
279 | }
280 |
281 | function isEqual (worklog1, worklog2) {
282 | return worklog1.jira === worklog2.jira &&
283 | worklog1.comment === worklog2.comment &&
284 | worklog1.timeSpent === worklog2.timeSpent
285 | }
286 |
287 | function updateJiraUrl (worklog) {
288 | worklog.jiraUrl = JiraHelper.getJiraUrl(worklog.jira)
289 | }
290 |
291 | function updateJiraUrlLink (url, row) {
292 | var link = row.querySelector('a.open-link-button')
293 | if (url) {
294 | link.href = url
295 | link.classList.remove('link-disabled')
296 | } else {
297 | link.classList.add('link-disabled')
298 | }
299 | }
300 |
301 | function worklogChanged (e) {
302 | var row = e.srcElement.parentElement.parentElement
303 | var worklog = getWorklogFromRow(row)
304 | console.log('worklog changed', worklog)
305 | validateInput(worklog, row)
306 | updateJiraUrl(worklog)
307 | updateJiraUrlLink(worklog.jiraUrl, row)
308 | if (worklog.status !== 'new') {
309 | changeStatusForUpdate(row, worklog)
310 | mediator.trigger('view.table.worklog.changed', worklog)
311 | } else {
312 | mediator.trigger('view.table.new-worklog.changed', worklog)
313 | }
314 | }
315 |
316 | function changeStatusForUpdate (row, worklog) {
317 | var originalWorklog = originalWorklogItems.filter(item => {
318 | return item.logId === worklog.logId
319 | })[0]
320 | if (isEqual(originalWorklog, worklog)) {
321 | updateWorklogRowStatus(row, 'saved')
322 | } else {
323 | updateWorklogRowStatus(row, 'edited')
324 | }
325 | }
326 |
327 | function deleteRow (row) {
328 | tbody.removeChild(row)
329 | }
330 |
331 | function worklogDeleted (e) {
332 | var row = e.srcElement.parentElement.parentElement
333 | var worklog = getWorklogFromRow(row)
334 |
335 | if (worklog.status === 'new') {
336 | // just delete the row
337 | deleteRow(row)
338 | mediator.trigger('view.table.new-worklog.deleted', worklog)
339 | } else {
340 | // mark existing item for deletion
341 | changeStatusForDeletion(row, worklog)
342 | }
343 | }
344 |
345 | function changeStatusForDeletion (row, worklog) {
346 | if (worklog.status === 'deleted') {
347 | updateWorklogRowStatus(row, 'saved')
348 | changeStatusForUpdate(row, worklog)
349 | } else {
350 | updateWorklogRowStatus(row, 'deleted')
351 | }
352 | }
353 |
354 | function configureInputListeners () {
355 | var inputs = tbody.querySelectorAll('input[type=text]')
356 |
357 | inputs.forEach(input => {
358 | input.removeEventListener('input', worklogChanged)
359 | input.addEventListener('input', worklogChanged)
360 | })
361 |
362 | var deleteButtons = tbody.querySelectorAll('a.delete-button')
363 |
364 | deleteButtons.forEach(deleteButton => {
365 | deleteButton.removeEventListener('click', worklogDeleted)
366 | deleteButton.addEventListener('click', worklogDeleted)
367 | })
368 | }
369 |
370 | function init () {
371 | table = document.getElementById('worklog-items')
372 | tbody = table.getElementsByTagName('tbody')[0]
373 |
374 | mediator.on('model.workloglist.updated', worklogItems => {
375 | originalWorklogItems = worklogItems
376 | populateWorklogTable(worklogItems)
377 | configureInputListeners()
378 | })
379 | }
380 |
381 | return {
382 | init: init,
383 | addRow: addRow,
384 | deleteRow: deleteRow,
385 | clearRows: clearRows,
386 | populateWorklogTable: populateWorklogTable,
387 | getWorklogItems: getWorklogItems
388 | }
389 | })()
390 |
--------------------------------------------------------------------------------