Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -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);
});
}
}
}

View File

@@ -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') {

View File

@@ -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);
});
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
});
}

View File

@@ -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');
});
}
}

View File

@@ -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;

View File

@@ -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();
});
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
});
}
}

View File

@@ -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();
}
});
}
}

View File

@@ -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();
}
});
}
}

View File

@@ -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;
}
}

View File

@@ -22,6 +22,10 @@ export default class KimaiEscape extends KimaiPlugin {
* @returns {string}
*/
escapeForHtml(title) {
if (title === undefined || title === null) {
return '';
}
const tagsToReplace = {
'&': '&amp;',
'<': '&lt;',
@@ -31,5 +35,5 @@ export default class KimaiEscape extends KimaiPlugin {
return title.replace(/[&<>]/g, function(tag) {
return tagsToReplace[tag] || tag;
});
};
}
}

View File

@@ -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);

View 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);
});
});
}
}

View File

@@ -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('&');

View File

@@ -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;">&nbsp;</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;
}
}

View 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';
}
}

View File

@@ -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;
}
}
}

View 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();
};
});
}
}

View File

@@ -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');
});
}
}

View File

@@ -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();
});
}
}

View File

@@ -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 === '') {

View File

@@ -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);
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}