Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
* [KIMAI] KimaiAPI: easy access to API methods
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiAPI extends KimaiPlugin {
|
||||
@@ -18,135 +17,162 @@ export default class KimaiAPI extends KimaiPlugin {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
_headers() {
|
||||
const headers = new Headers();
|
||||
headers.append('X-AUTH-SESSION', '1');
|
||||
headers.append('Content-Type', 'application/json');
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
get(url, data, callbackSuccess, callbackError) {
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
if (data !== undefined) {
|
||||
const params = (new URLSearchParams(data)).toString();
|
||||
if (params !== '') {
|
||||
url = url + (url.includes('?') ? '&' : '?') + params;
|
||||
}
|
||||
}
|
||||
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('An error occurred', error);
|
||||
};
|
||||
}
|
||||
|
||||
this.fetch(url, {
|
||||
method: 'GET',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
headers: this._headers()
|
||||
}).then((response) => {
|
||||
response.json().then((json) => {
|
||||
callbackSuccess(json);
|
||||
});
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
post(url, data, callbackSuccess, callbackError) {
|
||||
if (callbackError === null || callbackError === undefined) {
|
||||
callbackError = this.getPostErrorHandler();
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('action.update.error', error);
|
||||
};
|
||||
}
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
this.fetch(url, {
|
||||
method: 'POST',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
body: this._parseData(data),
|
||||
headers: this._headers()
|
||||
}).then((response) => {
|
||||
response.json().then((json) => {
|
||||
callbackSuccess(json);
|
||||
});
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
patch(url, data, callbackSuccess, callbackError) {
|
||||
if (callbackError === null || callbackError === undefined) {
|
||||
callbackError = this.getPatchErrorHandler();
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('action.update.error', error);
|
||||
};
|
||||
}
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
this.fetch(url, {
|
||||
method: 'PATCH',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
body: this._parseData(data),
|
||||
headers: this._headers()
|
||||
}).then((response) => {
|
||||
if (response.statusCode === 204) {
|
||||
callbackSuccess();
|
||||
} else {
|
||||
response.json().then((json) => {
|
||||
callbackSuccess(json);
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
delete(url, callbackSuccess, callbackError) {
|
||||
if (callbackError === null || callbackError === undefined) {
|
||||
callbackError = this.getDeleteErrorHandler();
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('action.delete.error', error);
|
||||
};
|
||||
}
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
this.fetch(url, {
|
||||
method: 'DELETE',
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
headers: this._headers()
|
||||
}).then(() => {
|
||||
callbackSuccess();
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
getDeleteErrorHandler() {
|
||||
const self = this;
|
||||
return function(xhr, err) {
|
||||
self.handleError('action.delete.error', xhr, err);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @param {string|object} data
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_parseData(data) {
|
||||
if (typeof data === 'object') {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
getPatchErrorHandler() {
|
||||
const self = this;
|
||||
return function(xhr, err) {
|
||||
self.handleError('action.update.error', xhr, err);
|
||||
};
|
||||
}
|
||||
|
||||
getPostErrorHandler() {
|
||||
const self = this;
|
||||
return function(xhr, err) {
|
||||
self.handleError('action.update.error', xhr, err);
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {jqXHR} xhr
|
||||
* @param {string} err
|
||||
* @param {Response} response
|
||||
*/
|
||||
handleError(message, xhr, err) {
|
||||
let resultError = err;
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
resultError = xhr.responseJSON.message;
|
||||
// find validation errors
|
||||
if (xhr.status === 400 && xhr.responseJSON.errors) {
|
||||
let collected = ['<u>' + resultError + '</u>'];
|
||||
// form errors that are not attached to a field (like extra fields)
|
||||
if (xhr.responseJSON.errors.errors) {
|
||||
for (let error of xhr.responseJSON.errors.errors) {
|
||||
collected.push(error);
|
||||
handleError(message, response) {
|
||||
if (response.headers === undefined) {
|
||||
// this can happen if someone clicks to fast and auto running
|
||||
// requests (e.g. active records) are aborted
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||
response.json().then(data => {
|
||||
let resultError = data.message;
|
||||
// find validation errors
|
||||
if (response.status === 400 && data.errors) {
|
||||
let collected = ['<u>' + resultError + '</u>'];
|
||||
// form errors that are not attached to a field (like extra fields)
|
||||
if (data.errors.errors) {
|
||||
for (let error of data.errors.errors) {
|
||||
collected.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (xhr.responseJSON.errors.children) {
|
||||
for (let field in xhr.responseJSON.errors.children) {
|
||||
let tmpField = xhr.responseJSON.errors.children[field];
|
||||
if (tmpField.hasOwnProperty('errors') && tmpField.errors.length > 0) {
|
||||
for (let error of tmpField.errors) {
|
||||
collected.push(error);
|
||||
if (data.errors.children) {
|
||||
for (let field in data.errors.children) {
|
||||
let tmpField = data.errors.children[field];
|
||||
if (tmpField.errors !== undefined && tmpField.errors.length > 0) {
|
||||
for (let error of tmpField.errors) {
|
||||
collected.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (collected.length > 0) {
|
||||
resultError = collected;
|
||||
}
|
||||
}
|
||||
if (collected.length > 0) {
|
||||
resultError = collected;
|
||||
}
|
||||
}
|
||||
} else if (xhr.status && xhr.statusText) {
|
||||
resultError = '[' + xhr.status + '] ' + xhr.statusText;
|
||||
}
|
||||
|
||||
this.getPlugin('alert').error(message, resultError);
|
||||
this.getPlugin('alert').error(message, resultError);
|
||||
|
||||
});
|
||||
} else {
|
||||
response.text().then(() => {
|
||||
const resultError = '[' + response.statusCode + '] ' + response.statusText;
|
||||
this.getPlugin('alert').error(message, resultError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,15 +22,14 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
document.addEventListener('click', function(event) {
|
||||
document.addEventListener('click', (event) => {
|
||||
let target = event.target;
|
||||
while (target !== null && !target.matches('body')) {
|
||||
if (target.classList.contains(self.selector)) {
|
||||
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
|
||||
if (target.classList.contains(this._selector)) {
|
||||
const attributes = target.dataset;
|
||||
|
||||
let url = attributes['href'];
|
||||
@@ -39,13 +38,13 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
}
|
||||
|
||||
if (attributes.question !== undefined) {
|
||||
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||
this.getContainer().getPlugin('alert').question(attributes.question, (value) => {
|
||||
if (value) {
|
||||
self._callApi(url, attributes);
|
||||
this._callApi(url, attributes);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
self._callApi(url, attributes);
|
||||
this._callApi(url, attributes);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
@@ -57,37 +56,45 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {DOMStringMap} attributes
|
||||
* @private
|
||||
*/
|
||||
_callApi(url, attributes)
|
||||
{
|
||||
const method = attributes['method'];
|
||||
const eventName = attributes['event'];
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
const eventing = this.getContainer().getPlugin('event');
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
const successHandle = function(result) {
|
||||
eventing.trigger(eventName);
|
||||
if (attributes.msgSuccess) {
|
||||
alert.success(attributes.msgSuccess);
|
||||
/** @type {KimaiEvent} EVENTS */
|
||||
const EVENTS = this.getContainer().getPlugin('event');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.getContainer().getPlugin('alert');
|
||||
const successHandle = () => {
|
||||
EVENTS.trigger(eventName);
|
||||
if (attributes['msgSuccess'] !== undefined) {
|
||||
ALERT.success(attributes['msgSuccess']);
|
||||
}
|
||||
};
|
||||
const errorHandle = function(xhr, err) {
|
||||
const errorHandle = (error) => {
|
||||
let message = 'action.update.error';
|
||||
if (attributes.msgError) {
|
||||
message = attributes.msgError;
|
||||
if (attributes['msgError'] !== undefined) {
|
||||
message = attributes['msgError'];
|
||||
}
|
||||
API.handleError(message, xhr, err);
|
||||
API.handleError(message, error);
|
||||
};
|
||||
|
||||
if (method === 'PATCH') {
|
||||
let data = {};
|
||||
if (attributes.payload) {
|
||||
data = attributes.payload;
|
||||
if (attributes['payload'] !== undefined) {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
API.patch(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'POST') {
|
||||
let data = {};
|
||||
if (attributes.payload) {
|
||||
data = attributes.payload;
|
||||
if (attributes['payload'] !== undefined) {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
API.post(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'DELETE') {
|
||||
|
||||
@@ -15,8 +15,8 @@ export default class KimaiActiveRecords extends KimaiPlugin {
|
||||
|
||||
constructor(selector, selectorEmpty) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this.selectorEmpty = selectorEmpty;
|
||||
this._selector = selector;
|
||||
this._selectorEmpty = selectorEmpty;
|
||||
}
|
||||
|
||||
getId() {
|
||||
@@ -24,101 +24,146 @@ export default class KimaiActiveRecords extends KimaiPlugin {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.menu = document.querySelector(this.selector);
|
||||
this._menu = document.querySelector(this._selector);
|
||||
|
||||
// the menu can be hidden if user has no permissions to see it
|
||||
if (this.menu === null) {
|
||||
if (this._menu === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attributes = this.menu.dataset;
|
||||
this.attributes = this._menu.dataset;
|
||||
|
||||
const self = this;
|
||||
const handle = function() { self.reloadActiveRecords(); };
|
||||
const handleUpdate = () => {
|
||||
this.reloadActiveRecords();
|
||||
};
|
||||
|
||||
document.addEventListener('kimai.timesheetUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.timesheetDelete', handleUpdate);
|
||||
document.addEventListener('kimai.activityUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.activityDelete', handleUpdate);
|
||||
document.addEventListener('kimai.projectUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.projectDelete', handleUpdate);
|
||||
document.addEventListener('kimai.customerUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.customerDelete', handleUpdate);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle duration in the visible UI
|
||||
this._updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
|
||||
this._updateDuration();
|
||||
const handle = () => {
|
||||
this._updateDuration();
|
||||
};
|
||||
this._updatesHandler = setInterval(handle, 10000);
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
document.addEventListener('kimai.timesheetDelete', handle);
|
||||
document.addEventListener('kimai.activityUpdate', handle);
|
||||
document.addEventListener('kimai.activityDelete', handle);
|
||||
document.addEventListener('kimai.projectUpdate', handle);
|
||||
document.addEventListener('kimai.projectDelete', handle);
|
||||
document.addEventListener('kimai.customerUpdate', handle);
|
||||
document.addEventListener('kimai.customerDelete', handle);
|
||||
document.addEventListener('kimai.reloadedContent', handle);
|
||||
}
|
||||
|
||||
_toggleMenu(hasEntries) {
|
||||
this.menu.style.display = hasEntries ? 'inline-block' : 'none';
|
||||
// TODO we could unregister all handler and listener
|
||||
// _unregisterHandler() {
|
||||
// clearInterval(this._updatesHandler);
|
||||
// }
|
||||
|
||||
_updateDuration() {
|
||||
const activeRecords = this._menu.querySelectorAll('[data-since]:not([data-since=""])');
|
||||
|
||||
if (activeRecords.length === 0) {
|
||||
if (this._updateBrowserTitle) {
|
||||
if (document.body.dataset['title'] === undefined) {
|
||||
this._updateBrowserTitle = false;
|
||||
} else {
|
||||
document.title = document.body.dataset['title'];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const DATE = this.getDateUtils();
|
||||
let durations = [];
|
||||
|
||||
for (const record of activeRecords) {
|
||||
const duration = DATE.formatDuration(record.dataset['since']);
|
||||
// only use the ones from the menu for the title
|
||||
if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
|
||||
durations.push(duration);
|
||||
}
|
||||
// but update all on the page (running entries in list pages)
|
||||
record.textContent = duration;
|
||||
}
|
||||
|
||||
if (durations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._updateBrowserTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let title = durations.shift();
|
||||
for (const duration of durations.slice(0, 2)) {
|
||||
title += ' | ' + duration;
|
||||
}
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
_setEntries(entries) {
|
||||
const hasEntries = entries.length > 0;
|
||||
|
||||
this._menu.style.display = hasEntries ? 'inline-block' : 'none';
|
||||
if (!hasEntries) {
|
||||
// make sure that template entries in the menu are removed, otherwise they
|
||||
// might still be shown in the browsers title
|
||||
for (let record of this.menu.querySelectorAll('[data-since]')) {
|
||||
for (let record of this._menu.querySelectorAll('[data-since]')) {
|
||||
record.dataset['since'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
const menuEmpty = document.querySelector(this.selectorEmpty);
|
||||
const menuEmpty = document.querySelector(this._selectorEmpty);
|
||||
if (menuEmpty !== null) {
|
||||
menuEmpty.style.display = !hasEntries ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
setEntries(entries) {
|
||||
this._toggleMenu(entries.length > 0);
|
||||
const stop = this._menu.querySelector('.ticktac-stop');
|
||||
|
||||
const template = this.menu.querySelector('[data-template="active-record"]');
|
||||
|
||||
const label = this.menu.querySelector('a > span.label');
|
||||
if (label !== null) {
|
||||
label.innerText = entries.length === 0 ? '' : entries.length;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
if (!hasEntries) {
|
||||
if (stop) {
|
||||
stop.accesskey = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (template === null) {
|
||||
this._replaceInNode(this.menu, entries[0]);
|
||||
} else {
|
||||
const container = template.parentElement;
|
||||
container.innerHTML = '';
|
||||
|
||||
for (let timesheet of entries) {
|
||||
const newNode = template.cloneNode(true);
|
||||
container.appendChild(this._replaceInNode(newNode, timesheet));
|
||||
}
|
||||
if (stop) {
|
||||
stop.accesskey = 's';
|
||||
}
|
||||
|
||||
this.getContainer().getPlugin('timesheet-duration').updateRecords();
|
||||
this._replaceInNode(this._menu, entries[0]);
|
||||
this._updateDuration();
|
||||
}
|
||||
|
||||
_replaceInNode(node, timesheet) {
|
||||
const date = this.getContainer().getPlugin('date');
|
||||
const date = this.getDateUtils();
|
||||
const allReplacer = node.querySelectorAll('[data-replacer]');
|
||||
for (let node of allReplacer) {
|
||||
const replacerName = node.dataset['replacer'];
|
||||
for (let link of allReplacer) {
|
||||
const replacerName = link.dataset['replacer'];
|
||||
if (replacerName === 'url') {
|
||||
node.href = this.attributes['href'].replace('000', timesheet.id);
|
||||
link.href = this.attributes['href'].replace('000', timesheet.id);
|
||||
} else if (replacerName === 'activity') {
|
||||
node.innerText = timesheet.activity.name;
|
||||
link.innerText = timesheet.activity.name;
|
||||
} else if (replacerName === 'project') {
|
||||
node.innerText = timesheet.project.name;
|
||||
link.innerText = timesheet.project.name;
|
||||
} else if (replacerName === 'customer') {
|
||||
node.innerText = timesheet.project.customer.name;
|
||||
link.innerText = timesheet.project.customer.name;
|
||||
} else if (replacerName === 'duration') {
|
||||
node.dataset['since'] = timesheet.begin;
|
||||
node.innerText = date.formatDuration(timesheet.duration);
|
||||
link.dataset['since'] = timesheet.begin;
|
||||
link.innerText = date.formatDuration(timesheet.duration);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
reloadActiveRecords() {
|
||||
const self = this;
|
||||
const API= this.getContainer().getPlugin('api');
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(this.attributes['api'], {}, function(result) {
|
||||
self.setEntries(result);
|
||||
API.get(this.attributes['api'], {}, (result) => {
|
||||
this._setEntries(result);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiActiveRecordsDuration: activate the updates for all active timesheet records on this page
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
return 'timesheet-duration';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
|
||||
this.updateRecords();
|
||||
const self = this;
|
||||
const handle = function() { self.updateRecords(); };
|
||||
this._updatesHandler = setInterval(handle, 10000);
|
||||
// this will probably not work as expected, as other event-handler might need longer to update the DOM
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
}
|
||||
|
||||
unregisterUpdates() {
|
||||
clearInterval(this._updatesHandler);
|
||||
}
|
||||
|
||||
updateRecords() {
|
||||
let durations = [];
|
||||
const activeRecords = document.querySelectorAll('[data-since]:not([data-since=""])');
|
||||
|
||||
if (activeRecords.length === 0) {
|
||||
if (this.updateBrowserTitle) {
|
||||
if (document.body.dataset['title'] === undefined) {
|
||||
this.updateBrowserTitle = false;
|
||||
} else {
|
||||
document.title = document.body.dataset['title'];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const DATE = this.getPlugin('date');
|
||||
|
||||
for (let record of activeRecords) {
|
||||
const since = record.dataset['since'];
|
||||
const duration = DATE.formatDuration(since);
|
||||
// only use the ones from the menu for the title
|
||||
if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
|
||||
durations.push(duration);
|
||||
}
|
||||
// but update all on the page (running entries in list pages)
|
||||
record.textContent = duration;
|
||||
}
|
||||
|
||||
if (durations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.updateBrowserTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let title = durations.shift();
|
||||
let prefix = ' | ';
|
||||
|
||||
for (let duration of durations.slice(0, 2)) {
|
||||
title += prefix + duration;
|
||||
}
|
||||
document.title = title;
|
||||
}
|
||||
}
|
||||
@@ -12,70 +12,87 @@
|
||||
* opening a modal with the content from the URL given in the elements 'data-href' or 'href' attribute
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiReducedClickHandler from "./KimaiReducedClickHandler";
|
||||
import { Modal } from 'bootstrap';
|
||||
|
||||
export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'modal';
|
||||
}
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
this.isDirty = false;
|
||||
init()
|
||||
{
|
||||
this._isDirty = false;
|
||||
|
||||
this.modal = jQuery('#remote_form_modal');
|
||||
this.modal
|
||||
.on('hide.bs.modal', function (e) {
|
||||
if (self.isDirty) {
|
||||
if (jQuery('#remote_form_modal .modal-body .remote_modal_is_dirty_warning').length === 0) {
|
||||
const msg = self.getContainer().getTranslation().get('modal.dirty');
|
||||
jQuery('#remote_form_modal .modal-body').prepend('<p class="'+(self.modal.hasClass('modal-danger') ? 'well well-sm ' : '') + 'text-danger small remote_modal_is_dirty_warning">' + msg + '</p>');
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
const modalElement = this._getModalElement();
|
||||
if (modalElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalElement.addEventListener('hide.bs.modal', (event) => {
|
||||
if (this._isDirty) {
|
||||
if (modalElement.querySelector('.modal-body .remote_modal_is_dirty_warning') === null) {
|
||||
const msg = this.translate('modal.dirty');
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = '<p class="text-danger small remote_modal_is_dirty_warning">' + msg + '</p>';
|
||||
modalElement.querySelector('.modal-body').prepend(temp.firstElementChild);
|
||||
}
|
||||
jQuery(self._getFormIdentifier()).off('change', self._isDirtyHandler);
|
||||
self.isDirty = false;
|
||||
self.getContainer().getPlugin('event').trigger('modal-hide');
|
||||
})
|
||||
.on('hidden.bs.modal', function () {
|
||||
// kill all references, so GC can kick in
|
||||
self.getContainer().getPlugin('form').destroyForm(self._getFormIdentifier());
|
||||
jQuery('#remote_form_modal .modal-body').replaceWith('');
|
||||
})
|
||||
.on('show.bs.modal', function () {
|
||||
self.getContainer().getPlugin('event').trigger('modal-show');
|
||||
});
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this._isDirty = false;
|
||||
document.dispatchEvent(new Event('modal-hide'));
|
||||
});
|
||||
|
||||
this._addClickHandler(this.selector, function(href) {
|
||||
self.openUrlInModal(href);
|
||||
modalElement.addEventListener('hidden.bs.modal', () => {
|
||||
// kill all references, so GC can kick in
|
||||
this.getContainer().getPlugin('form').destroyForm(this._getFormIdentifier());
|
||||
modalElement.querySelector('.modal-body').replaceWith('');
|
||||
});
|
||||
|
||||
modalElement.addEventListener('show.bs.modal', () => {
|
||||
document.dispatchEvent(new Event('modal-show'));
|
||||
});
|
||||
|
||||
this.addClickHandler(this._selector, (href) => {
|
||||
this.openUrlInModal(href);
|
||||
});
|
||||
}
|
||||
|
||||
openUrlInModal(url, errorHandler) {
|
||||
const self = this;
|
||||
_getModal()
|
||||
{
|
||||
return Modal.getOrCreateInstance(this._getModalElement())
|
||||
}
|
||||
|
||||
if (errorHandler === undefined) {
|
||||
errorHandler = function(xhr, err) {
|
||||
if (xhr.status === undefined || xhr.status !== 403) {
|
||||
window.location = url;
|
||||
}
|
||||
};
|
||||
}
|
||||
openUrlInModal(url)
|
||||
{
|
||||
const headers = new Headers();
|
||||
headers.append('X-Requested-With', 'Kimai-Modal');
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
success: function(html) {
|
||||
self._openFormInModal(html);
|
||||
},
|
||||
error: errorHandler
|
||||
this.fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
headers: headers
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
window.location = url;
|
||||
return;
|
||||
}
|
||||
|
||||
return response.text().then(html => {
|
||||
this._openFormInModal(html);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
window.location = url;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,157 +102,185 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_getFormIdentifier() {
|
||||
_getFormIdentifier()
|
||||
{
|
||||
return '#remote_form_modal .modal-content form';
|
||||
}
|
||||
|
||||
_openFormInModal(html) {
|
||||
const self = this;
|
||||
/**
|
||||
* @returns {HTMLElement|null}
|
||||
* @private
|
||||
*/
|
||||
_getModalElement()
|
||||
{
|
||||
return document.getElementById('remote_form_modal');
|
||||
}
|
||||
|
||||
let formIdentifier = this._getFormIdentifier();
|
||||
// if any of these is found in a response, the form will be re-displayed
|
||||
let flashErrorIdentifier = 'div.alert-error';
|
||||
// messages to show above the form
|
||||
let flashMessageIdentifier = 'div.alert';
|
||||
let form = jQuery(formIdentifier);
|
||||
let remoteModal = this.modal;
|
||||
/**
|
||||
* @param {Element|ChildNode} node
|
||||
* @returns {Element}
|
||||
* @private
|
||||
*/
|
||||
_makeScriptExecutable(node) {
|
||||
if (node.tagName !== undefined && node.tagName === 'SCRIPT') {
|
||||
const script = document.createElement('script');
|
||||
script.text = node.innerHTML;
|
||||
node.parentNode.replaceChild(script, node);
|
||||
} else {
|
||||
for (const child of node.childNodes) {
|
||||
this._makeScriptExecutable(child);
|
||||
}
|
||||
}
|
||||
|
||||
// will be (re-)activated later
|
||||
form.off('submit');
|
||||
return node;
|
||||
}
|
||||
|
||||
_openFormInModal(html)
|
||||
{
|
||||
const formIdentifier = this._getFormIdentifier();
|
||||
let remoteModal = this._getModalElement();
|
||||
const newFormHtml = document.createElement('div');
|
||||
newFormHtml.innerHTML = html;
|
||||
const newModalContent = this._makeScriptExecutable(newFormHtml.querySelector('#form_modal .modal-content'));
|
||||
|
||||
// load new form from given content
|
||||
if (jQuery(html).find('#form_modal .modal-content').length > 0) {
|
||||
// Support changing modal importance/types
|
||||
remoteModal.on('hidden.bs.modal', function () {
|
||||
if (remoteModal.hasClass('modal-danger')) {
|
||||
remoteModal.removeClass('modal-danger');
|
||||
}
|
||||
});
|
||||
|
||||
if (jQuery(html).find('#form_modal').hasClass('modal-danger')) {
|
||||
remoteModal.addClass('modal-danger');
|
||||
}
|
||||
|
||||
if (newModalContent !== null) {
|
||||
// Support changing modal sizes
|
||||
let modalDialog = remoteModal.find('.modal-dialog');
|
||||
let largeModal = jQuery(html).find('.modal-dialog').hasClass('modal-lg');
|
||||
if (largeModal && !modalDialog.hasClass('modal-lg')) {
|
||||
modalDialog.addClass('modal-lg');
|
||||
}
|
||||
if (!largeModal && modalDialog.hasClass('modal-lg')) {
|
||||
modalDialog.removeClass('modal-lg');
|
||||
let modalDialog = remoteModal.querySelector('.modal-dialog');
|
||||
let largeModal = newFormHtml.querySelector('.modal-dialog').classList.contains('modal-lg');
|
||||
|
||||
if (largeModal && !modalDialog.classList.contains('modal-lg')) {
|
||||
modalDialog.classList.toggle('modal-lg');
|
||||
}
|
||||
|
||||
jQuery('#remote_form_modal .modal-content').replaceWith(
|
||||
jQuery(html).find('#form_modal .modal-content')
|
||||
);
|
||||
if (!largeModal && modalDialog.classList.contains('modal-lg')) {
|
||||
modalDialog.classList.toggle('modal-lg');
|
||||
}
|
||||
|
||||
jQuery('#remote_form_modal [data-dismiss=modal]').on('click', function() {
|
||||
self.isDirty = false;
|
||||
remoteModal.querySelector('.modal-content').replaceWith(newModalContent);
|
||||
[].slice.call(remoteModal.querySelectorAll('[data-bs-dismiss="modal"]')).map((element) => {
|
||||
element.addEventListener('click', () => {
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
});
|
||||
});
|
||||
|
||||
// activate new loaded widgets
|
||||
self.getContainer().getPlugin('form').activateForm(formIdentifier);
|
||||
this.getContainer().getPlugin('form').activateForm(formIdentifier);
|
||||
}
|
||||
|
||||
// show error flash messages
|
||||
let flashMessages = jQuery(html).find(flashMessageIdentifier);
|
||||
if (flashMessages.length > 0) {
|
||||
jQuery('#remote_form_modal .modal-body').prepend(flashMessages);
|
||||
let flashMessages = newFormHtml.querySelector('div.alert');
|
||||
if (flashMessages !== null) {
|
||||
remoteModal.querySelector('.modal-body').prepend(flashMessages);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// a fix for firefox focus problems with datepicker in modal
|
||||
// see https://github.com/kimai/kimai/issues/618
|
||||
let enforceModalFocusFn = jQuery.fn.modal.Constructor.prototype.enforceFocus;
|
||||
jQuery.fn.modal.Constructor.prototype.enforceFocus = function() {};
|
||||
remoteModal.on('hidden.bs.modal', function () {
|
||||
jQuery.fn.modal.Constructor.prototype.enforceFocus = enforceModalFocusFn;
|
||||
});
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
remoteModal.modal('show');
|
||||
|
||||
// the new form that was loaded via ajax
|
||||
form = jQuery(formIdentifier);
|
||||
const form = document.querySelector(formIdentifier);
|
||||
|
||||
this._isDirtyHandler = function(e) {
|
||||
self.isDirty = true;
|
||||
}
|
||||
form.on('change', this._isDirtyHandler);
|
||||
form.addEventListener('change', () => {
|
||||
this._isDirty = true;
|
||||
});
|
||||
|
||||
// click handler for modal save button, to send forms via ajax
|
||||
form.on('submit', function(event) {
|
||||
// if the form has a target, we let the normal HTML flow happen
|
||||
if (form.attr('target') !== undefined) {
|
||||
return true;
|
||||
}
|
||||
form.addEventListener('submit', this._getEventHandler());
|
||||
|
||||
// otherwise we do some AJAX magic to process the form in the background
|
||||
const btn = jQuery(formIdentifier + ' button[type=submit]').button('loading');
|
||||
const eventName = form.attr('data-form-event');
|
||||
const events = self.getContainer().getPlugin('event');
|
||||
const alert = self.getContainer().getPlugin('alert');
|
||||
this._getModal().show();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
_getEventHandler()
|
||||
{
|
||||
if (this.eventHandler === undefined) {
|
||||
this.eventHandler = (event) => {
|
||||
const form = event.target;
|
||||
|
||||
jQuery.ajax({
|
||||
url: form.attr('action'),
|
||||
type: form.attr('method'),
|
||||
data: form.serialize(),
|
||||
success: function(html) {
|
||||
btn.button('reset');
|
||||
let hasFieldError = jQuery(html).find('#form_modal .modal-content .has-error').length > 0;
|
||||
let hasFormError = jQuery(html).find('#form_modal .modal-content ul.list-unstyled li.text-danger').length > 0;
|
||||
let hasFlashError = jQuery(html).find(flashErrorIdentifier).length > 0;
|
||||
|
||||
if (hasFieldError || hasFormError || hasFlashError) {
|
||||
self._openFormInModal(html);
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
|
||||
// try to find form defined messages first ...
|
||||
let msg = form.attr('data-msg-success');
|
||||
if (msg === null || msg === undefined) {
|
||||
// ... but if none was available, check the response to find server rendered flash-message
|
||||
let flashMessage = jQuery(html).find('section.content div.row div.alert.alert-success');
|
||||
if (flashMessage.length > 0) {
|
||||
let flashContent = flashMessage.contents();
|
||||
if (flashContent.length === 3) {
|
||||
msg = flashContent[2].textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... and if even that is not available, we use a generic fallback message
|
||||
if (msg === null || msg === undefined) {
|
||||
msg = 'action.update.success';
|
||||
}
|
||||
self.isDirty = false;
|
||||
remoteModal.modal('hide');
|
||||
alert.success(msg);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
error: function(xhr, err) {
|
||||
let message = form.attr('data-msg-error');
|
||||
if (message === null || message === undefined) {
|
||||
message = 'action.update.error';
|
||||
}
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
err = xhr.responseJSON.message;
|
||||
} else if (xhr.status && xhr.statusText) {
|
||||
err = '[' + xhr.status +'] ' + xhr.statusText;
|
||||
}
|
||||
alert.error(message, err);
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
setTimeout(function() {
|
||||
btn.button('reset');
|
||||
}, 1500);
|
||||
// if the form has a target, we let the normal HTML flow happen
|
||||
if (form.target !== undefined && form.target !== '') {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// otherwise we do some AJAX magic to process the form in the background
|
||||
/** @type {HTMLButtonElement} btn */
|
||||
const btn = document.querySelector(this._getFormIdentifier() + ' button[type=submit]');
|
||||
btn.textContent = btn.textContent + ' …';
|
||||
btn.disabled = true;
|
||||
|
||||
const eventName = form.dataset['formEvent'];
|
||||
/** @type {KimaiEvent} alert */
|
||||
const events = this.getContainer().getPlugin('event');
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('X-Requested-With', 'Kimai-Modal');
|
||||
const options = {headers: headers};
|
||||
|
||||
this.fetchForm(form, options)
|
||||
.then(response => {
|
||||
response.text().then((html) => {
|
||||
/** @type {HTMLDivElement} responseHtml */
|
||||
const responseHtml = document.createElement('div');
|
||||
responseHtml.innerHTML = html;
|
||||
let hasFieldError = false;
|
||||
let hasFormError = false;
|
||||
let hasFlashError = false;
|
||||
|
||||
// button must be re-enabled anyway
|
||||
btn.textContent = btn.textContent.replace(' …', '');
|
||||
btn.disabled = false;
|
||||
|
||||
// if the request was successful, there will be no form
|
||||
/** @type {Element} modalContent */
|
||||
const modalContent = responseHtml.querySelector('#form_modal .modal-content');
|
||||
if (modalContent !== null) {
|
||||
hasFieldError = modalContent.querySelector('.is-invalid') !== null;
|
||||
if (!hasFieldError) {
|
||||
// happens when an error occurs for a "hidden or non-classical" form element e.g. creating team without users
|
||||
hasFieldError = modalContent.querySelector('.invalid-feedback') !== null;
|
||||
}
|
||||
hasFormError = modalContent.querySelector('ul.list-unstyled li.text-danger') !== null;
|
||||
hasFlashError = responseHtml.querySelector('div.alert-danger') !== null;
|
||||
}
|
||||
|
||||
if (hasFieldError || hasFormError || hasFlashError) {
|
||||
this._openFormInModal(html);
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
|
||||
// try to find form defined message first, but
|
||||
let msg = form.dataset['msgSuccess'];
|
||||
// if that is not available: use a generic fallback message
|
||||
if (msg === null || msg === undefined || msg === '') {
|
||||
msg = 'action.update.success';
|
||||
}
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
alert.success(msg);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
let message = form.dataset['msgError'];
|
||||
if (message === null || message === undefined || message === '') {
|
||||
message = 'action.update.error';
|
||||
}
|
||||
|
||||
alert.error(message, error.message);
|
||||
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
setTimeout(() =>{
|
||||
// critical error, allow to re-submit?
|
||||
btn.textContent = btn.textContent.replace(' …', '');
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return this.eventHandler;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,108 +9,261 @@
|
||||
* [KIMAI] KimaiAlert: notifications for Kimai
|
||||
*/
|
||||
|
||||
import Swal from 'sweetalert2'
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import {Modal, Toast} from "bootstrap";
|
||||
|
||||
export default class KimaiAlert extends KimaiPlugin {
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
getId() {
|
||||
return 'alert';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @param {string|array} message
|
||||
* @param {string|array|undefined} message
|
||||
*/
|
||||
error(title, message) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
const translation = this.getTranslation();
|
||||
if (translation.has(title)) {
|
||||
title = translation.get(title);
|
||||
}
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
title = title.replace('%reason%', '');
|
||||
|
||||
if (message === undefined) {
|
||||
message = null;
|
||||
}
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: title.replace('%reason%', ''),
|
||||
html: message.join('<br>'),
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: title.replace('%reason%', ''),
|
||||
text: message,
|
||||
});
|
||||
if (message !== null) {
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
if (Array.isArray(message)) {
|
||||
message = message.join('<br>');
|
||||
}
|
||||
}
|
||||
|
||||
const id = 'alert_global_error';
|
||||
const oldModalElement = document.getElementById(id);
|
||||
if (oldModalElement !== null) {
|
||||
Modal.getOrCreateInstance(oldModalElement).hide();
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="modal modal-blur fade" id="` + id + `" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-status bg-` + this._mapClass('danger') + `"></div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="fas fa-exclamation-circle fa-3x mb-3 text-danger"></i>
|
||||
<h2>` + title + `</h2>
|
||||
` + (message !== null ? '<div class="text-muted">' + message + '</div>' : '') + `
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="w-100">
|
||||
<div class="row">
|
||||
<div class="col text-center"><a href="#" class="btn btn-primary" data-bs-dismiss="modal">` + translation.get('close') + `</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._showModal(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
warning(message) {
|
||||
this._show('warning', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
success(message) {
|
||||
this._toast('success', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
info(message) {
|
||||
this._show('info', message);
|
||||
}
|
||||
|
||||
_show(type, message) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
/**
|
||||
* @param {string} html
|
||||
* @private
|
||||
*/
|
||||
_showModal(html) {
|
||||
const container = document.body;
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html.trim();
|
||||
const element = template.content.firstChild;
|
||||
container.appendChild(element);
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
icon: type,
|
||||
title: message,
|
||||
});
|
||||
}
|
||||
|
||||
_toast(type, message) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
timer: 2000,
|
||||
timerProgressBar: true,
|
||||
toast: true,
|
||||
position: 'top',
|
||||
showConfirmButton: false,
|
||||
icon: type,
|
||||
title: message,
|
||||
const modal = new Modal(element);
|
||||
element.addEventListener('hidden.bs.modal', function () {
|
||||
container.removeChild(element);
|
||||
});
|
||||
modal.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback receives a value and needs to decide what should happen with it
|
||||
* @param {string} type
|
||||
* @param {string} message
|
||||
* @private
|
||||
*/
|
||||
_show(type, message) {
|
||||
const translation = this.getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="modal modal-blur fade" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-status bg-` + this._mapClass(type) + `"></div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="fas fa-exclamation-circle fa-3x mb-3 text-` + this._mapClass(type) + `"></i>
|
||||
<h2>` + message + `</h2>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="w-100">
|
||||
<div class="row">
|
||||
<div class="col text-center"><a href="#" class="btn btn-primary" data-bs-dismiss="modal">` + translation.get('close') + `</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._showModal(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @return {string}
|
||||
* @private
|
||||
*/
|
||||
_mapClass(type) {
|
||||
if (type === 'info' || type === 'success' || type === 'warning' || type === 'danger') {
|
||||
return type;
|
||||
} else if (type === 'error') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param type
|
||||
* @param message
|
||||
* @private
|
||||
*/
|
||||
_toast(type, message) {
|
||||
const translation = this.getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
let icon = '<i class="fas fa-info me-2"></i>';
|
||||
|
||||
if (type === 'success') {
|
||||
icon = '<i class="fas fa-check me-2"></i>';
|
||||
} else if (type === 'warning') {
|
||||
icon = '<i class="fas fa-exclamation me-2"></i>';
|
||||
} else if (type === 'danger' || type === 'error') {
|
||||
icon = '<i class="fas fa-exclamation-circle me-2"></i>';
|
||||
}
|
||||
|
||||
const html =
|
||||
`<div class="toast align-items-center text-white bg-` + this._mapClass(type) + ` border-0" data-bs-delay="2000" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
` + icon + ' ' + message + `
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="` + translation.get('close') + `"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const container = document.getElementById('toast-container');
|
||||
const template = document.createElement('template');
|
||||
|
||||
template.innerHTML = html.trim();
|
||||
const element = template.content.firstChild;
|
||||
container.appendChild(element);
|
||||
|
||||
const toast = new Toast(element);
|
||||
element.addEventListener('hidden.bs.toast', function () {
|
||||
container.removeChild(element);
|
||||
})
|
||||
toast.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback receives a bool value (true = confirm, false = cancel / close without action).
|
||||
*
|
||||
* @param message
|
||||
* @param callback
|
||||
*/
|
||||
question(message, callback) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
const translation = this.getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: message,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: translation.get('confirm'),
|
||||
cancelButtonText: translation.get('cancel')
|
||||
}).then((result) => {
|
||||
callback(result.value);
|
||||
});
|
||||
}
|
||||
const css = this._mapClass('info');
|
||||
const html = `
|
||||
<div class="modal modal-blur fade" tabindex="-1" role="dialog" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-status bg-` + css + `"></div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="fas fa-question fa-3x mb-3 text-` + css + `"></i>
|
||||
<h2>` + message + `</h2>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="w-100">
|
||||
<div class="row">
|
||||
<div class="col"><a href="#" class="question-confirm btn btn-primary w-100" data-bs-dismiss="modal">` + translation.get('confirm') + `</a></div>
|
||||
<div class="col"><a href="#" class="question-cancel btn w-100" data-bs-dismiss="modal">` + translation.get('cancel') + `</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.body;
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html.trim();
|
||||
const element = template.content.firstChild;
|
||||
container.appendChild(element);
|
||||
element.querySelector('.question-confirm').addEventListener('click', () => {
|
||||
callback(true);
|
||||
});
|
||||
element.querySelector('.question-cancel').addEventListener('click', () => {
|
||||
callback(false);
|
||||
});
|
||||
|
||||
const modal = new Modal(element);
|
||||
element.addEventListener('hidden.bs.modal', () => {
|
||||
container.removeChild(element);
|
||||
});
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,17 @@
|
||||
* redirecting to the URL given in the elements 'data-href' or 'href' attribute
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiReducedClickHandler from "./KimaiReducedClickHandler";
|
||||
|
||||
export default class KimaiAlternativeLinks extends KimaiReducedClickHandler {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._addClickHandler(this.selector, function(href) {
|
||||
this.addClickHandler(this._selector, function(href) {
|
||||
window.location = href;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
/**
|
||||
* Supporting auto-complete fields via API.
|
||||
* Currently used for timesheet tagging in toolbar and edit dialogs.
|
||||
*/
|
||||
export default class KimaiAutocomplete extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.minChars = this.getConfiguration('autoComplete');
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'autocomplete';
|
||||
}
|
||||
|
||||
splitTagList(val) {
|
||||
return val.split(/,\s*/);
|
||||
}
|
||||
|
||||
extractLastTag(term) {
|
||||
return this.splitTagList(term).pop();
|
||||
}
|
||||
|
||||
activateAutocomplete(selector) {
|
||||
const self = this;
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
const currentField = jQuery(this);
|
||||
const apiUrl = currentField.attr('data-autocomplete-url');
|
||||
const API = self.getContainer().getPlugin('api');
|
||||
|
||||
currentField
|
||||
// don't navigate away from the field on tab when selecting an item
|
||||
.on("keydown", function (event) {
|
||||
if (event.keyCode === jQuery.ui.keyCode.TAB &&
|
||||
jQuery(this).autocomplete("instance").menu.active) {
|
||||
event.preventDefault();
|
||||
}
|
||||
})
|
||||
.autocomplete({
|
||||
source: function (request, response) {
|
||||
const lastEntry = self.extractLastTag(request.term);
|
||||
API.get(apiUrl, {'name': lastEntry}, function(data){
|
||||
response(data);
|
||||
});
|
||||
},
|
||||
search: function () {
|
||||
// custom minLength
|
||||
var term = self.extractLastTag(this.value);
|
||||
if (term.length < self.minChars) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
focus: function () {
|
||||
// prevent value inserted on focus
|
||||
return false;
|
||||
},
|
||||
select: function (event, ui) {
|
||||
var terms = self.splitTagList(this.value);
|
||||
|
||||
// remove the current input
|
||||
terms.pop();
|
||||
|
||||
// check if selected tag is already in list
|
||||
if (!terms.includes(ui.item.value)) {
|
||||
// add the selected item
|
||||
terms.push(ui.item.value);
|
||||
}
|
||||
// add placeholder to get the comma-and-space at the end
|
||||
terms.push("");
|
||||
|
||||
this.value = terms.join(", ");
|
||||
|
||||
$(this).trigger('change');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
)
|
||||
;
|
||||
});
|
||||
}
|
||||
|
||||
destroyAutocomplete(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
const currentField = jQuery(this);
|
||||
currentField.autocomplete("destroy");
|
||||
currentField.removeData('autocomplete');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,15 +19,14 @@ export default class KimaiConfirmationLink extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
document.addEventListener('click', function(event) {
|
||||
document.addEventListener('click', (event) => {
|
||||
let target = event.target;
|
||||
while (target !== null && !target.matches('body')) {
|
||||
if (target.classList.contains(self.selector)) {
|
||||
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
|
||||
if (target.classList.contains(this._selector)) {
|
||||
const attributes = target.dataset;
|
||||
|
||||
// is this a link?
|
||||
@@ -44,7 +43,7 @@ export default class KimaiConfirmationLink extends KimaiPlugin {
|
||||
}
|
||||
|
||||
if (attributes.question !== undefined) {
|
||||
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||
this.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||
if (value) {
|
||||
if (form === null) {
|
||||
document.location = url;
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
* [KIMAI] KimaiDatatable: handles functionality for the datatable
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import KimaiContextMenu from "../widgets/KimaiContextMenu";
|
||||
|
||||
export default class KimaiDatatable extends KimaiPlugin {
|
||||
|
||||
constructor(contentAreaSelector, tableSelector) {
|
||||
super();
|
||||
this.contentArea = contentAreaSelector;
|
||||
this.selector = tableSelector;
|
||||
this._contentArea = contentAreaSelector;
|
||||
this._selector = tableSelector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
@@ -25,24 +25,21 @@ export default class KimaiDatatable extends KimaiPlugin {
|
||||
}
|
||||
|
||||
init() {
|
||||
const dataTable = document.querySelector(this.selector);
|
||||
const dataTable = document.querySelector(this._selector);
|
||||
|
||||
// not every page contains a dataTable
|
||||
if (dataTable === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributes = dataTable.dataset;
|
||||
const events = attributes['reloadEvent'];
|
||||
|
||||
this.fixDropdowns();
|
||||
this.registerContextMenu(this._selector);
|
||||
|
||||
const events = dataTable.dataset['reloadEvent'];
|
||||
if (events === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
const handle = function() { self.reloadDatatable(); };
|
||||
const handle = () => { this.reloadDatatable(); };
|
||||
|
||||
for (let eventName of events.split(' ')) {
|
||||
document.addEventListener(eventName, handle);
|
||||
@@ -52,55 +49,49 @@ export default class KimaiDatatable extends KimaiPlugin {
|
||||
document.addEventListener('filter-change', handle);
|
||||
}
|
||||
|
||||
reloadDatatable() {
|
||||
const self = this;
|
||||
const contentArea = this.contentArea;
|
||||
const durations = this.getContainer().getPlugin('timesheet-duration');
|
||||
const toolbarSelector = this.getContainer().getPlugin('toolbar').getSelector();
|
||||
|
||||
const form = jQuery(toolbarSelector);
|
||||
let loading = '<div class="overlay"><i class="fas fa-sync fa-spin"></i></div>';
|
||||
jQuery(contentArea).append(loading);
|
||||
|
||||
// remove the empty fields to prevent errors
|
||||
let formData = jQuery(toolbarSelector + ' :input')
|
||||
.filter(function(index, element) {
|
||||
return jQuery(element).val() !== '';
|
||||
})
|
||||
.serialize();
|
||||
|
||||
jQuery.ajax({
|
||||
url: form.attr('action'),
|
||||
type: form.attr('method'),
|
||||
data: formData,
|
||||
success: function(html) {
|
||||
jQuery(contentArea).replaceWith(
|
||||
jQuery(html).find(contentArea)
|
||||
);
|
||||
durations.updateRecords();
|
||||
self.fixDropdowns();
|
||||
},
|
||||
error: function(xhr, err) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @private
|
||||
*/
|
||||
registerContextMenu(selector)
|
||||
{
|
||||
KimaiContextMenu.createForDataTable(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* show dropdown menu upwards, if it is outside the visible viewport
|
||||
*/
|
||||
fixDropdowns() {
|
||||
const docHeight = jQuery(document).height();
|
||||
jQuery(this.selector + ' [data-toggle=dropdown]').each(function() {
|
||||
const parent = jQuery(this).parent();
|
||||
const menu = parent.find('.dropdown-menu');
|
||||
reloadDatatable()
|
||||
{
|
||||
const toolbarSelector = this.getContainer().getPlugin('toolbar').getSelector();
|
||||
|
||||
if (parent && menu) {
|
||||
if ((parent.offset().top + parent.outerHeight() + menu.outerHeight()) > docHeight) {
|
||||
parent.addClass('dropup').removeClass('dropdown');
|
||||
}
|
||||
}
|
||||
/** @type {HTMLFormElement} form */
|
||||
const form = document.querySelector(toolbarSelector);
|
||||
const callback = (text) => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = text;
|
||||
const newContent = temp.querySelector(this._contentArea);
|
||||
document.querySelector(this._contentArea).replaceWith(newContent);
|
||||
this.registerContextMenu(this._selector);
|
||||
document.dispatchEvent(new Event('kimai.reloadedContent'));
|
||||
};
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent', {detail: this._contentArea}));
|
||||
|
||||
if (form === null) {
|
||||
this.fetch(document.location)
|
||||
.then(response => {
|
||||
response.text().then(callback);
|
||||
})
|
||||
.catch(() => {
|
||||
document.location.reload();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchForm(form)
|
||||
.then(response => {
|
||||
response.text().then(callback);
|
||||
})
|
||||
.catch(() => {
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
* [KIMAI] KimaiDatatableColumnView: manages the visibility of data-table columns in cookies
|
||||
*/
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiDatatableColumnView extends KimaiPlugin {
|
||||
@@ -29,80 +27,110 @@ export default class KimaiDatatableColumnView extends KimaiPlugin {
|
||||
if (dataTable === null) {
|
||||
return;
|
||||
}
|
||||
this.id = dataTable.getAttribute(this.dataAttribute);
|
||||
this.modal = document.getElementById('modal_' + this.id);
|
||||
this.bindButtons();
|
||||
}
|
||||
|
||||
bindButtons() {
|
||||
let self = this;
|
||||
this.modal.querySelector('button[data-type=save]').addEventListener('click', function() {
|
||||
self.saveVisibility();
|
||||
this._id = dataTable.getAttribute(this.dataAttribute);
|
||||
this._modal = document.getElementById('modal_' + this._id);
|
||||
this._modal.addEventListener('show.bs.modal', () => {
|
||||
this._evaluateCheckboxes();
|
||||
});
|
||||
this.modal.querySelector('button[data-type=reset]').addEventListener('click', function() {
|
||||
self.resetVisibility();
|
||||
this._modal.querySelector('button[data-type=save]').addEventListener('click', () => {
|
||||
this._saveVisibility();
|
||||
});
|
||||
for (let checkbox of this.modal.querySelectorAll('form input[type=checkbox]')) {
|
||||
checkbox.addEventListener('click', function () {
|
||||
self.changeVisibility(checkbox.getAttribute('name'), checkbox.checked);
|
||||
this._modal.querySelector('button[data-type=reset]').addEventListener('click', (event) => {
|
||||
this._resetVisibility(event.currentTarget);
|
||||
});
|
||||
this._modal.querySelectorAll('input[name=datatable_profile]').forEach(element => {
|
||||
element.addEventListener('change', () => {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
this.fetchForm(form, {}, element.getAttribute('data-href'))
|
||||
.then(() => {
|
||||
// the local storage is read in the login screen to set a cookie,
|
||||
// which triggers the session switch in ProfileSubscriber
|
||||
localStorage.setItem('kimai_profile', element.getAttribute('value'));
|
||||
document.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
form.setAttribute('action', element.getAttribute('data-href'));
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
for (let checkbox of this._modal.querySelectorAll('form input[type=checkbox]')) {
|
||||
checkbox.addEventListener('change', () => {
|
||||
this._changeVisibility(checkbox.getAttribute('name'), checkbox.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
saveVisibility() {
|
||||
const form = this.modal.getElementsByTagName('form')[0];
|
||||
let settings = {};
|
||||
for (let checkbox of form.querySelectorAll('input[type=checkbox]')) {
|
||||
settings[checkbox.getAttribute('name')] = checkbox.checked;
|
||||
_evaluateCheckboxes() {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
const table = document.getElementsByClassName('datatable_' + this._id)[0];
|
||||
for (let columnElement of table.getElementsByTagName('th')) {
|
||||
const fieldName = columnElement.getAttribute('data-field');
|
||||
if (fieldName === null) {
|
||||
continue;
|
||||
}
|
||||
const checkbox = form.querySelector('input[name=' + fieldName + ']');
|
||||
if (checkbox === null) {
|
||||
continue;
|
||||
}
|
||||
checkbox.checked = window.getComputedStyle(columnElement).display !== 'none';
|
||||
}
|
||||
Cookies.set(form.getAttribute('name'), JSON.stringify(settings), {expires: 365, SameSite: 'Strict'});
|
||||
jQuery(this.modal).modal('toggle');
|
||||
}
|
||||
|
||||
resetVisibility() {
|
||||
const form = this.modal.getElementsByTagName('form')[0];
|
||||
Cookies.remove(form.getAttribute('name'));
|
||||
for (let checkbox of form.querySelectorAll('input[type=checkbox]')) {
|
||||
if (!checkbox.checked) {
|
||||
checkbox.click();
|
||||
}
|
||||
}
|
||||
jQuery(this.modal).modal('toggle');
|
||||
_saveVisibility() {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
|
||||
this.fetchForm(form)
|
||||
.then(() => {
|
||||
document.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
changeVisibility(columnName, checked) {
|
||||
const tables = document.getElementsByClassName('datatable_' + this.id);
|
||||
for (let tableBox of tables) {
|
||||
let column = 0;
|
||||
let foundColumn = false;
|
||||
let table = tableBox.getElementsByClassName('dataTable')[0];
|
||||
for (let columnElement of table.getElementsByTagName('th')) {
|
||||
if (columnElement.getAttribute('data-field') === columnName) {
|
||||
foundColumn = true;
|
||||
break;
|
||||
_resetVisibility(button) {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
|
||||
this.fetchForm(form, {}, button.getAttribute('formaction'))
|
||||
.then(() => {
|
||||
document.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
form.setAttribute('action', button.getAttribute('formaction'));
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
_changeVisibility(columnName, checked) {
|
||||
for (const tableBox of document.getElementsByClassName('datatable_' + this._id)) {
|
||||
let targetClasses = null;
|
||||
for (let element of tableBox.getElementsByClassName('col_' + columnName)) {
|
||||
// only calculate that once and re-use the cached class list
|
||||
if (targetClasses === null) {
|
||||
let removeClass = '-none';
|
||||
let addClass = 'd-table-cell';
|
||||
|
||||
if (!checked) {
|
||||
removeClass = '-table-cell';
|
||||
addClass = 'd-none';
|
||||
}
|
||||
|
||||
targetClasses = '';
|
||||
element.classList.forEach(
|
||||
function (name, index, listObj) { // eslint-disable-line no-unused-vars
|
||||
if (name.indexOf(removeClass) === -1) {
|
||||
targetClasses += ' ' + name;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (targetClasses.indexOf(addClass) === -1) {
|
||||
targetClasses += ' ' + addClass;
|
||||
}
|
||||
}
|
||||
|
||||
if (columnElement.getAttribute('colspan') !== null) {
|
||||
console.log('Tables with colspans are not supported!');
|
||||
}
|
||||
|
||||
column++;
|
||||
}
|
||||
|
||||
if (!foundColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let rowElement of table.getElementsByTagName('tr')) {
|
||||
if (rowElement.children[column] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
rowElement.children[column].classList.remove('hidden');
|
||||
} else {
|
||||
rowElement.children[column].classList.add('hidden');
|
||||
}
|
||||
element.className = targetClasses;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiDatePicker: single date selects (currently unused)
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiDatePicker extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'date-picker';
|
||||
}
|
||||
|
||||
activateDatePicker(selector) {
|
||||
const TRANSLATE = this.getContainer().getTranslation();
|
||||
const DATE_UTILS = this.getContainer().getPlugin('date');
|
||||
const firstDow = this.getConfiguration('first_dow_iso') % 7;
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
let localeFormat = jQuery(this).data('format');
|
||||
jQuery(this).daterangepicker({
|
||||
singleDatePicker: true,
|
||||
showDropdowns: true,
|
||||
autoUpdateInput: false,
|
||||
drops: 'down',
|
||||
locale: {
|
||||
format: localeFormat,
|
||||
firstDay: firstDow,
|
||||
applyLabel: TRANSLATE.get('confirm'),
|
||||
cancelLabel: TRANSLATE.get('cancel'),
|
||||
customRangeLabel: TRANSLATE.get('customRange'),
|
||||
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
|
||||
monthNames: DATE_UTILS.getMonthNames(),
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('show.daterangepicker', function (ev, picker) {
|
||||
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
|
||||
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
|
||||
picker.drops = 'auto';
|
||||
picker.move();
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
|
||||
jQuery(this).val(picker.startDate.format(localeFormat));
|
||||
jQuery(this).trigger("change");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyDatePicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
if (jQuery(this).data('daterangepicker') !== undefined) {
|
||||
jQuery(this).data('daterangepicker').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiDateRangePicker: activate the (daterange picker) compound field in toolbar
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class KimaiDateRangePicker extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'date-range-picker';
|
||||
}
|
||||
|
||||
activateDateRangePicker(selector) {
|
||||
const TRANSLATE = this.getContainer().getTranslation();
|
||||
const DATE_UTILS = this.getContainer().getPlugin('date');
|
||||
const firstDow = this.getConfiguration('first_dow_iso') % 7;
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
let localeFormat = jQuery(this).data('format');
|
||||
let separator = jQuery(this).data('separator');
|
||||
let rangesList = {};
|
||||
|
||||
rangesList[TRANSLATE.get('today')] = [moment(), moment()];
|
||||
rangesList[TRANSLATE.get('yesterday')] = [moment().subtract(1, 'days'), moment().subtract(1, 'days')];
|
||||
rangesList[TRANSLATE.get('thisWeek')] = [moment().startOf('isoWeek'), moment().endOf('isoWeek')];
|
||||
rangesList[TRANSLATE.get('lastWeek')] = [moment().subtract(1, 'week').startOf('isoWeek'), moment().subtract(1, 'week').endOf('isoWeek')];
|
||||
if (firstDow === 0) { // sunday = 0
|
||||
rangesList[TRANSLATE.get('thisWeek')] = [moment().startOf('isoWeek').subtract(1, 'day'), moment().endOf('isoWeek').subtract(1, 'day')];
|
||||
rangesList[TRANSLATE.get('lastWeek')] = [moment().subtract(1, 'week').startOf('isoWeek').subtract(1, 'day'), moment().subtract(1, 'week').endOf('isoWeek').subtract(1, 'day')];
|
||||
}
|
||||
rangesList[TRANSLATE.get('thisMonth')] = [moment().startOf('month'), moment().endOf('month')];
|
||||
rangesList[TRANSLATE.get('lastMonth')] = [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')];
|
||||
rangesList[TRANSLATE.get('thisYear')] = [moment().startOf('year'), moment().endOf('year')];
|
||||
rangesList[TRANSLATE.get('lastYear')] = [moment().subtract(1, 'year').startOf('year'), moment().subtract(1, 'year').endOf('year')];
|
||||
|
||||
jQuery(this).daterangepicker({
|
||||
showDropdowns: true,
|
||||
autoUpdateInput: false,
|
||||
autoApply: false,
|
||||
linkedCalendars: true,
|
||||
drops: 'down',
|
||||
locale: {
|
||||
separator: separator,
|
||||
format: localeFormat,
|
||||
firstDay: firstDow,
|
||||
applyLabel: TRANSLATE.get('confirm'),
|
||||
cancelLabel: TRANSLATE.get('cancel'),
|
||||
customRangeLabel: TRANSLATE.get('customRange'),
|
||||
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
|
||||
monthNames: DATE_UTILS.getMonthNames(),
|
||||
},
|
||||
ranges: rangesList,
|
||||
alwaysShowCalendars: true
|
||||
});
|
||||
|
||||
jQuery(this).on('show.daterangepicker', function (ev, picker) {
|
||||
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
|
||||
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
|
||||
picker.drops = 'auto';
|
||||
picker.move();
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
|
||||
jQuery(this).val(picker.startDate.format(localeFormat) + ' - ' + picker.endDate.format(localeFormat));
|
||||
jQuery(this).data('begin', picker.startDate.format(localeFormat));
|
||||
jQuery(this).data('end', picker.endDate.format(localeFormat));
|
||||
jQuery(this).trigger("change");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyDateRangePicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
if (jQuery(this).data('daterangepicker') !== undefined) {
|
||||
jQuery(this).data('daterangepicker').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiDateTimePicker: activate the (datetime picker) field in timesheet edit dialog
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiDateTimePicker extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'date-time-picker';
|
||||
}
|
||||
|
||||
activateDateTimePicker(selector) {
|
||||
const TRANSLATE = this.getContainer().getTranslation();
|
||||
const DATE_UTILS = this.getContainer().getPlugin('date');
|
||||
const firstDow = this.getConfiguration('first_dow_iso') % 7;
|
||||
const is24hours = this.getConfiguration('twentyFourHours');
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
let localeFormat = jQuery(this).data('format');
|
||||
jQuery(this).daterangepicker({
|
||||
singleDatePicker: true,
|
||||
timePicker: true,
|
||||
timePicker24Hour: is24hours,
|
||||
showDropdowns: true,
|
||||
autoUpdateInput: false,
|
||||
drops: 'down',
|
||||
locale: {
|
||||
format: localeFormat,
|
||||
firstDay: firstDow,
|
||||
applyLabel: TRANSLATE.get('confirm'),
|
||||
cancelLabel: TRANSLATE.get('cancel'),
|
||||
customRangeLabel: TRANSLATE.get('customRange'),
|
||||
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
|
||||
monthNames: DATE_UTILS.getMonthNames(),
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('show.daterangepicker', function (ev, picker) {
|
||||
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
|
||||
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
|
||||
picker.drops = 'auto';
|
||||
picker.move();
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
|
||||
jQuery(this).val(picker.startDate.format(localeFormat));
|
||||
jQuery(this).trigger("change");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyDateTimePicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
if (jQuery(this).data('daterangepicker') !== undefined) {
|
||||
jQuery(this).data('daterangepicker').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,71 +10,241 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
import moment from 'moment';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
|
||||
export default class KimaiDateUtils extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'date';
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
if (this.getConfigurations().is24Hours()) {
|
||||
this.timeFormat = 'HH:mm';
|
||||
} else {
|
||||
this.timeFormat = 'hh:mm a';
|
||||
}
|
||||
this.durationFormat = this.getConfiguration('formatDuration');
|
||||
this.dateFormat = this.getConfiguration('formatDate');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dateTime
|
||||
* @see https://moment.github.io/luxon/#/formatting?id=table-of-tokens
|
||||
* @param {string} format
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_parseFormat(format)
|
||||
{
|
||||
format = format.replace('DD', 'dd');
|
||||
format = format.replace('D', 'd');
|
||||
format = format.replace('MM', 'LL');
|
||||
format = format.replace('M', 'L');
|
||||
format = format.replace('YYYY', 'yyyy');
|
||||
format = format.replace('YY', 'yy');
|
||||
format = format.replace('A', 'a');
|
||||
|
||||
return format;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} format
|
||||
* @param {string|Date|null|undefined} dateTime
|
||||
* @returns {string}
|
||||
*/
|
||||
getFormattedDate(dateTime) {
|
||||
return moment(dateTime).format(this.getConfiguration('formatDate'));
|
||||
}
|
||||
format(format, dateTime)
|
||||
{
|
||||
let newDate = null;
|
||||
|
||||
getWeekDaysShort() {
|
||||
return moment.localeData().weekdaysShort();
|
||||
}
|
||||
if (dateTime === null || dateTime === undefined) {
|
||||
newDate = DateTime.now();
|
||||
} else if (dateTime instanceof Date) {
|
||||
newDate = DateTime.fromJSDate(dateTime);
|
||||
} else {
|
||||
newDate = DateTime.fromISO(dateTime);
|
||||
}
|
||||
|
||||
getMonthNames() {
|
||||
return moment.localeData().months();
|
||||
}
|
||||
|
||||
formatDuration(since) {
|
||||
const duration = moment.duration(moment(new Date()).diff(moment(since)));
|
||||
|
||||
return this.formatMomentDuration(duration);
|
||||
}
|
||||
|
||||
formatSeconds(seconds) {
|
||||
const duration = moment.duration('PT' + seconds + 'S');
|
||||
|
||||
return this.formatMomentDuration(duration);
|
||||
// using locale english here prevents that that AM/PM is translated to the
|
||||
// locale variant: e.g. "ko" translates it to 오후 / 오전
|
||||
return newDate.toFormat(this._parseFormat(format), { locale: 'en-us' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {moment.Duration} duration
|
||||
* @returns {string|*}
|
||||
* @param {string|Date} dateTime
|
||||
* @returns {string}
|
||||
*/
|
||||
formatMomentDuration(duration) {
|
||||
const hours = parseInt(duration.asHours());
|
||||
const minutes = duration.minutes();
|
||||
|
||||
return this.formatTime(hours, minutes);
|
||||
getFormattedDate(dateTime)
|
||||
{
|
||||
return this.format(this._parseFormat(this.dateFormat), dateTime);
|
||||
}
|
||||
|
||||
formatTime(hours, minutes) {
|
||||
let format = this.getConfiguration('formatDuration');
|
||||
/**
|
||||
* Returns a "YYYY-MM-DDTHH:mm:ss" formatted string in local time.
|
||||
* This can take Date objects (e.g. from FullCalendar) and turn them into the correct format.
|
||||
*
|
||||
* @param {Date|DateTime} date
|
||||
* @param {boolean|undefined} isUtc
|
||||
* @return {string}
|
||||
*/
|
||||
formatForAPI(date, isUtc = false)
|
||||
{
|
||||
if (date instanceof Date) {
|
||||
date = DateTime.fromJSDate(date);
|
||||
}
|
||||
|
||||
if (isUtc === undefined || !isUtc) {
|
||||
date = date.toUTC();
|
||||
}
|
||||
|
||||
return date.toISO({ includeOffset: false, suppressMilliseconds: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} date
|
||||
* @param {string} format
|
||||
* @return {DateTime}
|
||||
*/
|
||||
fromFormat(date, format)
|
||||
{
|
||||
// using locale en-us here prevents that Luxon expects the localized
|
||||
// version of AM/PM (e.g. 오후 / 오전 for locale "ko")
|
||||
return DateTime.fromFormat(date, this._parseFormat(format), { locale: 'en-us' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null} date
|
||||
* @param {string|null} time
|
||||
* @return {DateTime}
|
||||
*/
|
||||
fromHtml5Input(date, time)
|
||||
{
|
||||
date = date ?? '';
|
||||
time = time ?? '';
|
||||
|
||||
if (date === '' && time === '') {
|
||||
return DateTime.invalid('Empty date and time given');
|
||||
}
|
||||
|
||||
if (date !== '' && time !== '') {
|
||||
date = date + 'T' + time;
|
||||
}
|
||||
|
||||
return DateTime.fromISO(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} date
|
||||
* @param {string} format
|
||||
* @return {boolean}
|
||||
*/
|
||||
isValidDateTime(date, format)
|
||||
{
|
||||
return this.fromFormat(date, format).isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a string like "00:30:00" or "01:15" to a given date.
|
||||
*
|
||||
* @param {Date} date
|
||||
* @param {string} duration
|
||||
* @return {Date}
|
||||
*/
|
||||
addHumanDuration(date, duration)
|
||||
{
|
||||
/** @type {DateTime} newDate */
|
||||
let newDate = null;
|
||||
|
||||
if (date instanceof Date) {
|
||||
newDate = DateTime.fromJSDate(date);
|
||||
} else if (date instanceof DateTime) {
|
||||
newDate = date;
|
||||
} else {
|
||||
throw 'addHumanDuration() needs a JS Date';
|
||||
}
|
||||
|
||||
const parsed = DateTime.fromISO(duration);
|
||||
const today = DateTime.now().startOf('day');
|
||||
const timeOfDay = parsed.diff(today);
|
||||
|
||||
return newDate.plus(timeOfDay).toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|integer|null} since
|
||||
* @return {string}
|
||||
*/
|
||||
formatDuration(since)
|
||||
{
|
||||
let duration = null;
|
||||
|
||||
if (typeof since === 'string') {
|
||||
duration = DateTime.now().diff(DateTime.fromISO(since));
|
||||
} else {
|
||||
duration = Duration.fromISO('PT' + (since === null ? 0 : since) + 'S');
|
||||
}
|
||||
|
||||
return this.formatLuxonDuration(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {integer} seconds
|
||||
* @return {string}
|
||||
*/
|
||||
formatSeconds(seconds)
|
||||
{
|
||||
return this.formatLuxonDuration(Duration.fromObject({seconds: seconds}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Duration} duration
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
formatLuxonDuration(duration)
|
||||
{
|
||||
duration = duration.shiftTo('hours', 'minutes', 'seconds');
|
||||
|
||||
return this.formatAsDuration(duration.hours, duration.minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @param {boolean|undefined} isUtc
|
||||
* @return {string}
|
||||
*/
|
||||
formatTime(date, isUtc = false)
|
||||
{
|
||||
let newDate = DateTime.fromJSDate(date);
|
||||
|
||||
if (isUtc === undefined || !isUtc) {
|
||||
newDate = newDate.toUTC();
|
||||
}
|
||||
|
||||
// .utc() is required for calendar
|
||||
return newDate.toFormat(this.timeFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO remove seconds
|
||||
*
|
||||
* @param {int} hours
|
||||
* @param {int} minutes
|
||||
* @return {string}
|
||||
*/
|
||||
formatAsDuration(hours, minutes)
|
||||
{
|
||||
let format = this.durationFormat;
|
||||
|
||||
if (hours < 0 || minutes < 0) {
|
||||
hours = Math.abs(hours);
|
||||
minutes = Math.abs(minutes);
|
||||
if (minutes > 0 || hours > 0) {
|
||||
format = '-' + format;
|
||||
}
|
||||
format = '-' + format;
|
||||
}
|
||||
|
||||
// special case for hours, as they can overflow the 24h barrier - Kimai does not support days as duration unit
|
||||
if (hours < 10) {
|
||||
hours = '0' + hours;
|
||||
}
|
||||
|
||||
|
||||
return format.replace('%h', hours).replace('%m', ('0' + minutes).substr(-2));
|
||||
return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2));
|
||||
//return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2)).replace('%s', ('0' + seconds).slice(-2));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,32 +253,52 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
*/
|
||||
getSecondsFromDurationString(duration)
|
||||
{
|
||||
duration = duration.trim().toUpperCase();
|
||||
let momentDuration = moment.duration(NaN);
|
||||
const luxonDuration = this.parseDuration(duration);
|
||||
|
||||
if (duration.indexOf(':') !== -1) {
|
||||
momentDuration = moment.duration(duration);
|
||||
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
|
||||
duration = duration.replace(/,/, '.');
|
||||
duration = (parseFloat(duration) * 3600).toString();
|
||||
momentDuration = moment.duration('PT' + duration + 'S');
|
||||
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
|
||||
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
|
||||
momentDuration = moment.duration('PT' + duration);
|
||||
} else {
|
||||
let c = parseInt(duration);
|
||||
let d = parseInt(duration).toFixed();
|
||||
if (!isNaN(c) && duration === d) {
|
||||
duration = (c * 3600).toString();
|
||||
momentDuration = moment.duration('PT' + duration + 'S');
|
||||
}
|
||||
}
|
||||
|
||||
if (!momentDuration.isValid()) {
|
||||
if (luxonDuration === null || !luxonDuration.isValid) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return momentDuration.asSeconds();
|
||||
return luxonDuration.as('seconds');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} duration
|
||||
* @returns {Duration}
|
||||
*/
|
||||
parseDuration(duration)
|
||||
{
|
||||
if (duration === undefined || duration === null || duration === '') {
|
||||
return new Duration({seconds: 0});
|
||||
}
|
||||
|
||||
duration = duration.trim().toUpperCase();
|
||||
let luxonDuration = null;
|
||||
|
||||
if (duration.indexOf(':') !== -1) {
|
||||
const [, hours, minutes, seconds] = duration.match(/(\d+):(\d+)(?::(\d+))*/);
|
||||
luxonDuration = Duration.fromObject({hours: hours, minutes: minutes, seconds: seconds});
|
||||
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
|
||||
duration = duration.replace(/,/, '.');
|
||||
duration = (parseFloat(duration) * 3600).toString();
|
||||
luxonDuration = Duration.fromISO('PT' + duration + 'S');
|
||||
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
|
||||
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
|
||||
luxonDuration = Duration.fromISO('PT' + duration);
|
||||
} else {
|
||||
let c = parseInt(duration);
|
||||
const d = parseInt(duration).toFixed();
|
||||
if (!isNaN(c) && duration === d) {
|
||||
duration = (c * 3600).toString();
|
||||
luxonDuration = Duration.fromISO('PT' + duration + 'S');
|
||||
}
|
||||
}
|
||||
|
||||
if (luxonDuration === null || !luxonDuration.isValid) {
|
||||
return new Duration({seconds: 0});
|
||||
}
|
||||
|
||||
return luxonDuration;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export default class KimaiEscape extends KimaiPlugin {
|
||||
* @returns {string}
|
||||
*/
|
||||
escapeForHtml(title) {
|
||||
if (title === undefined || title === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tagsToReplace = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
@@ -31,5 +35,5 @@ export default class KimaiEscape extends KimaiPlugin {
|
||||
return title.replace(/[&<>]/g, function(tag) {
|
||||
return tagsToReplace[tag] || tag;
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,24 @@ import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiEvent extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'event';
|
||||
}
|
||||
|
||||
trigger(name, details) {
|
||||
if (name === null || name === undefined) {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string|array|object|null} details
|
||||
*/
|
||||
trigger(name, details = null)
|
||||
{
|
||||
if (name === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
for(let event of name.split(' ')) {
|
||||
for (const event of name.split(' ')) {
|
||||
let triggerEvent = new Event(event);
|
||||
if (details !== undefined) {
|
||||
if (details !== null) {
|
||||
triggerEvent = new CustomEvent(event, {detail: details});
|
||||
}
|
||||
document.dispatchEvent(triggerEvent);
|
||||
|
||||
80
assets/js/plugins/KimaiFetch.js
Normal file
80
assets/js/plugins/KimaiFetch.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiEscape: sanitize strings
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiFetch extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
return 'fetch';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
fetch(url, options = {}) {
|
||||
if (options.headers === undefined) {
|
||||
options.headers = new Headers();
|
||||
}
|
||||
options.headers.append('X-Requested-With', 'Kimai');
|
||||
|
||||
options = {...{
|
||||
redirect: 'follow',
|
||||
}, ...options};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(url, options).then(response => {
|
||||
if (response.ok) {
|
||||
if (response.status === 201 && response.headers.has('x-modal-redirect')) {
|
||||
window.location = response.headers.get('x-modal-redirect');
|
||||
return;
|
||||
}
|
||||
|
||||
// "ok" is only in status code range of 2xx
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
|
||||
let stopPropagation = false;
|
||||
switch (response.status) {
|
||||
case 403: {
|
||||
if (response.headers.has('login-required')) {
|
||||
const loginUrl = this.getConfiguration('login').toString();
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
alert.question(this.translate('login.required'), (result) => {
|
||||
if (result === true) {
|
||||
window.location.replace(loginUrl);
|
||||
}
|
||||
});
|
||||
stopPropagation = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.log('Some error occurred');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stopPropagation) {
|
||||
reject(response);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error occurred while talking to Kimai backend', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,35 +10,44 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import KimaiFormPlugin from "../forms/KimaiFormPlugin";
|
||||
|
||||
export default class KimaiForm extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'form';
|
||||
}
|
||||
|
||||
activateForm(formSelector, container) {
|
||||
this.getContainer().getPlugin('date-range-picker').activateDateRangePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-time-picker').activateDateTimePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-picker').activateDatePicker(formSelector);
|
||||
this.getContainer().getPlugin('autocomplete').activateAutocomplete(formSelector);
|
||||
this.getContainer().getPlugin('form-select').activateSelectPicker(formSelector, container);
|
||||
activateForm(formSelector)
|
||||
{
|
||||
[].slice.call(document.querySelectorAll(formSelector)).map((form) => {
|
||||
for (const plugin of this.getContainer().getPlugins()) {
|
||||
if (plugin instanceof KimaiFormPlugin && plugin.supportsForm(form)) {
|
||||
plugin.activateForm(form);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroyForm(formSelector) {
|
||||
this.getContainer().getPlugin('form-select').destroySelectPicker(formSelector);
|
||||
this.getContainer().getPlugin('autocomplete').destroyAutocomplete(formSelector);
|
||||
this.getContainer().getPlugin('date-picker').destroyDatePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-time-picker').destroyDateTimePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector);
|
||||
|
||||
destroyForm(formSelector)
|
||||
{
|
||||
[].slice.call(document.querySelectorAll(formSelector)).map((form) => {
|
||||
for (const plugin of this.getContainer().getPlugins()) {
|
||||
if (plugin instanceof KimaiFormPlugin && plugin.supportsForm(form)) {
|
||||
plugin.destroyForm(form);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {Object} overwrites
|
||||
* @param {boolean} removeEmpty
|
||||
* @returns {string}
|
||||
*/
|
||||
convertFormDataToQueryString(form, overwrites = {})
|
||||
convertFormDataToQueryString(form, overwrites = {}, removeEmpty = false)
|
||||
{
|
||||
let serialized = [];
|
||||
let data = new FormData(form);
|
||||
@@ -48,7 +57,9 @@ export default class KimaiForm extends KimaiPlugin {
|
||||
}
|
||||
|
||||
for (let row of data) {
|
||||
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
|
||||
if (!removeEmpty || row[1] !== '') {
|
||||
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return serialized.join('&');
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiFormSelect: enhanced functionality for HTML select's
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiFormSelect extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'form-select';
|
||||
}
|
||||
|
||||
init() {
|
||||
// selects the original value inside select2 dropdowns, as the "reset" event (the updated option)
|
||||
// is not automatically catched by select2
|
||||
jQuery('body').on('reset', 'form', function(event) {
|
||||
setTimeout(function() {
|
||||
jQuery(event.target).find(this.selector).trigger('change');
|
||||
}, 10);
|
||||
});
|
||||
|
||||
const self = this;
|
||||
|
||||
// Function to match the name of the parent and not only the names of the children
|
||||
// Based on the original matcher function of Select2: https://github.com/select2/select2/blob/5765090318c4d382ae56463cfa25ba8ca7bdd495/src/js/select2/defaults.js#L272
|
||||
// More information: https://select2.org/searching | https://github.com/select2/docs/blob/develop/pages/11.searching/docs.md
|
||||
this.matcher = function (params, data) {
|
||||
// Always return the object if there is nothing to compare
|
||||
if (jQuery.trim(params.term) === '') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Check whether options has children
|
||||
let hasChildren = data.children && data.children.length > 0;
|
||||
|
||||
// Split search param by space to search for all terms and convert all to uppercase
|
||||
let terms = params.term.toUpperCase().split(' ');
|
||||
let original = data.text.toUpperCase();
|
||||
|
||||
// Always return the parent option including its children, when the name matches one of the params
|
||||
// Check if the text contains all or at least one of the terms
|
||||
let foundAll = true;
|
||||
let foundOne = false;
|
||||
let missingTerms = [];
|
||||
terms.forEach(function(item, index) {
|
||||
if (original.indexOf(item) > -1) {
|
||||
foundOne = true;
|
||||
} else {
|
||||
foundAll = false;
|
||||
missingTerms.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// If the option element contains all terms, return it
|
||||
if (foundAll) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Do a recursive check for options with children
|
||||
if (hasChildren) {
|
||||
// If the parent already contains one or more search terms, proceed only with the missing ones
|
||||
// First: Clone the original params object...
|
||||
let newParams = jQuery.extend(true, {}, params);
|
||||
if (foundOne) {
|
||||
newParams.term = missingTerms.join(' ');
|
||||
} else {
|
||||
newParams.term = params.term;
|
||||
}
|
||||
|
||||
// Clone the data object if there are children
|
||||
// This is required as we modify the object to remove any non-matches
|
||||
let match = jQuery.extend(true, {}, data);
|
||||
|
||||
// Check each child of the option
|
||||
for (let c = data.children.length - 1; c >= 0; c--) {
|
||||
let child = data.children[c];
|
||||
|
||||
let matches = self.matcher(newParams, child);
|
||||
|
||||
// If there wasn't a match, remove the object in the array
|
||||
if (matches === null) {
|
||||
match.children.splice(c, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If any children matched, return the new object
|
||||
if (match.children.length > 0) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
// If the option or its children do not contain the term, don't return anything
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
activateSelectPickerByElement(node, container) {
|
||||
let options = {};
|
||||
if (container !== undefined) {
|
||||
options = {
|
||||
dropdownParent: jQuery(container),
|
||||
};
|
||||
}
|
||||
|
||||
options = {...options, ...{
|
||||
language: this.getConfiguration('locale').replace('_', '-'),
|
||||
theme: "bootstrap",
|
||||
matcher: this.matcher,
|
||||
dropdownAutoWidth: true,
|
||||
width: "resolve"
|
||||
}};
|
||||
|
||||
const element = jQuery(node);
|
||||
|
||||
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
|
||||
const templateResultFunc = function (state) {
|
||||
return jQuery('<span><span style="background-color:'+state.id+'; width: 20px; height: 20px; display: inline-block; margin-right: 10px;"> </span>' + state.text + '</span>');
|
||||
};
|
||||
|
||||
const colorOptions = {...options, ...{
|
||||
templateSelection: templateResultFunc,
|
||||
templateResult: templateResultFunc
|
||||
}};
|
||||
|
||||
element.select2(colorOptions);
|
||||
} else {
|
||||
element.select2(options);
|
||||
}
|
||||
|
||||
// this is a bugfix for safari, which does render the dropdown only with correct width upon the second opening
|
||||
// see https://github.com/select2/select2/issues/4678
|
||||
element.on('select2:open', function (ev) {
|
||||
if (element.data('performing-reopen') === undefined || element.data('performing-reopen') === null) {
|
||||
element.data('performing-reopen', true);
|
||||
element.select2('close');
|
||||
element.select2('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
activateSelectPicker(selector, container) {
|
||||
const self = this;
|
||||
jQuery(selector + ' ' + this.selector).each(function(i, el) {
|
||||
self.activateSelectPickerByElement(el, container);
|
||||
});
|
||||
}
|
||||
|
||||
destroySelectPicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).select2('destroy');
|
||||
}
|
||||
|
||||
updateOptions(selectIdentifier, data) {
|
||||
let select = jQuery(selectIdentifier);
|
||||
let emptyOption = jQuery(selectIdentifier + ' option[value=""]');
|
||||
const selectedValue = select.val();
|
||||
|
||||
select.find('option').remove().end().find('optgroup').remove().end();
|
||||
|
||||
if (emptyOption.length !== 0) {
|
||||
select.append(this._createOption(emptyOption.text(), ''));
|
||||
}
|
||||
|
||||
let emptyOpts = [];
|
||||
let options = [];
|
||||
let titlePattern = null;
|
||||
if (select[0] !== undefined && select[0].dataset !== undefined && select[0].dataset['optionPattern'] !== undefined) {
|
||||
titlePattern = select[0].dataset['optionPattern'];
|
||||
}
|
||||
if (titlePattern === null || titlePattern === '') {
|
||||
titlePattern = '{name}';
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === '__empty__') {
|
||||
for (const entity of value) {
|
||||
emptyOpts.push(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let optGroup = this._createOptgroup(key);
|
||||
for (const entity of value) {
|
||||
optGroup.appendChild(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
options.push(optGroup);
|
||||
}
|
||||
|
||||
select.append(options);
|
||||
select.append(emptyOpts);
|
||||
|
||||
// if available, re-select the previous selected option (mostly usable for global activities)
|
||||
select.val(selectedValue);
|
||||
|
||||
// pre-select an option if it is the only available one
|
||||
if (select.val() === '' || select.val() === null) {
|
||||
const allOptions = select.find('option');
|
||||
const optionLength = allOptions.length;
|
||||
let selectOption = '';
|
||||
|
||||
if (optionLength === 1) {
|
||||
selectOption = allOptions[0].value;
|
||||
} else if (optionLength === 2 && emptyOption.length === 1) {
|
||||
selectOption = allOptions[1].value;
|
||||
}
|
||||
|
||||
if (selectOption !== '') {
|
||||
select.val(selectOption);
|
||||
}
|
||||
}
|
||||
|
||||
// if we don't trigger the change, the other selects won't reset
|
||||
select.trigger('change');
|
||||
|
||||
// if select2 is active, this will tell the select to refresh
|
||||
if (select.hasClass('selectpicker')) {
|
||||
select.trigger('change.select2');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pattern
|
||||
* @param {array} entity
|
||||
* @private
|
||||
*/
|
||||
_getTitleFromPattern(pattern, entity) {
|
||||
const DATE_UTILS = this.getPlugin('date');
|
||||
const regexp = new RegExp('{[^}]*?}','g');
|
||||
let title = pattern;
|
||||
let match = null;
|
||||
|
||||
while ((match = regexp.exec(pattern)) !== null) {
|
||||
const field = match[0].substr(1, match[0].length - 2);
|
||||
let value = entity[field] === undefined ? null : entity[field];
|
||||
if ((field === 'start' || field === 'end')) {
|
||||
if (value === null) {
|
||||
value = '?';
|
||||
} else {
|
||||
value = DATE_UTILS.getFormattedDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
title = title.replace(new RegExp('{' + field + '}', 'g'), value ?? '');
|
||||
}
|
||||
title = title.replace(/- \?-\?/, '');
|
||||
title = title.replace(/\r\n|\r|\n/g, ' ');
|
||||
title = title.substr(0, 110);
|
||||
|
||||
const chars = '- ';
|
||||
let start = 0, end = title.length;
|
||||
|
||||
while (start < end && chars.indexOf(title[start]) >= 0) {
|
||||
++start;
|
||||
}
|
||||
|
||||
while (end > start && chars.indexOf(title[end - 1]) >= 0) {
|
||||
--end;
|
||||
}
|
||||
|
||||
return (start > 0 || end < title.length) ? title.substring(start, end) : title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @returns {HTMLElement}
|
||||
* @private
|
||||
*/
|
||||
_createOption(label, value) {
|
||||
let option = document.createElement('option');
|
||||
option.innerText = label;
|
||||
option.value = value;
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @returns {HTMLElement}
|
||||
* @private
|
||||
*/
|
||||
_createOptgroup(label) {
|
||||
let optGroup = document.createElement('optgroup');
|
||||
optGroup.label = label;
|
||||
return optGroup;
|
||||
}
|
||||
}
|
||||
51
assets/js/plugins/KimaiHotkeys.js
Normal file
51
assets/js/plugins/KimaiHotkeys.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiHotkeys extends KimaiPlugin {
|
||||
|
||||
getId()
|
||||
{
|
||||
return 'hotkeys';
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
|
||||
|
||||
const selector = '[data-hotkey="ctrl+Enter"]';
|
||||
|
||||
window.addEventListener('keyup', (ev) => {
|
||||
if (ev.ctrlKey && ev.key === 'Enter') {
|
||||
const elements = [...document.querySelectorAll(selector)].filter(element => this.isVisible(element));
|
||||
|
||||
if (elements.length > 1) {
|
||||
console.warn('KimaiHotkeys: More than one visible element matches ${selector}. No action triggered.');
|
||||
}
|
||||
|
||||
if (elements.length === 1) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
elements[0].click();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// adopted from Bootstrap 5.1.1, MIT
|
||||
isVisible (element)
|
||||
{
|
||||
if (!element || element.getClientRects().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getComputedStyle(element).getPropertyValue('visibility') === 'visible';
|
||||
}
|
||||
}
|
||||
@@ -10,63 +10,79 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiMultiUpdateTable extends KimaiPlugin {
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
|
||||
jQuery('body').
|
||||
on('change', '#multi_update_all', function(event) {
|
||||
jQuery('.multi_update_single').prop('checked', jQuery(event.target).prop('checked'));
|
||||
self.toggleForm();
|
||||
})
|
||||
.on('change', '.multi_update_single', function(event) {
|
||||
self.toggleForm();
|
||||
})
|
||||
.on('change', '#multi_update_table_action', function(event) {
|
||||
const selectedItem = jQuery('#multi_update_table_action option:selected');
|
||||
const selectedVal = selectedItem.val();
|
||||
init()
|
||||
{
|
||||
if (document.getElementById('multi_update_all') === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedVal === '') {
|
||||
return;
|
||||
// we have to attach it to the "page-body" div, because section.content can be replaced
|
||||
// via KimaiDatable and everything inside will be removed, including event listeners
|
||||
const element = document.querySelector('div.page-body');
|
||||
element.addEventListener('change', (event) => {
|
||||
if (event.target.matches('#multi_update_all')) {
|
||||
// the "check all" checkbox in the upper start corner of the table
|
||||
const checked = event.target.checked;
|
||||
for (const element of document.querySelectorAll('.multi_update_single')) {
|
||||
element.checked = checked;
|
||||
}
|
||||
|
||||
const form = jQuery('#multi_update_form form');
|
||||
const selectedText = selectedItem.text();
|
||||
const ids = self.getSelectedIds();
|
||||
const question = form.attr('data-question').replace(/%action%/, selectedText).replace(/%count%/, ids.length);
|
||||
|
||||
self.getContainer().getPlugin('alert').question(question, function(value) {
|
||||
this._toggleForm();
|
||||
event.stopPropagation();
|
||||
} else if (event.target.matches('.multi_update_single')) {
|
||||
// single checkboxes in front of each row
|
||||
this._toggleForm();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('click', (event) => {
|
||||
if (event.target.matches('.multi_update_table_action')) {
|
||||
const selectedItem = event.target;
|
||||
const ids = this._getSelectedIds();
|
||||
const form = document.getElementById('multi_update_form');
|
||||
const question = form.dataset['question'].replace(/%action%/, selectedItem.textContent).replace(/%count%/, ids.length.toString());
|
||||
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.getPlugin('alert');
|
||||
ALERT.question(question, function(value) {
|
||||
if (value) {
|
||||
form.attr('action', selectedVal).submit();
|
||||
} else {
|
||||
jQuery('#multi_update_table_action').val('').trigger('change');
|
||||
const form = document.getElementById('multi_update_form');
|
||||
form.action = selectedItem.dataset['href'];
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSelectedIds()
|
||||
_getSelectedIds()
|
||||
{
|
||||
let ids = [];
|
||||
jQuery('.multi_update_single:checked').each(function(i){
|
||||
ids[i] = $(this).val();
|
||||
});
|
||||
for (const box of document.querySelectorAll('input.multi_update_single:checked')) {
|
||||
ids.push(box.value);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
toggleForm()
|
||||
_toggleForm()
|
||||
{
|
||||
const ids = this.getSelectedIds();
|
||||
jQuery('#multi_update_table_entities').val(ids.join(','));
|
||||
const ids = this._getSelectedIds();
|
||||
document.getElementById('multi_update_table_entities').value = ids.join(',');
|
||||
|
||||
if (ids.length > 0) {
|
||||
jQuery('#multi_update_form').show();
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
element.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
document.getElementById('multi_update_form').style.display = null;//'block';
|
||||
} else {
|
||||
jQuery('#multi_update_form').hide();
|
||||
document.getElementById('multi_update_form').style.setProperty('display', 'none', 'important');
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
element.style.display = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
assets/js/plugins/KimaiNotification.js
Normal file
101
assets/js/plugins/KimaiNotification.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] Notification: notifications for Kimai
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiNotification extends KimaiPlugin {
|
||||
|
||||
getId()
|
||||
{
|
||||
return 'notification';
|
||||
}
|
||||
|
||||
isSupported()
|
||||
{
|
||||
if (!window.Notification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Notification.permission === "granted";
|
||||
}
|
||||
|
||||
request(callback)
|
||||
{
|
||||
try {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
callback(true);
|
||||
} else if (permission === "default") {
|
||||
callback(null);
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Notification.requestPermission((permission) => {
|
||||
if (permission === "granted") {
|
||||
callback(true);
|
||||
} else if (permission === "default") {
|
||||
callback(null);
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
notify(title, message, icon, options)
|
||||
{
|
||||
this.request((permission) => {
|
||||
|
||||
if (permission !== true) {
|
||||
/** @type KimaiAlert */
|
||||
const ALERT = this.getPlugin('alert');
|
||||
ALERT.info(message);
|
||||
}
|
||||
|
||||
let opts = {
|
||||
body: message,
|
||||
dir: this.getConfigurations().isRTL() ? 'rtl' : 'ltr',
|
||||
};
|
||||
//opts.requireInteraction = true;
|
||||
//opts.renotify = true;
|
||||
/*
|
||||
if (options.tag === undefined) {
|
||||
opts.tag = 'kimai';
|
||||
}
|
||||
*/
|
||||
if (icon !== undefined && icon !== null) {
|
||||
opts.icon = icon;
|
||||
}
|
||||
|
||||
let nTitle = 'Kimai';
|
||||
if (title !== null) {
|
||||
nTitle = nTitle + ': ' + title;
|
||||
}
|
||||
|
||||
if (options !== undefined && options !== null) {
|
||||
opts = { ...opts, ...options};
|
||||
}
|
||||
|
||||
const notification = new window.Notification(nTitle, opts);
|
||||
|
||||
notification.onclick = function () {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiPauseRecord
|
||||
*
|
||||
* allows to pause records
|
||||
* THIS IS JUST A DRAFT FOR THE DOM, IT IS NOT SUPPORTED IN KIMAI ITSELF!
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiPauseRecord extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.activate(this.selector);
|
||||
}
|
||||
|
||||
activate(selector) {
|
||||
jQuery(selector + ' .pull-left i').hover(function () {
|
||||
let link = jQuery(this).parents('a');
|
||||
link.attr('href', link.attr('href').replace('/stop', '/pause'));
|
||||
jQuery(this).removeClass('fa-stop-circle').addClass('fa-pause-circle').addClass('text-orange');
|
||||
},function () {
|
||||
let link = jQuery(this).parents('a');
|
||||
link.attr('href', link.attr('href').replace('/pause', '/stop'));
|
||||
jQuery(this).removeClass('fa-pause-circle').removeClass('text-orange').addClass('fa-stop-circle');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,32 +13,23 @@ import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiRecentActivities extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'recent-activities';
|
||||
}
|
||||
|
||||
init() {
|
||||
const menu = document.querySelector(this.selector);
|
||||
init()
|
||||
{
|
||||
this.menu = document.querySelector('header .notifications-menu');
|
||||
// the menu can be hidden if user has no permissions to see it
|
||||
if (menu === null) {
|
||||
// or no timesheet was recorded yet
|
||||
if (this.menu === null || this.menu.dataset['reload'] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropdown = menu.querySelector('ul.dropdown-menu');
|
||||
|
||||
this.attributes = dropdown.dataset;
|
||||
this.itemList = dropdown.querySelector('li > ul.menu');
|
||||
|
||||
const self = this;
|
||||
const handle = function() { self.reloadRecentActivities(); };
|
||||
|
||||
// don't block initial browser rendering
|
||||
setTimeout(handle, 500);
|
||||
const handle = () => {
|
||||
this._reloadMenu(this.menu.dataset['reload']);
|
||||
};
|
||||
|
||||
document.addEventListener('kimai.recentActivities', handle);
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
@@ -49,45 +40,44 @@ export default class KimaiRecentActivities extends KimaiPlugin {
|
||||
document.addEventListener('kimai.projectDelete', handle);
|
||||
document.addEventListener('kimai.customerUpdate', handle);
|
||||
document.addEventListener('kimai.customerDelete', handle);
|
||||
|
||||
this._attachAddRemoveFavorite();
|
||||
}
|
||||
|
||||
emptyList() {
|
||||
this.itemList.innerHTML = '';
|
||||
}
|
||||
_attachAddRemoveFavorite()
|
||||
{
|
||||
[].slice.call(this.menu.querySelectorAll('a.list-group-item-actions')).map((element) => {
|
||||
element.addEventListener('click', (event) => {
|
||||
this._reloadMenu(event.currentTarget.href);
|
||||
|
||||
setEntries(entries) {
|
||||
if (entries.length === 0) {
|
||||
this.emptyList();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let htmlToInsert = '';
|
||||
|
||||
for (let timesheet of entries) {
|
||||
let label = this.attributes['template']
|
||||
.replace('%customer%', this.escape(timesheet.project.customer.name))
|
||||
.replace('%project%', this.escape(timesheet.project.name))
|
||||
.replace('%activity%', this.escape(timesheet.activity.name))
|
||||
;
|
||||
|
||||
htmlToInsert +=
|
||||
`<li>` +
|
||||
`<a href="${ this.attributes['href'].replace('000', timesheet.id) }" data-event="kimai.timesheetStart kimai.timesheetUpdate" class="api-link" data-method="PATCH" data-msg-error="timesheet.start.error" data-msg-success="timesheet.start.success">` +
|
||||
`<i class="${ this.attributes['icon'] }"></i> ${ label }` +
|
||||
`</a>` +
|
||||
`</li>`;
|
||||
}
|
||||
|
||||
this.itemList.innerHTML = htmlToInsert;
|
||||
}
|
||||
|
||||
reloadRecentActivities() {
|
||||
const self = this;
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(this.attributes['api'], {}, function(result) {
|
||||
self.setEntries(result);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_reloadMenu(url)
|
||||
{
|
||||
this.fetch(url, {method: 'GET'})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
//this.menu.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
return response.text().then(html => {
|
||||
const newFormHtml = document.createElement('div');
|
||||
newFormHtml.innerHTML = html;
|
||||
this.menu.replaceWith(newFormHtml.firstElementChild);
|
||||
|
||||
this.menu = document.querySelector('header .notifications-menu');
|
||||
this._attachAddRemoveFavorite();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
//this.menu.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,57 @@
|
||||
* [KIMAI] KimaiReducedClickHandler: abstract class
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiReducedClickHandler extends KimaiPlugin {
|
||||
|
||||
_addClickHandler(selector, callback) {
|
||||
jQuery('body').on('click', selector, function(event) {
|
||||
// just in case an inner element is editable, than this should not be triggered
|
||||
if (event.target.parentNode.isContentEditable || event.target.isContentEditable) {
|
||||
/**
|
||||
* No _underscore naming for now, as it would be mangled otherwise
|
||||
* @param selector
|
||||
* @param callback
|
||||
*/
|
||||
addClickHandler(selector, callback) {
|
||||
document.body.addEventListener('click', (event) => {
|
||||
// event.currentTarget is ALWAYS the body
|
||||
|
||||
let target = event.target;
|
||||
while (target !== null) {
|
||||
const tagName = target.tagName.toUpperCase();
|
||||
if (tagName === 'BODY') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.matches(selector)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// when an element is clicked, which can trigger stuff itself, we don't want the event to be processed
|
||||
if (tagName === 'A' || tagName === 'BUTTON' || tagName === 'INPUT' || tagName === 'LABEL') {
|
||||
return;
|
||||
}
|
||||
|
||||
target = target.parentNode;
|
||||
}
|
||||
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// handles the "click" on table rows or list elements
|
||||
let target = event.target;
|
||||
if (event.currentTarget.matches('tr') || event.currentTarget.matches('li')) {
|
||||
while (target !== null && !target.matches('body')) {
|
||||
// when an element within the row is clicked, that can trigger stuff itself, we don't want the event to be processed
|
||||
// don't act if a link, button or form element was clicked
|
||||
if (target.matches('a') || target.matches ('button') || target.matches ('input')) {
|
||||
return;
|
||||
}
|
||||
target = target.parentNode;
|
||||
}
|
||||
// just in case an inner element is editable, then this should not be triggered
|
||||
if (target.isContentEditable || target.parentNode.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.matches(selector)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let href = jQuery(this).attr('data-href');
|
||||
if (!href) {
|
||||
href = jQuery(this).attr('href');
|
||||
let href = target.dataset['href'];
|
||||
if (href === undefined || href === null) {
|
||||
href = target.href;
|
||||
}
|
||||
|
||||
if (href === undefined || href === null || href === '') {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiSelectDataAPI: <select> boxes with dynamic data from API
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import moment from 'moment';
|
||||
|
||||
export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'select-data-api';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.activateApiSelects(this.selector);
|
||||
}
|
||||
|
||||
activateApiSelects(selector) {
|
||||
const self = this;
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
jQuery('body').on('change', selector, function(event) {
|
||||
const targetSelect = '#' + this.dataset['relatedSelect'];
|
||||
|
||||
// if the related target select does not exist, we do not need to load the related data
|
||||
if (jQuery(targetSelect).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let formPrefix = this.dataset['formPrefix'];
|
||||
if (formPrefix === undefined || formPrefix === null) {
|
||||
formPrefix = '';
|
||||
} else if (formPrefix.length > 0) {
|
||||
formPrefix += '_';
|
||||
}
|
||||
|
||||
let newApiUrl = self._buildUrlWithFormFields(this.dataset['apiUrl'], formPrefix);
|
||||
|
||||
const selectValue = jQuery(this).val();
|
||||
|
||||
// Problem: select a project with activities and then select a customer that has no project
|
||||
// results in a wrong URL, it triggers "activities?project=" instead of using the "emptyUrl"
|
||||
if (selectValue === undefined || selectValue === null || selectValue === '' || (Array.isArray(selectValue) && selectValue.length === 0)) {
|
||||
if (this.dataset['emptyUrl'] === undefined) {
|
||||
self._updateSelect(targetSelect, {});
|
||||
jQuery(targetSelect).attr('disabled', 'disabled');
|
||||
return;
|
||||
}
|
||||
newApiUrl = self._buildUrlWithFormFields(this.dataset['emptyUrl'], formPrefix);
|
||||
}
|
||||
|
||||
jQuery(targetSelect).removeAttr('disabled');
|
||||
|
||||
API.get(newApiUrl, {}, function(data){
|
||||
self._updateSelect(targetSelect, data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_buildUrlWithFormFields(apiUrl, formPrefix) {
|
||||
let newApiUrl = apiUrl;
|
||||
|
||||
apiUrl.split('?')[1].split('&').forEach(item => {
|
||||
const [key, value] = item.split('=');
|
||||
const decoded = decodeURIComponent(value);
|
||||
const test = decoded.match(/%(.*)%/);
|
||||
if (test !== null) {
|
||||
const targetField = jQuery('#' + formPrefix + test[1]);
|
||||
let newValue = '';
|
||||
if (targetField.length === 0) {
|
||||
// happens for example:
|
||||
// - in duration only mode, when the end field is not found
|
||||
// console.log('ERROR: Cannot find field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
|
||||
} else {
|
||||
if (targetField.val() !== null) {
|
||||
newValue = targetField.val();
|
||||
|
||||
if (newValue !== '') {
|
||||
// having that special case here is far from being perfect... but for now it works ;-)
|
||||
if (targetField.data('daterangepicker') !== undefined) {
|
||||
if (key === 'begin' || key === 'start' || targetField.data('daterangepicker').singleDatePicker) {
|
||||
newValue = targetField.data('daterangepicker').startDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
} else if (key === 'end') {
|
||||
newValue = targetField.data('daterangepicker').endDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
}
|
||||
} else if (targetField.data('format') !== undefined) {
|
||||
if (moment(newValue, targetField.data('format')).isValid()) {
|
||||
newValue = moment(newValue, targetField.data('format')).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// happens for example:
|
||||
// - when the end date is not set on a timesheet record and the project list is loaded (as the URL contains the %end% replacer)
|
||||
// console.log('Empty value found for field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
|
||||
}
|
||||
} else {
|
||||
// happens for example:
|
||||
// - when a customer without projects is selected
|
||||
// console.log('ERROR: Empty field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(newValue)) {
|
||||
newValue = newValue.join(',');
|
||||
}
|
||||
|
||||
newApiUrl = newApiUrl.replace(value, newValue);
|
||||
}
|
||||
});
|
||||
|
||||
return newApiUrl;
|
||||
}
|
||||
|
||||
_updateSelect(selectName, data) {
|
||||
const options = {};
|
||||
for (const apiData of data) {
|
||||
let title = '__empty__';
|
||||
if (apiData.hasOwnProperty('parentTitle') && apiData.parentTitle !== null) {
|
||||
title = apiData.parentTitle;
|
||||
}
|
||||
if (!options.hasOwnProperty(title)) {
|
||||
options[title] = [];
|
||||
}
|
||||
options[title].push(apiData);
|
||||
}
|
||||
|
||||
const ordered = {};
|
||||
Object.keys(options).sort().forEach(function(key) {
|
||||
ordered[key] = options[key];
|
||||
});
|
||||
|
||||
/** @var {KimaiFormSelect} select */
|
||||
const select = this.getContainer().getPlugin('form-select');
|
||||
select.updateOptions(selectName, ordered);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,93 +9,80 @@
|
||||
* [KIMAI] KimaiThemeInitializer: initialize theme functionality
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import { Tooltip } from 'bootstrap';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
|
||||
init() {
|
||||
this.registerGlobalAjaxErrorHandler();
|
||||
this.registerAutomaticAlertRemove('div.alert-success', 5000);
|
||||
// activate the dropdown functionality
|
||||
jQuery('.dropdown-toggle').dropdown();
|
||||
// activate the tooltip functionality
|
||||
jQuery('[data-toggle="tooltip"]').tooltip();
|
||||
// activate all form plugins
|
||||
this.getContainer().getPlugin('form').activateForm('.content-wrapper form', 'body');
|
||||
this.getContainer().getPlugin('form').activateForm('form.searchform', 'body');
|
||||
init()
|
||||
{
|
||||
// the tooltip do not use data-bs-toggle="tooltip" so they can be mixed with data-toggle="modal"
|
||||
[].slice.call(document.querySelectorAll('[data-toggle="tooltip"]')).map(function (tooltipTriggerEl) {
|
||||
return new Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
this.registerModalAutofocus('#modal_search');
|
||||
this.registerModalAutofocus('#remote_form_modal');
|
||||
// activate all form plugins
|
||||
/** @type {KimaiForm} FORMS */
|
||||
const FORMS = this.getContainer().getPlugin('form');
|
||||
FORMS.activateForm('div.page-wrapper form');
|
||||
|
||||
this._registerModalAutofocus('#remote_form_modal');
|
||||
|
||||
this.overlay = null;
|
||||
|
||||
// register a global event listener, which displays an overlays upon notification
|
||||
document.addEventListener('kimai.reloadContent', (event) => {
|
||||
// do not allow more than one loading screen at a time
|
||||
if (this.overlay !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// at which element we append the loading screen
|
||||
let container = 'body';
|
||||
if (event.detail !== undefined && event.detail !== null) {
|
||||
container = event.detail;
|
||||
}
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = '<div class="overlay"><div class="fas fa-sync fa-spin"></div></div>';
|
||||
this.overlay = temp.firstElementChild;
|
||||
document.querySelector(container).append(this.overlay);
|
||||
});
|
||||
|
||||
// register a global event listener, which hides an overlay upon notification
|
||||
document.addEventListener('kimai.reloadedContent', () => {
|
||||
if (this.overlay !== null) {
|
||||
this.overlay.remove();
|
||||
this.overlay = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* workaround for autofocus attribute, as the modal "steals" it
|
||||
* Helps to set the autofocus on modals.
|
||||
*
|
||||
* @param {string} selector
|
||||
*/
|
||||
registerModalAutofocus(selector) {
|
||||
let modal = jQuery(selector);
|
||||
if (modal.length === 0) {
|
||||
_registerModalAutofocus(selector) {
|
||||
// on mobile you do not want to trigger the virtual keyboard upon modal open
|
||||
if (this.isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.on('shown.bs.modal', function () {
|
||||
let form = modal.find('form');
|
||||
let formAutofocus = form.find('[autofocus]');
|
||||
const modal = document.querySelector(selector);
|
||||
if (modal === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
const form = modal.querySelector('form');
|
||||
let formAutofocus = form.querySelectorAll('[autofocus]');
|
||||
if (formAutofocus.length < 1) {
|
||||
formAutofocus = form.find('input[type=text],textarea,select');
|
||||
formAutofocus = form.querySelectorAll('input[type=text],input[type=date],textarea,select');
|
||||
}
|
||||
formAutofocus.filter(':not("[data-datetimepicker=on]")').filter(':visible:first').focus().delay(1000).focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect access denied / session timeouts to login page
|
||||
*/
|
||||
registerGlobalAjaxErrorHandler() {
|
||||
const loginUrl = this.getConfiguration('login');
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
const translation = this.getContainer().getTranslation().get('login.required');
|
||||
jQuery(document).ajaxError(function(event, jqxhr, settings, thrownError) {
|
||||
if (jqxhr.status !== undefined && jqxhr.status === 403) {
|
||||
const loginRequired = jqxhr.getResponseHeader('login-required');
|
||||
if (loginRequired !== null) {
|
||||
alert.question(translation, function (result) {
|
||||
if (result === true) {
|
||||
window.location.replace(loginUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (formAutofocus.length > 0) {
|
||||
formAutofocus[0].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* auto hide success messages, as they are just meant as user feedback and not as a permanent information
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {integer} interval
|
||||
*/
|
||||
registerAutomaticAlertRemove(selector, interval) {
|
||||
const self = this;
|
||||
this._alertRemoveHandler = setInterval(
|
||||
function() {
|
||||
self.hideAlert(selector);
|
||||
},
|
||||
interval
|
||||
);
|
||||
}
|
||||
|
||||
unregisterAutomaticAlertRemove() {
|
||||
clearInterval(this._alertRemoveHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
*/
|
||||
hideAlert(selector) {
|
||||
jQuery(selector).alert('close');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
* [KIMAI] KimaiToolbar: some event listener to handle the toolbar/data-table filter, toolbar and navigation
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiToolbar extends KimaiPlugin {
|
||||
|
||||
constructor(formSelector, formSubmitActionClass) {
|
||||
super();
|
||||
this.formSelector = formSelector;
|
||||
this.actionClass = formSubmitActionClass;
|
||||
this._formSelector = formSelector;
|
||||
this._actionClass = formSubmitActionClass;
|
||||
}
|
||||
|
||||
getId() {
|
||||
@@ -26,50 +25,52 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
|
||||
init() {
|
||||
const formSelector = this.getSelector();
|
||||
const self = this;
|
||||
const EVENT = self.getContainer().getPlugin('event');
|
||||
|
||||
this._registerPagination(formSelector, EVENT);
|
||||
this._registerSortableTables(formSelector, EVENT);
|
||||
this._registerAlternativeSubmitActions(formSelector, this.actionClass);
|
||||
this._registerPagination(formSelector);
|
||||
this._registerSortableTables(formSelector);
|
||||
this._registerAlternativeSubmitActions(formSelector, this._actionClass);
|
||||
|
||||
// Reset the page if filter values are changed, otherwise we might end up with a limited set of data,
|
||||
// which does not support the given page - and it would be just wrong to stay in the same page
|
||||
jQuery(formSelector +' input').change(function (event) {
|
||||
switch (event.target.id) {
|
||||
case 'order':
|
||||
case 'orderBy':
|
||||
case 'page':
|
||||
break;
|
||||
default:
|
||||
jQuery(formSelector + ' input#page').val(1);
|
||||
break;
|
||||
}
|
||||
self.triggerChange();
|
||||
[].slice.call(document.querySelectorAll(formSelector + ' input')).map((element) => {
|
||||
element.addEventListener('change', (event) => {
|
||||
switch (event.target.id) {
|
||||
case 'order':
|
||||
case 'orderBy':
|
||||
case 'page':
|
||||
break;
|
||||
default:
|
||||
document.querySelector(formSelector + ' input#page').value = 1;
|
||||
break;
|
||||
}
|
||||
});
|
||||
this.triggerChange();
|
||||
});
|
||||
|
||||
|
||||
// when user selected a new customer or project, reset the pagination back to 1
|
||||
// and then find out if the results should be reloaded
|
||||
jQuery(formSelector + ' select').change(function (event) {
|
||||
let reload = true;
|
||||
switch (event.target.id) {
|
||||
case 'customer':
|
||||
if (jQuery(formSelector + ' select#project').length > 0) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
[].slice.call(document.querySelectorAll(formSelector + ' select')).map((element) => {
|
||||
element.addEventListener('change', (event) => {
|
||||
let reload = true;
|
||||
switch (event.target.id) {
|
||||
case 'customer':
|
||||
if (document.querySelector(formSelector + ' select#project') !== null) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'project':
|
||||
if (jQuery(formSelector + ' select#activity').length > 0) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
jQuery(formSelector + ' input#page').val(1);
|
||||
case 'project':
|
||||
if (document.querySelector(formSelector + ' select#activity') !== null) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
document.querySelector(formSelector + ' input#page').value = 1;
|
||||
|
||||
if (reload) {
|
||||
self.triggerChange();
|
||||
}
|
||||
if (reload) {
|
||||
this.triggerChange();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,15 +81,17 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
_registerAlternativeSubmitActions(toolbarSelector, actionBtnClass) {
|
||||
document.addEventListener('click', function(event) {
|
||||
let target = event.target;
|
||||
while (target !== null && !target.matches('body')) {
|
||||
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
|
||||
if (target.classList.contains(actionBtnClass)) {
|
||||
const form = document.querySelector(toolbarSelector);
|
||||
if (form === null) {
|
||||
return;
|
||||
}
|
||||
const prevAction = form.action;
|
||||
const prevMethod = form.method;
|
||||
form.target = '_blank';
|
||||
const prevAction = form.getAttribute('action');
|
||||
const prevMethod = form.getAttribute('method');
|
||||
if (target.dataset.target !== undefined) {
|
||||
form.target = target.dataset.target;
|
||||
}
|
||||
form.action = target.href;
|
||||
if (target.dataset.method !== undefined) {
|
||||
form.method = target.dataset.method;
|
||||
@@ -104,51 +107,65 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
|
||||
target = target.parentNode;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortable datatables use hidden fields in the toolbar filter/search form
|
||||
* @private
|
||||
*/
|
||||
_registerSortableTables(formSelector, EVENT) {
|
||||
jQuery('body').on('click', 'th.sortable', function(event){
|
||||
var $header = jQuery(event.target);
|
||||
var order = 'DESC';
|
||||
var orderBy = $header.data('order');
|
||||
if ($header.hasClass('sorting_desc')) {
|
||||
_registerSortableTables(formSelector) {
|
||||
document.body.addEventListener('click', (event) => {
|
||||
if (!event.target.matches('th.sortable')) {
|
||||
return;
|
||||
}
|
||||
let order = 'DESC';
|
||||
let orderBy = event.target.dataset['order'];
|
||||
if (event.target.classList.contains('sorting_desc')) {
|
||||
order = 'ASC';
|
||||
}
|
||||
|
||||
jQuery(formSelector + ' #orderBy').val(orderBy);
|
||||
jQuery(formSelector + ' #order').val(order);
|
||||
document.querySelector(formSelector + ' #orderBy').value = orderBy;
|
||||
document.querySelector(formSelector + ' #order').value = order;
|
||||
|
||||
// re-render the selectboxes
|
||||
jQuery(formSelector + ' #orderBy').trigger('change');
|
||||
jQuery(formSelector + ' #order').trigger('change');
|
||||
// re-render the selectbox
|
||||
document.querySelector(formSelector + ' #orderBy').dispatchEvent(new Event('change'));
|
||||
document.querySelector(formSelector + ' #order').dispatchEvent(new Event('change'));
|
||||
|
||||
// triggers the datatable reload - search for the event name
|
||||
EVENT.trigger('filter-change');
|
||||
document.dispatchEvent(new Event('filter-change'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This catches all clicks on the pagination and prevents the default action, as we want to reload the page via JS
|
||||
* This catches all clicks on the pagination and prevents the default action,
|
||||
* as we want to reload the page via JS.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_registerPagination(formSelector, EVENT) {
|
||||
jQuery('body').on('click', 'div.navigation ul.pagination li a', function(event) {
|
||||
let pager = jQuery(formSelector + " input#page");
|
||||
if (pager.length === 0) {
|
||||
_registerPagination(formSelector) {
|
||||
document.body.addEventListener('click', (event) => {
|
||||
if (!event.target.matches('ul.pagination li a') && (event.target.parentNode === null || !event.target.parentNode.matches('ul.pagination li a'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pager = document.querySelector(formSelector + " input#page");
|
||||
if (pager === null) {
|
||||
return;
|
||||
}
|
||||
let target = event.target;
|
||||
|
||||
// this happens for the arrows, which can be an icon <i> element
|
||||
if (!target.matches('a')) {
|
||||
target = target.parentNode;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let urlParts = jQuery(this).attr('href').split('/');
|
||||
let page = urlParts[urlParts.length-1];
|
||||
pager.val(page);
|
||||
pager.trigger('change');
|
||||
EVENT.trigger('pagination-change');
|
||||
let urlParts = target.href.split('/');
|
||||
pager.value = urlParts[urlParts.length-1];
|
||||
pager.dispatchEvent(new Event('change'));
|
||||
document.dispatchEvent(new Event('pagination-change'));
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -158,7 +175,7 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
* Triggers an event, that everyone can listen for.
|
||||
*/
|
||||
triggerChange() {
|
||||
this.getContainer().getPlugin('event').trigger('toolbar-change');
|
||||
document.dispatchEvent(new Event('toolbar-change'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +184,7 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
* @returns {string}
|
||||
*/
|
||||
getSelector() {
|
||||
return this.formSelector;
|
||||
return this._formSelector;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user