diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5a938ce --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "useTabs": false +} diff --git a/app.js b/app.js index 7d1d36a..22be8f7 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,14 @@ var nunjucks = require("nunjucks"); const passport = require('passport'); const AtlassianOAuth2Strategy = require('passport-atlassian-oauth2'); const session = require('express-session'); + +var fs = require('fs'); + +if (!fs.existsSync('.env')) { + console.log("No .env file found. Please create one using the .env.example file as a template."); + process.exit(1); +} + const dotenv = require('dotenv').config(); const https = require('https'); diff --git a/controllers/jiraAPIController.js b/controllers/jiraAPIController.js index e4ffc28..1f42ae8 100644 --- a/controllers/jiraAPIController.js +++ b/controllers/jiraAPIController.js @@ -124,6 +124,23 @@ exports.searchIssues = function(req, jql) { return withRetry(searchIssuesInternal, req, jql); } +exports.suggestIssues = function(req, query) { + return withRetry(suggestIssuesInternal, req, query); +} + +function suggestIssuesInternal(req, query) { + const url = getCallURL(req); + if (process.env.JIRA_PROJECT_JQL) { + query += '¤tJQL=' + process.env.JIRA_PROJECT_JQL + } + return fetch(url + '/rest/api/3/issue/picker?query=' + query , { + method: 'GET', + headers: getDefaultHeaders(req), + agent:httpsAgent + }).then(res => res.json()); + +} + function getIssueInternal(req, issueId) { const url = getCallURL(req); return fetch(url + '/rest/api/3/issue/' + issueId, { diff --git a/controllers/jiraController.js b/controllers/jiraController.js index c5404b1..378aed3 100644 --- a/controllers/jiraController.js +++ b/controllers/jiraController.js @@ -9,7 +9,9 @@ exports.getUsersWorkLogsAsEvent = function(req, start, end) { const formattedStart = filterStartTime.toLocaleDateString('en-CA'); const formattedEnd = filterEndTime.toLocaleDateString('en-CA'); - return jiraAPIController.searchIssues(req, 'worklogAuthor = currentUser() AND worklogDate >= ' + formattedStart + ' AND worklogDate <= '+ formattedEnd).then(result => { + let issuesPromise = jiraAPIController.searchIssues(req, 'worklogAuthor = currentUser() AND worklogDate >= ' + formattedStart + ' AND worklogDate <= '+ formattedEnd); + + return issuesPromise.then(result => { // create an array of issue IDs and keys from result.issues const issues = result.issues.map(issue => { return {issueId: issue.id, issueKey: issue.key, summary: issue.fields.summary} }); const userWorkLogs = []; @@ -87,6 +89,65 @@ exports.deleteWorkLog = function(req, issueId, worklogId) { return jiraAPIController.deleteWorkLog(req, issueId, worklogId); } +exports.suggestIssues = function(req, start, end, query) { + var startDate = new Date(start).toISOString().split('T')[0]; + var endDate = new Date(end).toISOString().split('T')[0]; + var searchInJira = req.query.searchInJira; + var query = req.query.query; + + var promises = []; + + var emptyJQL = 'worklogAuthor = currentUser() AND worklogDate >= ' + startDate + ' AND worklogDate <= '+endDate+' OR ((assignee = currentUser() OR reporter = currentUser()) AND ((statusCategory != '+ process.env.JIRA_DONE_STATUS +') OR (statusCategory = '+ process.env.JIRA_DONE_STATUS +' AND status CHANGED DURING (' + startDate + ', '+endDate+'))))'; + + var keyJQL = 'key = ' + query + + if (searchInJira != 'true') { + promises.push(jiraAPIController.searchIssues(req, emptyJQL)); + } else { + promises.push(jiraAPIController.searchIssues(req, keyJQL)); + promises.push(jiraAPIController.suggestIssues(req, query)); + } + + + + return Promise.all(promises).then(results => { + // results[0] = base JQL search + // if search in jira + // results[0] = Key search + // results[1] = Suggestion search + + var issues = [] + if (searchInJira != 'true' && results[0].issues) { + issues = results[0].issues.map(mapIssuesFunction); + } + + if (searchInJira == 'true') { + if (results[1] && results[1].sections && results[1].sections.length > 0) { + for (var i = 0; i < results[1].sections.length; i++) { + if (results[1].sections[i].id == "cs") { + //console.log(JSON.stringify(results[0].sections[i])); + // add issues from the suggestion results to the issues array + var mappedIssues = results[1].sections[i].issues.map(mapIssuesFunction); + issues = issues.concat(mappedIssues); + } + } + } + if (results[0] && results[0].issues) { + issues = issues.concat(results[0].issues.map(mapIssuesFunction)); + } + } + return issues; + }); +} + +function mapIssuesFunction(issue) { + return { + id: issue.id, + key: issue.key, + summary: issue.fields ? issue.fields.summary : issue.summary + } +} + function formatDateToJira(toFormat) { const dayJsDate = dayjs(toFormat); diff --git a/example.env b/example.env index 223e240..cb048e6 100644 --- a/example.env +++ b/example.env @@ -29,6 +29,8 @@ JIRA_OAUTH_CALLBACK_URL=http://locahost:3000/auth/callback JIRA_DONE_STATUS="Done" # Maximum number of issues to return at once. JIRA_MAX_SEARCH_RESULTS=200 +# JQL used to filter suggested issues. uswful for restricting to a project +JIRA_PROJECT_JQL="project = PLY" # Express app settings # The port to run the app on diff --git a/public/images/icons8-dots-loading.gif b/public/images/icons8-dots-loading.gif new file mode 100644 index 0000000..4f6c443 Binary files /dev/null and b/public/images/icons8-dots-loading.gif differ diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css index 8dd7221..2e9fe62 100644 --- a/public/stylesheets/style.css +++ b/public/stylesheets/style.css @@ -59,6 +59,19 @@ a { margin-bottom: 15px; } +.modal-content .search-options { + display: flex; + width: fit-content; + align-items: center; +} +.modal-content .search-options input { + flex-grow: 0; +} +.modal-content .search-options label { + word-break: keep-all; + white-space: nowrap; +} + .modal .destructive { border: 2px solid darkred; background: red; @@ -139,12 +152,16 @@ input#include-weekends { } -.about-section { +#about-section { position: absolute; bottom: 5px; opacity: 35%; } +#loading-container { + width: 50px; + overflow: clip; +} /********** Choices.js **********/ diff --git a/routes/index.js b/routes/index.js index dbca415..5f96085 100644 --- a/routes/index.js +++ b/routes/index.js @@ -30,19 +30,13 @@ router.get('/events', function(req, res, next) { router.get('/issues/user', function(req, res, next) { try { - var startDate = new Date(req.query.start).toISOString().split('T')[0]; - - var endDate = new Date(req.query.end).toISOString().split('T')[0]; - - jiraAPIController.searchIssues(req, 'worklogAuthor = currentUser() AND worklogDate >= ' + startDate + ' AND worklogDate <= '+endDate+' OR ((assignee = currentUser() OR reporter = currentUser()) AND ((statusCategory != '+ process.env.JIRA_DONE_STATUS +') OR (statusCategory = '+ process.env.JIRA_DONE_STATUS +' AND status CHANGED DURING (' + startDate + ', '+endDate+'))))').then(result => { - if (!result.issues) { - console.log(result); - } - res.json(result.issues); + jiraController.suggestIssues(req, req.query.start, req.query.end, req.query.query).then(result => { + res.json(result); }); } catch (error) { console.log(error); } + }); router.get('/issues/:issueId', function(req, res, next) { diff --git a/views/aboutModal.njk b/views/aboutModal.njk new file mode 100644 index 0000000..42a3b46 --- /dev/null +++ b/views/aboutModal.njk @@ -0,0 +1,39 @@ +{% macro modal() %} + + + +{% endmacro %} \ No newline at end of file diff --git a/views/createModal.njk b/views/createModal.njk new file mode 100644 index 0000000..b1b471b --- /dev/null +++ b/views/createModal.njk @@ -0,0 +1,225 @@ +{% macro modal(endpoint) %} + + + + +{% endmacro %} diff --git a/views/index.njk b/views/index.njk index ccd572b..dfaae52 100644 --- a/views/index.njk +++ b/views/index.njk @@ -1,5 +1,5 @@ -{% import "worklogModal.njk" as worklogModal %} +{% import "modals.njk" as modals %} @@ -56,6 +56,13 @@ endTime: '17:00', // an end time }, scrollTime: '08:00', + loading: function(bool) { + if (bool) { + showLoading(); + } else { + hideLoading(); + } + }, events: { url: '/events', method: 'GET', @@ -67,9 +74,10 @@ } else { alert('there was an error while fetching events!'); } - + hideLoading(); }, success: function(events) { + hideLoading(); updateTotalTime(events); } }, @@ -80,11 +88,11 @@ return true }) updateTotalTime(visibleEvents); - refreshIssuesDropdown(); }, eventResize: function(info) { // use fetch to call /worklog with a PUT request, passing the new start time and calculated duration + showLoading(); fetch('/worklog/' + info.event.extendedProps.worklogId, { method: 'PUT', headers: { @@ -96,15 +104,17 @@ duration: (info.event.end - info.event.start)/1000 }) }).then(function(response) { - + hideLoading(); }).catch((error) => { if(error && error.message && error.message=="NetworkError when attempting to fetch resource.") { window.location.href = "/auth/login"; } info.revert(); + hideLoading(); }); }, eventDrop: function(info) { + showLoading(); fetch('/worklog/' + info.event.extendedProps.worklogId, { method: 'PUT', headers: { @@ -116,13 +126,14 @@ duration: (info.event.end - info.event.start)/1000 }) }).then(function(response) { - + hideLoading(); }).catch((error) => { if(error && error.message && error.message=="NetworkError when attempting to fetch resource.") { window.location.href = "/auth/login"; } info.revert(); + hideLoading(); }); }, eventClick: function(info) { @@ -151,25 +162,36 @@ if (timeVal < 10) { timeVal = '0' + timeVal; } - /*switch(this.value) { + + var zoomLabel = document.getElementById("zoom-output"); + switch(this.value) { case '1': calendar.setOption('slotDuration', '00:30:00'); + zoomLabel.innerHTML = "30 minutes / slot"; break; case '2': - calendar.setOption('slotDuration', '00:20:00'); + calendar.setOption('slotDuration', '00:15:00'); + zoomLabel.innerHTML = "15 minutes / slot"; break; case '3': - calendar.setOption('slotDuration', '00:15:00'); + calendar.setOption('slotDuration', '00:10:00'); + zoomLabel.innerHTML = "10 minutes / slot"; + break; + case '4': + calendar.setOption('slotDuration', '00:05:00'); + zoomLabel.innerHTML = "5 minutes / slot"; break; - }*/ - calendar.setOption('slotDuration', '00:'+timeVal+':00'); - document.getElementById("zoom-output").innerHTML = this.value + " minutes / slot"; + } + //calendar.setOption('slotDuration', '00:'+timeVal+':00'); + } var weekendInput = document.getElementById("include-weekends"); weekendInput.addEventListener("change", () => { calendar.setOption('weekends', weekendInput.checked); }); + + hideLoading(); }); function updateTotalTime(events) { @@ -183,17 +205,27 @@ var timeElement = document.getElementById('total-time-value'); timeElement.innerHTML = moment.duration(totalTime).format("H [hour and] m [min]"); } + + function showLoading() { + document.getElementById('loading').style.display = 'block'; + } + + function hideLoading() { + document.getElementById('loading').style.display = 'none'; + } - {{ worklogModal.modalForm('/worklog/') }} + {{ modals.modals('/worklog/') }}
\ No newline at end of file diff --git a/views/modals.njk b/views/modals.njk new file mode 100644 index 0000000..32ebb7e --- /dev/null +++ b/views/modals.njk @@ -0,0 +1,57 @@ +{% macro modals(endpoint) %} +{% import "createModal.njk" as createModal %} +{% import "updateModal.njk" as updateModal %} +{% import "aboutModal.njk" as aboutModal %} + {{ aboutModal.modal() }} + {{ createModal.modal(endpoint) }} + {{ updateModal.modal(endpoint) }} + +{% endmacro %} \ No newline at end of file diff --git a/views/updateModal.njk b/views/updateModal.njk new file mode 100644 index 0000000..6335e8b --- /dev/null +++ b/views/updateModal.njk @@ -0,0 +1,160 @@ +{% macro modal(endpoint) %} + + + + + +{% endmacro %} \ No newline at end of file diff --git a/views/worklogModal.njk b/views/worklogModal.njk deleted file mode 100644 index 2354909..0000000 --- a/views/worklogModal.njk +++ /dev/null @@ -1,328 +0,0 @@ -{% macro modalForm(endpoint) %} - - - - - -{% endmacro %} \ No newline at end of file