Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)
This commit is contained in:
@@ -23,4 +23,31 @@ export default class KimaiConfiguration {
|
||||
return name in this._configurations;
|
||||
}
|
||||
|
||||
isRTL() {
|
||||
return this.get('direction') === 'rtl';
|
||||
}
|
||||
|
||||
getLanguage() {
|
||||
return this.get('locale').replace('_', '-');
|
||||
}
|
||||
|
||||
is24Hours() {
|
||||
return !!this.get('twentyFourHours');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} iso
|
||||
* @return {number}
|
||||
*/
|
||||
getFirstDayOfWeek(iso = true) {
|
||||
if (iso === undefined) {
|
||||
iso = true;
|
||||
}
|
||||
let config = this.get('first_dow_iso');
|
||||
if (!iso) {
|
||||
config = config % 7;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export default class KimaiContainer {
|
||||
/**
|
||||
* Create a new Container with the given configurations and translations.
|
||||
*
|
||||
* @param {Object} configuration
|
||||
* @param {Object} translation
|
||||
* @param {KimaiConfiguration} configuration
|
||||
* @param {KimaiTranslation} translation
|
||||
*/
|
||||
constructor(configuration, translation) {
|
||||
if (!(configuration instanceof KimaiConfiguration)) {
|
||||
|
||||
@@ -9,19 +9,16 @@
|
||||
* [KIMAI] KimaiLoader: bootstrap the application and all plugins
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import { Settings } from 'luxon';
|
||||
import KimaiTranslation from "./KimaiTranslation";
|
||||
import KimaiConfiguration from "./KimaiConfiguration";
|
||||
import KimaiContainer from "./KimaiContainer";
|
||||
import KimaiActiveRecordsDuration from './plugins/KimaiActiveRecordsDuration.js';
|
||||
import KimaiDatatableColumnView from './plugins/KimaiDatatableColumnView.js';
|
||||
import KimaiThemeInitializer from "./plugins/KimaiThemeInitializer";
|
||||
import KimaiDateRangePicker from "./plugins/KimaiDateRangePicker";
|
||||
import KimaiDateRangePicker from "./forms/KimaiDateRangePicker";
|
||||
import KimaiDatatable from "./plugins/KimaiDatatable";
|
||||
import KimaiToolbar from "./plugins/KimaiToolbar";
|
||||
import KimaiAPI from "./plugins/KimaiAPI";
|
||||
import KimaiSelectDataAPI from "./plugins/KimaiSelectDataAPI";
|
||||
import KimaiDateTimePicker from "./plugins/KimaiDateTimePicker";
|
||||
import KimaiAlternativeLinks from "./plugins/KimaiAlternativeLinks";
|
||||
import KimaiAjaxModalForm from "./plugins/KimaiAjaxModalForm";
|
||||
import KimaiActiveRecords from "./plugins/KimaiActiveRecords";
|
||||
@@ -29,60 +26,76 @@ import KimaiRecentActivities from "./plugins/KimaiRecentActivities";
|
||||
import KimaiEvent from "./plugins/KimaiEvent";
|
||||
import KimaiAPILink from "./plugins/KimaiAPILink";
|
||||
import KimaiAlert from "./plugins/KimaiAlert";
|
||||
import KimaiAutocomplete from "./plugins/KimaiAutocomplete";
|
||||
import KimaiFormSelect from "./plugins/KimaiFormSelect";
|
||||
import KimaiAutocomplete from "./forms/KimaiAutocomplete";
|
||||
import KimaiFormSelect from "./forms/KimaiFormSelect";
|
||||
import KimaiForm from "./plugins/KimaiForm";
|
||||
import KimaiDatePicker from "./plugins/KimaiDatePicker";
|
||||
import KimaiDatePicker from "./forms/KimaiDatePicker";
|
||||
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
|
||||
import KimaiMultiUpdateTable from "./plugins/KimaiMultiUpdateTable";
|
||||
import KimaiDateUtils from "./plugins/KimaiDateUtils";
|
||||
import KimaiEscape from "./plugins/KimaiEscape";
|
||||
import KimaiFetch from "./plugins/KimaiFetch";
|
||||
import KimaiTimesheetForm from "./forms/KimaiTimesheetForm";
|
||||
import KimaiTeamForm from "./forms/KimaiTeamForm";
|
||||
import KimaiCopyDataForm from "./forms/KimaiCopyDataForm";
|
||||
import KimaiDateNowForm from "./forms/KimaiDateNowForm";
|
||||
import KimaiNotification from "./plugins/KimaiNotification";
|
||||
import KimaiHotkeys from "./plugins/KimaiHotkeys";
|
||||
|
||||
export default class KimaiLoader {
|
||||
|
||||
constructor(configurations, translations) {
|
||||
// set the current locale for all javascript components
|
||||
moment.locale(configurations['locale'].replace('_', '-').toLowerCase());
|
||||
Settings.defaultLocale = configurations['locale'].replace('_', '-').toLowerCase();
|
||||
Settings.defaultZone = configurations['timezone'];
|
||||
|
||||
const kimai = new KimaiContainer(
|
||||
new KimaiConfiguration(configurations),
|
||||
new KimaiTranslation(translations)
|
||||
);
|
||||
|
||||
// GLOBAL HELPER PLUGINS
|
||||
kimai.registerPlugin(new KimaiEscape());
|
||||
kimai.registerPlugin(new KimaiEvent());
|
||||
kimai.registerPlugin(new KimaiAPI());
|
||||
kimai.registerPlugin(new KimaiAlert());
|
||||
kimai.registerPlugin(new KimaiFetch());
|
||||
kimai.registerPlugin(new KimaiDateUtils());
|
||||
kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
|
||||
kimai.registerPlugin(new KimaiNotification());
|
||||
|
||||
// FORM PLUGINS
|
||||
kimai.registerPlugin(new KimaiFormSelect('.selectpicker', 'select[data-related-select]'));
|
||||
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepicker="on"]'));
|
||||
kimai.registerPlugin(new KimaiDatePicker('input[data-datepicker="on"]'));
|
||||
kimai.registerPlugin(new KimaiAutocomplete());
|
||||
kimai.registerPlugin(new KimaiTimesheetForm());
|
||||
kimai.registerPlugin(new KimaiTeamForm());
|
||||
kimai.registerPlugin(new KimaiCopyDataForm());
|
||||
kimai.registerPlugin(new KimaiDateNowForm());
|
||||
kimai.registerPlugin(new KimaiForm());
|
||||
kimai.registerPlugin(new KimaiHotkeys());
|
||||
|
||||
// SPECIAL FEATURES
|
||||
kimai.registerPlugin(new KimaiConfirmationLink('confirmation-link'));
|
||||
kimai.registerPlugin(new KimaiActiveRecordsDuration());
|
||||
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
|
||||
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));
|
||||
kimai.registerPlugin(new KimaiDateTimePicker('input[data-datetimepicker="on"]'));
|
||||
kimai.registerPlugin(new KimaiDatePicker('input[data-datepickerenable="on"]'));
|
||||
kimai.registerPlugin(new KimaiDatatable('section.content', 'table.dataTable'));
|
||||
kimai.registerPlugin(new KimaiToolbar('form.searchform', 'toolbar-action'));
|
||||
kimai.registerPlugin(new KimaiSelectDataAPI('select[data-related-select]'));
|
||||
kimai.registerPlugin(new KimaiAlternativeLinks('.alternative-link'));
|
||||
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form'));
|
||||
kimai.registerPlugin(new KimaiRecentActivities('li.notifications-menu'));
|
||||
kimai.registerPlugin(new KimaiActiveRecords('li.messages-menu', 'li.messages-menu-empty'));
|
||||
kimai.registerPlugin(new KimaiRecentActivities());
|
||||
kimai.registerPlugin(new KimaiActiveRecords('header .messages-menu', 'header .messages-menu-empty'));
|
||||
kimai.registerPlugin(new KimaiAPILink('api-link'));
|
||||
kimai.registerPlugin(new KimaiAutocomplete('.js-autocomplete'));
|
||||
kimai.registerPlugin(new KimaiForm());
|
||||
kimai.registerPlugin(new KimaiThemeInitializer());
|
||||
kimai.registerPlugin(new KimaiMultiUpdateTable());
|
||||
//kimai.registerPlugin(new KimaiPauseRecord('li.messages-menu ul.menu li'));
|
||||
kimai.registerPlugin(new KimaiThemeInitializer());
|
||||
|
||||
// notify all listeners that Kimai plugins can now be registered
|
||||
kimai.getPlugin('event').trigger('kimai.pluginRegister', {'kimai': kimai});
|
||||
document.dispatchEvent(new CustomEvent('kimai.pluginRegister', {detail: {'kimai': kimai}}));
|
||||
|
||||
// initialize all plugins
|
||||
kimai.getPlugins().map(plugin => { plugin.init(); });
|
||||
|
||||
// notify all listeners that Kimai is now ready to be used
|
||||
kimai.getPlugin('event').trigger('kimai.initialized', {'kimai': kimai});
|
||||
document.dispatchEvent(new CustomEvent('kimai.initialized', {detail: {'kimai': kimai}}));
|
||||
|
||||
this.kimai = kimai;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,20 @@ export default class KimaiPlugin {
|
||||
return this.getContainer().getConfiguration().get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {KimaiConfiguration}
|
||||
*/
|
||||
getConfigurations() {
|
||||
return this.getContainer().getConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {KimaiDateUtils}
|
||||
*/
|
||||
getDateUtils() {
|
||||
return this.getPlugin('date');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {KimaiPlugin}
|
||||
@@ -66,11 +80,82 @@ export default class KimaiPlugin {
|
||||
return this.getContainer().getPlugin(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {KimaiTranslation}
|
||||
*/
|
||||
getTranslation() {
|
||||
return this.getContainer().getTranslation();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
translate(name) {
|
||||
return this.getTranslation().get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
escape(title) {
|
||||
return this.getPlugin('escape').escapeForHtml(title);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string|null} details
|
||||
*/
|
||||
trigger(name, details = null) {
|
||||
this.getPlugin('event').trigger(name, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
fetch(url, options = {}) {
|
||||
return this.getPlugin('fetch').fetch(url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {object} options
|
||||
* @param {string|null} url
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
fetchForm(form, options = {}, url = null) {
|
||||
url = url || form.getAttribute('action');
|
||||
const method = form.getAttribute('method').toUpperCase();
|
||||
|
||||
if (method === 'GET') {
|
||||
const data = this.getPlugin('form').convertFormDataToQueryString(form, {}, true);
|
||||
// TODO const data = new URLSearchParams(new FormData(form)).toString();
|
||||
url = url + (url.includes('?') ? '&' : '?') + data;
|
||||
options = {...{method: 'GET'}, ...options};
|
||||
} else if (method === 'POST') {
|
||||
options = {...{
|
||||
method: 'POST',
|
||||
body: new FormData(form)
|
||||
}, ...options};
|
||||
}
|
||||
|
||||
return this.fetch(url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current device is a mobile device (targeting the bootstrip xs breakpoint size).
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMobile() {
|
||||
const width = Math.max(
|
||||
document.documentElement.clientWidth,
|
||||
window.innerWidth || 0
|
||||
)
|
||||
|
||||
return width < 576;
|
||||
}
|
||||
}
|
||||
|
||||
97
assets/js/forms/KimaiAutocomplete.js
Normal file
97
assets/js/forms/KimaiAutocomplete.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 TomSelect from 'tom-select';
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
|
||||
/**
|
||||
* Supporting auto-complete fields via API.
|
||||
* Used for timesheet tagging in toolbar and edit dialogs.
|
||||
*/
|
||||
export default class KimaiAutocomplete extends KimaiFormPlugin {
|
||||
|
||||
init()
|
||||
{
|
||||
this.selector = '[data-form-widget="autocomplete"]';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
activateForm(form)
|
||||
{
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
|
||||
const apiUrl = node.dataset['autocompleteUrl'];
|
||||
let minChars = 3;
|
||||
if (node.dataset['minimumCharacter'] !== undefined) {
|
||||
minChars = parseInt(node.dataset['minimumCharacter']);
|
||||
}
|
||||
|
||||
new TomSelect(node, {
|
||||
// if there are more than 500, they need to be found by "typing"
|
||||
maxOptions: 500,
|
||||
// the autocomplete is ONLY used, when the user can create tags
|
||||
create: node.dataset['create'] !== undefined,
|
||||
onOptionAdd: (value) => {
|
||||
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
|
||||
},
|
||||
plugins: ['remove_button'],
|
||||
shouldLoad: function(query) {
|
||||
return query.length >= minChars;
|
||||
},
|
||||
load: (query, callback) => {
|
||||
API.get(apiUrl, {'name': query}, (data) => {
|
||||
const results = [].slice.call(data).map((result) => {
|
||||
return {text: result, value: result};
|
||||
});
|
||||
callback(results);
|
||||
}, () => {
|
||||
callback();
|
||||
});
|
||||
},
|
||||
render: {
|
||||
// eslint-disable-next-line
|
||||
not_loading: (data, escape) => {
|
||||
// no default content
|
||||
},
|
||||
option_create: (data, escape) => {
|
||||
const name = escape(data.input);
|
||||
if (name.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const tpl = this.translate('select.search.create');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>')
|
||||
return '<div class="create">' + tplReplaced + '</div>';
|
||||
},
|
||||
no_results: (data, escape) => {
|
||||
const tpl = this.translate('select.search.notfound');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>')
|
||||
return '<div class="no-results">' + tplReplaced + '</div>';
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyForm(form) {
|
||||
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
|
||||
if (node.tomselect) {
|
||||
node.tomselect.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
71
assets/js/forms/KimaiCopyDataForm.js
Normal file
71
assets/js/forms/KimaiCopyDataForm.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
|
||||
*/
|
||||
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
|
||||
/**
|
||||
* Used for simple copy from link to input action, e.g. the time and duration dropdowns
|
||||
* copy the selected values into their corresponding input.
|
||||
*/
|
||||
export default class KimaiCopyDataForm extends KimaiFormPlugin {
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
activateForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
if (this._eventHandler === undefined) {
|
||||
this._eventHandler = (event) => {
|
||||
let element = event.target;
|
||||
if (!element.matches('a[data-form-widget="copy-data"]')) {
|
||||
element = element.parentNode; // mostly for icons
|
||||
}
|
||||
if (!element.matches('a[data-form-widget="copy-data"]') || element.dataset.target === undefined) {
|
||||
return;
|
||||
}
|
||||
const target = document.querySelector(element.dataset.target);
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
target.value = element.dataset.value;
|
||||
if (element.dataset.event !== undefined) {
|
||||
for (const event of element.dataset.event.split(' ')) {
|
||||
target.dispatchEvent(new Event(event));
|
||||
}
|
||||
} else if (element.dataset.eventBubbles !== undefined) {
|
||||
for (const event of element.dataset.eventBubbles.split(' ')) {
|
||||
target.dispatchEvent(new Event(event, {bubbles: true}));
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
form.addEventListener('click', this._eventHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
destroyForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
form.removeEventListener('click', this._eventHandler);
|
||||
}
|
||||
|
||||
}
|
||||
70
assets/js/forms/KimaiDateNowForm.js
Normal file
70
assets/js/forms/KimaiDateNowForm.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
|
||||
*/
|
||||
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
|
||||
/**
|
||||
*/
|
||||
export default class KimaiDateNowForm extends KimaiFormPlugin {
|
||||
|
||||
init()
|
||||
{
|
||||
this.selector = 'a[data-form-widget="date-now"]';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
activateForm(form)
|
||||
{
|
||||
[].slice.call(form.querySelectorAll(this.selector)).map((element) => {
|
||||
if (element.dataset.format !== undefined && element.dataset.target !== undefined) {
|
||||
if (this._eventHandler === undefined) {
|
||||
this._eventHandler = (event) => {
|
||||
const linkTarget = event.currentTarget;
|
||||
|
||||
const formElement = document.getElementById(linkTarget.dataset.target);
|
||||
if (!formElement.disabled) {
|
||||
formElement.value = this.getDateUtils().format(linkTarget.dataset.format, null);
|
||||
formElement.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
element.addEventListener('click', this._eventHandler);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
destroyForm(form)
|
||||
{
|
||||
[].slice.call(form.querySelectorAll(this.selector)).map((element) => {
|
||||
if (element.dataset.format !== undefined && element.dataset.target !== undefined) {
|
||||
element.removeEventListener('click', this._eventHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
113
assets/js/forms/KimaiDatePicker.js
Normal file
113
assets/js/forms/KimaiDatePicker.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 { Litepicker } from 'litepicker';
|
||||
import 'litepicker/dist/plugins/mobilefriendly';
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
|
||||
export default class KimaiDatePicker extends KimaiFormPlugin {
|
||||
|
||||
constructor(selector)
|
||||
{
|
||||
super();
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
window.disableLitepickerStyles = true;
|
||||
this._pickers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
activateForm(form)
|
||||
{
|
||||
const FIRST_DOW = this.getConfigurations().getFirstDayOfWeek(false);
|
||||
const LANGUAGE = this.getConfigurations().getLanguage();
|
||||
|
||||
let options = {
|
||||
buttonText: {
|
||||
previousMonth: `<i class="fas fa-chevron-left"></i>`,
|
||||
nextMonth: `<i class="fas fa-chevron-right"></i>`,
|
||||
apply: this.translate('confirm'),
|
||||
cancel: this.translate('cancel'),
|
||||
},
|
||||
};
|
||||
|
||||
const newPickers = [].slice.call(form.querySelectorAll(this._selector)).map((element) => {
|
||||
if (element.dataset.format === undefined) {
|
||||
console.log('Trying to bind litepicker to an element without data-format attribute');
|
||||
}
|
||||
options = {...options, ...{
|
||||
format: element.dataset.format,
|
||||
showTooltip: false,
|
||||
element: element,
|
||||
lang: LANGUAGE,
|
||||
autoRefresh: true,
|
||||
firstDay: FIRST_DOW, // Litepicker: 0 = Sunday, 1 = Monday
|
||||
setup: (picker) => {
|
||||
// nasty hack, because litepicker does not trigger change event on the input and the available
|
||||
// event "selected" is triggered why to often, even when moving the cursor inside the input
|
||||
// element (not even typing is necessary) and so we have to make sure that the manual "click" event
|
||||
// (works for touch as well) happened before we actually dispatch the change event manually ...
|
||||
// what? report forms would be submitted upon cursor move without the "preselect” check
|
||||
picker.on('preselect', (date1, date2) => { // eslint-disable-line no-unused-vars
|
||||
picker._wasPreselected = true;
|
||||
});
|
||||
picker.on('selected', (date1, date2) => { // eslint-disable-line no-unused-vars
|
||||
if (picker._wasPreselected !== undefined) {
|
||||
element.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
delete picker._wasPreselected;
|
||||
}
|
||||
});
|
||||
},
|
||||
}};
|
||||
|
||||
return [element, new Litepicker(this.prepareOptions(options))];
|
||||
});
|
||||
|
||||
this._pickers = this._pickers.concat(newPickers);
|
||||
}
|
||||
|
||||
prepareOptions(options)
|
||||
{
|
||||
return {...options, ...{
|
||||
plugins: ['mobilefriendly'],
|
||||
}};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
destroyForm(form)
|
||||
{
|
||||
[].slice.call(form.querySelectorAll(this._selector)).map((element) => {
|
||||
for (let i = 0; i < this._pickers.length; i++) {
|
||||
if (this._pickers[i][0] === element) {
|
||||
this._pickers[i][1].destroy();
|
||||
this._pickers.splice(i, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
25
assets/js/forms/KimaiDateRangePicker.js
Normal file
25
assets/js/forms/KimaiDateRangePicker.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 KimaiDatePicker from "./KimaiDatePicker";
|
||||
|
||||
export default class KimaiDateRangePicker extends KimaiDatePicker {
|
||||
|
||||
prepareOptions(options)
|
||||
{
|
||||
return {...options, ...{
|
||||
plugins: ['mobilefriendly'],
|
||||
singleMode: false,
|
||||
autoRefresh: true,
|
||||
}};
|
||||
}
|
||||
|
||||
}
|
||||
39
assets/js/forms/KimaiFormPlugin.js
Normal file
39
assets/js/forms/KimaiFormPlugin.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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] KimaiFormPlugin: base class for all none ID plugin that handle forms
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiFormPlugin extends KimaiPlugin {
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
activateForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
destroyForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
574
assets/js/forms/KimaiFormSelect.js
Normal file
574
assets/js/forms/KimaiFormSelect.js
Normal file
@@ -0,0 +1,574 @@
|
||||
/*
|
||||
* 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 HTMLSelectElement
|
||||
*/
|
||||
|
||||
import TomSelect from 'tom-select';
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
|
||||
export default class KimaiFormSelect extends KimaiFormPlugin {
|
||||
|
||||
constructor(selector, apiSelects)
|
||||
{
|
||||
super();
|
||||
this._selector = selector;
|
||||
this._apiSelects = apiSelects;
|
||||
}
|
||||
|
||||
getId()
|
||||
{
|
||||
return 'form-select';
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
// selects the original value inside dropdowns, as the "reset" event (the updated option)
|
||||
// is not automatically propagated to the JS element
|
||||
document.addEventListener('reset', (event) => {
|
||||
if (event.target.tagName.toUpperCase() === 'FORM') {
|
||||
setTimeout(() => {
|
||||
const fields = event.target.querySelectorAll(this._selector);
|
||||
for (let field of fields) {
|
||||
if (field.tagName.toUpperCase() === 'SELECT') {
|
||||
field.dispatchEvent(new Event('data-reloaded'));
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} node
|
||||
*/
|
||||
activateSelectPickerByElement(node)
|
||||
{
|
||||
let plugins = ['change_listener'];
|
||||
|
||||
const isMultiple = node.multiple !== undefined && node.multiple === true;
|
||||
|
||||
/*
|
||||
const isOrdering = false;
|
||||
if (isOrdering) {
|
||||
plugins.push('caret_position');
|
||||
plugins.push('drag_drop');
|
||||
}
|
||||
*/
|
||||
|
||||
if (isMultiple) {
|
||||
plugins.push('remove_button');
|
||||
}
|
||||
|
||||
let options = {
|
||||
lockOptgroupOrder: true,
|
||||
allowEmptyOption: true,
|
||||
plugins: plugins,
|
||||
// if there are more than X entries, the other ones are hidden and can only be found
|
||||
// by typing some characters to trigger the internal option search
|
||||
maxOptions: 500,
|
||||
};
|
||||
|
||||
let render = {
|
||||
option_create: (data, escape) => {
|
||||
const name = escape(data.input);
|
||||
if (name.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const tpl = this.translate('select.search.create');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>');
|
||||
return '<div class="create">' + tplReplaced + '</div>';
|
||||
},
|
||||
no_results: (data, escape) => {
|
||||
const tpl = this.translate('select.search.notfound');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>');
|
||||
return '<div class="no-results">' + tplReplaced + '</div>';
|
||||
},
|
||||
onOptionAdd: (value) => {
|
||||
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
|
||||
},
|
||||
};
|
||||
|
||||
if (node.dataset['create'] !== undefined) {
|
||||
options = {...options, ...{
|
||||
persist: true,
|
||||
create: true,
|
||||
}};
|
||||
} else {
|
||||
options = {...options, ...{
|
||||
persist: false,
|
||||
create: false,
|
||||
}};
|
||||
}
|
||||
|
||||
if (node.dataset.disableSearch !== undefined) {
|
||||
options = {...options, ...{
|
||||
controlInput: null,
|
||||
}};
|
||||
}
|
||||
|
||||
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
|
||||
options.render = {...render, ...{
|
||||
option: function(data, escape) {
|
||||
let color = data.value;
|
||||
if (data.color !== undefined) {
|
||||
color = data.color;
|
||||
}
|
||||
return '<div class="list-group-item border-0 p-1 ps-2 text-nowrap"><span style="background-color:' + color + '" class="color-choice-item"> </span>' + escape(data.text) + '</div>';
|
||||
},
|
||||
item: function(data, escape) {
|
||||
let color = data.value;
|
||||
if (data.color !== undefined) {
|
||||
color = data.color;
|
||||
}
|
||||
return '<div class="text-nowrap"><span style="background-color:' + color + '" class="color-choice-item"> </span>' + escape(data.text) + '</div>';
|
||||
}
|
||||
}};
|
||||
} else {
|
||||
options.render = {...render, ...{
|
||||
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
|
||||
option: function(data, escape) {
|
||||
let text = data.text;
|
||||
if (text === null || text.trim() === '') {
|
||||
text = ' ';
|
||||
} else {
|
||||
text = escape(text);
|
||||
}
|
||||
return '<div>' + text + '</div>';
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
const select = new TomSelect(node, options);
|
||||
node.addEventListener('data-reloaded', (event) => {
|
||||
select.clear(true);
|
||||
select.clearOptionGroups();
|
||||
select.clearOptions();
|
||||
select.sync();
|
||||
select.setValue(event.detail);
|
||||
select.refreshItems();
|
||||
select.refreshOptions(false);
|
||||
});
|
||||
|
||||
// support reloading the list upon external event
|
||||
if (node.dataset['reload'] !== undefined) {
|
||||
node.addEventListener('reload', () => {
|
||||
select.disable();
|
||||
node.disabled = true;
|
||||
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(node.dataset['reload'], {}, (data) => {
|
||||
this._updateSelect(node, data);
|
||||
select.enable();
|
||||
node.disabled = false;
|
||||
});
|
||||
|
||||
node.dispatchEvent(new Event('change'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form) // eslint-disable-line no-unused-vars
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
activateForm(form)
|
||||
{
|
||||
[].slice.call(form.querySelectorAll(this._selector)).map((node) => {
|
||||
this.activateSelectPickerByElement(node);
|
||||
});
|
||||
|
||||
this._activateApiSelects(this._apiSelects);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
destroyForm(form)
|
||||
{
|
||||
[].slice.call(form.querySelectorAll(this._selector)).map((node) => {
|
||||
if (node.tomselect) {
|
||||
node.tomselect.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|Element} selectIdentifier
|
||||
* @param {object} data
|
||||
* @private
|
||||
*/
|
||||
_updateOptions(selectIdentifier, data)
|
||||
{
|
||||
let emptyOption = null;
|
||||
let node = null;
|
||||
if (selectIdentifier instanceof Element) {
|
||||
node = selectIdentifier;
|
||||
} else {
|
||||
node = document.querySelector(selectIdentifier);
|
||||
}
|
||||
if (node === null) {
|
||||
console.log('Missing select: ' + selectIdentifier);
|
||||
return;
|
||||
}
|
||||
const selectedValue = node.value;
|
||||
|
||||
for (let i = 0; i < node.options.length; i++) {
|
||||
if (node.options[i].value === '') {
|
||||
emptyOption = node.options[i];
|
||||
}
|
||||
}
|
||||
|
||||
node.options.length = 0;
|
||||
|
||||
if (emptyOption !== null) {
|
||||
node.appendChild(this._createOption(emptyOption.text, ''));
|
||||
}
|
||||
|
||||
let emptyOpts = [];
|
||||
let options = [];
|
||||
/** @type {string|null} titlePattern */
|
||||
let titlePattern = null;
|
||||
if (node.dataset !== undefined && node.dataset['optionPattern'] !== undefined) {
|
||||
titlePattern = node.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);
|
||||
}
|
||||
|
||||
options.forEach(child => node.appendChild(child));
|
||||
emptyOpts.forEach(child => node.appendChild(child));
|
||||
|
||||
// if available, re-select the previous selected option (mostly usable for global activities)
|
||||
node.value = selectedValue;
|
||||
|
||||
// pre-select an option if it is the only available one
|
||||
if (node.value === '' || node.value === null) {
|
||||
const allOptions = node.options;
|
||||
const optionLength = allOptions.length;
|
||||
let selectOption = '';
|
||||
|
||||
if (optionLength === 1) {
|
||||
selectOption = allOptions[0].value;
|
||||
} else if (optionLength === 2 && emptyOption !== null) {
|
||||
selectOption = allOptions[1].value;
|
||||
}
|
||||
|
||||
if (selectOption !== '') {
|
||||
node.value = selectOption;
|
||||
}
|
||||
}
|
||||
|
||||
// this will update the attached javascript component
|
||||
node.dispatchEvent(new CustomEvent('data-reloaded', {detail: node.value}));
|
||||
// if we don't trigger the change, the other selects won't reset
|
||||
node.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pattern
|
||||
* @param {array} entity
|
||||
* @private
|
||||
*/
|
||||
_getTitleFromPattern(pattern, entity)
|
||||
{
|
||||
const DATE_UTILS = this.getDateUtils();
|
||||
const regexp = new RegExp('{[^}]*?}','g');
|
||||
let title = pattern;
|
||||
let match = null;
|
||||
|
||||
while ((match = regexp.exec(pattern)) !== null) {
|
||||
// cutting a string like "{name}" into "name"
|
||||
const field = match[0].slice(1, -1);
|
||||
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.substring(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 {HTMLSelectElement} select
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @param {object} dataset
|
||||
*/
|
||||
addOption(select, label, value, dataset)
|
||||
{
|
||||
const option = this._createOption(label, value);
|
||||
for (const key in dataset) {
|
||||
option.dataset[key] = dataset[key];
|
||||
}
|
||||
|
||||
select.options.add(option);
|
||||
if (select.tomselect !== undefined) {
|
||||
select.tomselect.sync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLSelectElement} select
|
||||
* @param {HTMLOptionElement} option
|
||||
*/
|
||||
removeOption(select, option)
|
||||
{
|
||||
option.remove();
|
||||
if (select.tomselect !== undefined) {
|
||||
select.tomselect.removeOption(option.value, true);
|
||||
select.tomselect.clear(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @private
|
||||
*/
|
||||
_activateApiSelects(selector)
|
||||
{
|
||||
if (this._eventHandlerApiSelects === undefined) {
|
||||
this._eventHandlerApiSelects = (event) => {
|
||||
if (event.target === null || !event.target.matches(selector)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiSelect = event.target;
|
||||
const targetSelectId = '#' + apiSelect.dataset['relatedSelect'];
|
||||
/** @type {HTMLSelectElement} targetSelect */
|
||||
const targetSelect = document.getElementById(apiSelect.dataset['relatedSelect']);
|
||||
|
||||
// if the related target select does not exist, we do not need to load the related data
|
||||
if (targetSelect === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetSelect.tomselect !== undefined) {
|
||||
targetSelect.tomselect.disable();
|
||||
}
|
||||
targetSelect.disabled = true;
|
||||
|
||||
let formPrefix = apiSelect.dataset['formPrefix'];
|
||||
if (formPrefix === undefined || formPrefix === null) {
|
||||
formPrefix = '';
|
||||
} else if (formPrefix.length > 0) {
|
||||
formPrefix += '_';
|
||||
}
|
||||
|
||||
let newApiUrl = this._buildUrlWithFormFields(apiSelect.dataset['apiUrl'], formPrefix);
|
||||
|
||||
const selectValue = apiSelect.value;
|
||||
|
||||
// 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 (apiSelect.dataset['emptyUrl'] === undefined) {
|
||||
this._updateSelect(targetSelectId, {});
|
||||
return;
|
||||
}
|
||||
newApiUrl = this._buildUrlWithFormFields(apiSelect.dataset['emptyUrl'], formPrefix);
|
||||
}
|
||||
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(newApiUrl, {}, (data) => {
|
||||
this._updateSelect(targetSelectId, data);
|
||||
if (targetSelect.tomselect !== undefined) {
|
||||
targetSelect.tomselect.enable();
|
||||
}
|
||||
targetSelect.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('change', this._eventHandlerApiSelects);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} apiUrl
|
||||
* @param {string} formPrefix
|
||||
* @return {string}
|
||||
* @private
|
||||
*/
|
||||
_buildUrlWithFormFields(apiUrl, formPrefix)
|
||||
{
|
||||
let newApiUrl = apiUrl;
|
||||
|
||||
apiUrl.split('?')[1].split('&').forEach(item => {
|
||||
const [key, value] = item.split('='); // eslint-disable-line no-unused-vars
|
||||
const decoded = decodeURIComponent(value);
|
||||
const test = decoded.match(/%(.*)%/);
|
||||
if (test !== null) {
|
||||
const originalFieldName = test[1];
|
||||
const targetFieldName = (formPrefix + originalFieldName).replace(/\[/, '').replace(/]/, '');
|
||||
const targetField = document.getElementById(targetFieldName);
|
||||
let newValue = '';
|
||||
if (targetField === null) {
|
||||
// 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.value !== null) {
|
||||
newValue = targetField.value;
|
||||
if (targetField.tagName === 'SELECT' && targetField.multiple) {
|
||||
newValue = [...targetField.selectedOptions].map(o => o.value);
|
||||
} else if (newValue !== '') {
|
||||
if (targetField.type === 'date') {
|
||||
const timeId = targetField.id.replace('_date', '_time')
|
||||
const timeElement = document.getElementById(timeId);
|
||||
const time = timeElement === null ? '12:00:00' : timeElement.value;
|
||||
// using 12:00 as fallback, because timezone handling might change the date if we use 00:00
|
||||
const newDate = this.getDateUtils().fromHtml5Input(newValue, time);
|
||||
newValue = this.getDateUtils().formatForAPI(newDate, false);
|
||||
} else if (targetField.type === 'text' && targetField.name.includes('date')) {
|
||||
const timeId = targetField.id.replace('_date', '_time')
|
||||
const timeElement = document.getElementById(timeId);
|
||||
// using 12:00 as fallback, because timezone handling might change the date if we use 00:00
|
||||
let time = '12:00:00';
|
||||
let timeFormat = 'HH:mm';
|
||||
if (timeElement !== null) {
|
||||
time = timeElement.value;
|
||||
timeFormat = timeElement.dataset['format'];
|
||||
}
|
||||
const newDate = this.getDateUtils().fromFormat(newValue.trim() + ' ' + time.trim(), targetField.dataset['format'] + ' ' + timeFormat);
|
||||
newValue = this.getDateUtils().formatForAPI(newDate, false);
|
||||
} else if (targetField.dataset['format'] !== undefined) {
|
||||
// find out when this else branch is triggered and document!
|
||||
|
||||
if (this.getDateUtils().isValidDateTime(newValue, targetField.dataset['format'])) {
|
||||
newValue = this.getDateUtils().format(targetField.dataset['format'], newValue);
|
||||
}
|
||||
}
|
||||
} 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)) {
|
||||
let urlParams = [];
|
||||
for (let tmpValue of newValue) {
|
||||
urlParams.push(originalFieldName + '=' + tmpValue);
|
||||
}
|
||||
newApiUrl = newApiUrl.replace(item, urlParams.join('&'));
|
||||
} else {
|
||||
newApiUrl = newApiUrl.replace(value, newValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newApiUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|Element} select
|
||||
* @param {object} data
|
||||
* @private
|
||||
*/
|
||||
_updateSelect(select, data)
|
||||
{
|
||||
const options = {};
|
||||
for (const apiData of data) {
|
||||
let title = '__empty__';
|
||||
if (apiData['parentTitle'] !== undefined && apiData['parentTitle'] !== null) {
|
||||
title = apiData['parentTitle'];
|
||||
}
|
||||
if (options[title] === undefined) {
|
||||
options[title] = [];
|
||||
}
|
||||
options[title].push(apiData);
|
||||
}
|
||||
|
||||
const ordered = {};
|
||||
Object.keys(options).sort().forEach(function(key) {
|
||||
ordered[key] = options[key];
|
||||
});
|
||||
|
||||
this._updateOptions(select, ordered);
|
||||
}
|
||||
}
|
||||
145
assets/js/forms/KimaiTeamForm.js
Normal file
145
assets/js/forms/KimaiTeamForm.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
|
||||
*/
|
||||
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
import KimaiColor from "../widgets/KimaiColor";
|
||||
|
||||
export default class KimaiTeamForm extends KimaiFormPlugin {
|
||||
|
||||
init()
|
||||
{
|
||||
this.usersId = 'team_edit_form_users';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form)
|
||||
{
|
||||
return form.name === 'team_edit_form';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
* @private
|
||||
*/
|
||||
_getPrototype()
|
||||
{
|
||||
return document.getElementById('team_edit_form_members');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
activateForm(form)
|
||||
{
|
||||
if (!this.supportsForm(form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// must be attached to the form, because the button is added dynamically
|
||||
form.addEventListener('click', event => this._removeMember(event));
|
||||
|
||||
document.getElementById(this.usersId).addEventListener('change', event => {
|
||||
const select = event.target;
|
||||
const option = select.options[select.selectedIndex];
|
||||
const member = this._createMember(option);
|
||||
this._getPrototype().append(member);
|
||||
this.getPlugin('form-select').removeOption(select, option);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLOptionElement} option
|
||||
* @returns {Element}
|
||||
* @private
|
||||
*/
|
||||
_createMember(option)
|
||||
{
|
||||
const prototype = this._getPrototype();
|
||||
let counter = prototype.dataset['widgetCounter'] || prototype.childNodes.length;
|
||||
let newWidget = prototype.dataset['prototype'];
|
||||
|
||||
newWidget = newWidget.replace(/__name__/g, counter);
|
||||
|
||||
newWidget = newWidget.replace(/#000000/g, KimaiColor.calculateContrastColor(option.dataset.color));
|
||||
newWidget = newWidget.replace(/__DISPLAY__/g, option.dataset.display);
|
||||
newWidget = newWidget.replace(/__COLOR__/g, option.dataset.color);
|
||||
newWidget = newWidget.replace(/__INITIALS__/g, option.dataset.initials);
|
||||
newWidget = newWidget.replace(/__TITLE__/g, option.dataset.title);
|
||||
newWidget = newWidget.replace(/__USERNAME__/g, option.text);
|
||||
|
||||
prototype.dataset['widgetCounter'] = (++counter).toString();
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = newWidget;
|
||||
temp.querySelector('input[type=hidden]').value = option.value;
|
||||
|
||||
const newNode = temp.firstElementChild;
|
||||
|
||||
// copy over all initial settings, so we are able to rebuild the original option if the
|
||||
// member is removed from the list later on
|
||||
for (const key in option.dataset) {
|
||||
newNode.dataset[key] = option.dataset[key];
|
||||
}
|
||||
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_removeMember(event)
|
||||
{
|
||||
let button = event.target;
|
||||
|
||||
if (button.parentNode.matches('.remove-member')) {
|
||||
button = button.parentNode;
|
||||
}
|
||||
|
||||
if (button.matches('.remove-member')) {
|
||||
// see blocks.html.twig => block team_member_widget
|
||||
const element = button.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||
|
||||
// re-adding the option to the select makes up for form validation errors
|
||||
// because the list would have to be re-ordered and indices need to be changed ...
|
||||
/*
|
||||
this.getPlugin('form-select').addOption(
|
||||
document.getElementById(this.usersId),
|
||||
element.dataset['display'],
|
||||
element.dataset['id'],
|
||||
element.dataset
|
||||
);
|
||||
const prototype = this._getPrototype();
|
||||
prototype.dataset['widgetCounter'] = (prototype.dataset['widgetCounter'] - 1).toString();
|
||||
*/
|
||||
|
||||
element.remove();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
destroyForm(form)
|
||||
{
|
||||
if (!this.supportsForm(form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.removeEventListener('click', this._removeMember);
|
||||
}
|
||||
|
||||
}
|
||||
368
assets/js/forms/KimaiTimesheetForm.js
Normal file
368
assets/js/forms/KimaiTimesheetForm.js
Normal file
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
|
||||
*/
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
|
||||
export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @return boolean
|
||||
*/
|
||||
supportsForm(form)
|
||||
{
|
||||
return (form.name === 'timesheet_edit_form' || form.name ==='timesheet_admin_edit_form' || form.name ==='timesheet_multi_user_edit_form');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
destroyForm(form)
|
||||
{
|
||||
if (!this.supportsForm(form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._beginDate !== undefined) {
|
||||
this._beginDate.removeEventListener('change', this._beginListener);
|
||||
delete this._beginListener;
|
||||
delete this._beginDate;
|
||||
}
|
||||
|
||||
if (this._beginTime !== undefined) {
|
||||
this._beginTime.removeEventListener('change', this._beginListener);
|
||||
delete this._beginTime;
|
||||
}
|
||||
|
||||
if (this._endTime !== undefined) {
|
||||
this._endTime.removeEventListener('change', this._endListener);
|
||||
delete this._endTime;
|
||||
}
|
||||
|
||||
if (this._duration !== undefined) {
|
||||
this._duration.removeEventListener('change', this._durationListener);
|
||||
delete this._durationListener;
|
||||
delete this._duration;
|
||||
}
|
||||
|
||||
if (this._durationToggle !== undefined && this._durationToggle !== null) {
|
||||
this._durationToggle.removeEventListener('change', this._durationToggleListener);
|
||||
delete this._durationToggleListener;
|
||||
delete this._durationToggle;
|
||||
}
|
||||
|
||||
if (this._activity !== undefined) {
|
||||
this._activity.removeEventListener('create', this._activityListener);
|
||||
delete this._activityListener;
|
||||
delete this._activity;
|
||||
}
|
||||
|
||||
if (this._project !== undefined) {
|
||||
delete this._project;
|
||||
}
|
||||
}
|
||||
|
||||
activateForm(form)
|
||||
{
|
||||
if (!this.supportsForm(form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formPrefix = form.name;
|
||||
|
||||
this._activity = document.getElementById(formPrefix + '_activity');
|
||||
this._project = document.getElementById(formPrefix + '_project');
|
||||
|
||||
/** @param {CustomEvent} event */
|
||||
this._activityListener = (event) => {
|
||||
const project = this._project.value;
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
API.post(this._activity.dataset['create'], {
|
||||
name: event.detail.value,
|
||||
project: (project === '' ? null : project),
|
||||
visible: true,
|
||||
}, () => {
|
||||
this._project.dispatchEvent(new Event('change'));
|
||||
});
|
||||
};
|
||||
this._activity.addEventListener('create', this._activityListener);
|
||||
|
||||
this._beginDate = document.getElementById(formPrefix + '_begin_date');
|
||||
this._beginTime = document.getElementById(formPrefix + '_begin_time');
|
||||
this._endTime = document.getElementById(formPrefix + '_end_time');
|
||||
this._duration = document.getElementById(formPrefix + '_duration');
|
||||
this._durationToggle = document.getElementById(formPrefix + '_duration_toggle');
|
||||
|
||||
if (this._beginDate === null || this._beginTime === null || this._endTime === null || this._duration === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._beginListener = () => this._changedBegin();
|
||||
this._endListener = () => this._changedEnd();
|
||||
this._durationListener = () => this._changedDuration();
|
||||
|
||||
this._beginDate.addEventListener('change', this._beginListener);
|
||||
this._beginTime.addEventListener('change', this._beginListener);
|
||||
this._endTime.addEventListener('change', this._endListener);
|
||||
this._duration.addEventListener('change', this._durationListener);
|
||||
|
||||
if (this._duration !== null && this._durationToggle !== null) {
|
||||
this._durationToggleListener = () => {
|
||||
this._durationToggle.classList.toggle('text-success');
|
||||
};
|
||||
this._durationToggle.addEventListener('click', this._durationToggleListener);
|
||||
}
|
||||
}
|
||||
|
||||
_isDurationConnected()
|
||||
{
|
||||
if (this._duration === null && this._durationToggle === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._durationToggle === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this._durationToggle.classList.contains('text-success');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {DateTime|null}
|
||||
* @private
|
||||
*/
|
||||
_getBegin()
|
||||
{
|
||||
if (this._beginDate.value === '' || this._beginTime.value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = this.getDateUtils().fromFormat(
|
||||
this._beginDate.value + ' ' + this._beginTime.value,
|
||||
this._beginDate.dataset['format'] + ' ' + this._beginTime.dataset['format'],
|
||||
);
|
||||
|
||||
if (date.invalid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {DateTime|null}
|
||||
* @private
|
||||
*/
|
||||
_getEnd()
|
||||
{
|
||||
if (this._endTime.value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let date = this.getDateUtils().fromFormat(
|
||||
DateTime.now().toFormat('yyyy-LL-dd') + ' ' + this._endTime.value,
|
||||
'yyyy-LL-dd ' + this._endTime.dataset['format'],
|
||||
);
|
||||
|
||||
const begin = this._getBegin();
|
||||
if (begin !== null) {
|
||||
date = this.getDateUtils().fromFormat(
|
||||
begin.toFormat('yyyy-LL-dd') + ' ' + this._endTime.value,
|
||||
'yyyy-LL-dd ' + this._endTime.dataset['format'],
|
||||
);
|
||||
|
||||
if (date < begin) {
|
||||
date = date.plus({days: 1});
|
||||
}
|
||||
}
|
||||
|
||||
if (date.invalid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruleset:
|
||||
* - invalid begin => skip
|
||||
* - empty end => set end to begin (only if duration > 0 = running record)
|
||||
* - invalid end => skip
|
||||
* - calculate duration
|
||||
*/
|
||||
_changedBegin()
|
||||
{
|
||||
const begin = this._getBegin();
|
||||
if (begin === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = this._getParsedDuration();
|
||||
const hasDuration = duration.as('seconds') > 0;
|
||||
const end = this._getEnd();
|
||||
|
||||
if (end === null && hasDuration) {
|
||||
this._applyDateToField(begin.plus(duration), null, this._endTime);
|
||||
} else {
|
||||
this._updateDuration();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruleset:
|
||||
* - invalid end => skip
|
||||
* - empty begin => set begin to end
|
||||
* - invalid begin => skip
|
||||
* - calculate duration
|
||||
*/
|
||||
_changedEnd()
|
||||
{
|
||||
const end = this._getEnd();
|
||||
// empty or invalid date => reset duration and stop progress
|
||||
if (end === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = this._getParsedDuration();
|
||||
const hasDuration = duration.as('seconds') > 0;
|
||||
const begin = this._getBegin();
|
||||
|
||||
if (begin === null && hasDuration) {
|
||||
this._applyDateToField(end.minus(duration), this._beginDate, this._beginTime);
|
||||
} else {
|
||||
this._updateDuration();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_updateDuration()
|
||||
{
|
||||
const begin = this._getBegin();
|
||||
const end = this._getEnd();
|
||||
let newDuration = null;
|
||||
|
||||
if (begin !== null && end !== null) {
|
||||
newDuration = end.diff(begin);
|
||||
}
|
||||
|
||||
this._setDurationAsString(newDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruleset:
|
||||
* - invalid duration => skip
|
||||
* - if begin and end are empty: set begin to now and end to duration
|
||||
* - if begin is empty and end is not empty: set begin to end minus duration
|
||||
* - if begin is not empty and end is empty and duration is > 0 (running records = 0): set end to begin plus duration
|
||||
*/
|
||||
_changedDuration()
|
||||
{
|
||||
if (!this._isDurationConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = this._getParsedDuration();
|
||||
if (!duration.isValid) {
|
||||
this._setDurationAsString(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const begin = this._getBegin();
|
||||
let end = this._getEnd();
|
||||
const seconds = duration.as('seconds');
|
||||
|
||||
if (seconds < 0) {
|
||||
end = null;
|
||||
}
|
||||
|
||||
if (begin === null && end === null) {
|
||||
const newBegin = DateTime.now();
|
||||
this._applyDateToField(newBegin, this._beginDate, this._beginTime);
|
||||
this._applyDateToField(newBegin.plus({seconds: seconds}), null, this._endTime);
|
||||
} else if (begin === null && end !== null) {
|
||||
this._applyDateToField(end.minus({seconds: seconds}), this._beginDate, this._beginTime);
|
||||
} else if (begin !== null && seconds > 0) {
|
||||
this._applyDateToField(begin.plus({seconds: seconds}), null, this._endTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the value of a duration object as human-readable string into the duration field
|
||||
*
|
||||
* @param {Duration|null} duration
|
||||
*/
|
||||
_setDurationAsString(duration)
|
||||
{
|
||||
if (!this._isDurationConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (duration === null) {
|
||||
this._duration.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!duration.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = duration.as('seconds');
|
||||
if (seconds < 0) {
|
||||
this._duration.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60);
|
||||
|
||||
if (minutes < 10) {
|
||||
minutes = '0' + minutes;
|
||||
}
|
||||
|
||||
this._duration.value = hours + ':' + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a duration object from the duration input field.
|
||||
*
|
||||
* @private
|
||||
* @return {Duration}
|
||||
*/
|
||||
_getParsedDuration()
|
||||
{
|
||||
return this.getDateUtils().parseDuration(this._duration.value.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateTime|null} dateTime
|
||||
* @param {HTMLElement|null} dateField
|
||||
* @param {HTMLElement} timeField
|
||||
* @private
|
||||
*/
|
||||
_applyDateToField(dateTime, dateField, timeField)
|
||||
{
|
||||
if (dateTime === null || dateTime.invalid) {
|
||||
dateField.value = '';
|
||||
timeField.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (dateField !== null) {
|
||||
dateField.value = this.getDateUtils().format(dateField.dataset['format'], dateTime);
|
||||
}
|
||||
timeField.value = this.getDateUtils().format(timeField.dataset['format'], dateTime);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
* [KIMAI] KimaiAPI: easy access to API methods
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiAPI extends KimaiPlugin {
|
||||
@@ -18,135 +17,162 @@ export default class KimaiAPI extends KimaiPlugin {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
_headers() {
|
||||
const headers = new Headers();
|
||||
headers.append('X-AUTH-SESSION', '1');
|
||||
headers.append('Content-Type', 'application/json');
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
get(url, data, callbackSuccess, callbackError) {
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
if (data !== undefined) {
|
||||
const params = (new URLSearchParams(data)).toString();
|
||||
if (params !== '') {
|
||||
url = url + (url.includes('?') ? '&' : '?') + params;
|
||||
}
|
||||
}
|
||||
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('An error occurred', error);
|
||||
};
|
||||
}
|
||||
|
||||
this.fetch(url, {
|
||||
method: 'GET',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
headers: this._headers()
|
||||
}).then((response) => {
|
||||
response.json().then((json) => {
|
||||
callbackSuccess(json);
|
||||
});
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
post(url, data, callbackSuccess, callbackError) {
|
||||
if (callbackError === null || callbackError === undefined) {
|
||||
callbackError = this.getPostErrorHandler();
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('action.update.error', error);
|
||||
};
|
||||
}
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
this.fetch(url, {
|
||||
method: 'POST',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
body: this._parseData(data),
|
||||
headers: this._headers()
|
||||
}).then((response) => {
|
||||
response.json().then((json) => {
|
||||
callbackSuccess(json);
|
||||
});
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
patch(url, data, callbackSuccess, callbackError) {
|
||||
if (callbackError === null || callbackError === undefined) {
|
||||
callbackError = this.getPatchErrorHandler();
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('action.update.error', error);
|
||||
};
|
||||
}
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
this.fetch(url, {
|
||||
method: 'PATCH',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
body: this._parseData(data),
|
||||
headers: this._headers()
|
||||
}).then((response) => {
|
||||
if (response.statusCode === 204) {
|
||||
callbackSuccess();
|
||||
} else {
|
||||
response.json().then((json) => {
|
||||
callbackSuccess(json);
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
delete(url, callbackSuccess, callbackError) {
|
||||
if (callbackError === null || callbackError === undefined) {
|
||||
callbackError = this.getDeleteErrorHandler();
|
||||
if (callbackError === undefined) {
|
||||
callbackError = (error) => {
|
||||
this.handleError('action.delete.error', error);
|
||||
};
|
||||
}
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
headers: {
|
||||
'X-AUTH-SESSION': true,
|
||||
'Content-Type':'application/json'
|
||||
},
|
||||
this.fetch(url, {
|
||||
method: 'DELETE',
|
||||
dataType: 'json',
|
||||
success: callbackSuccess,
|
||||
error: callbackError
|
||||
headers: this._headers()
|
||||
}).then(() => {
|
||||
callbackSuccess();
|
||||
}).catch((error) => {
|
||||
callbackError(error);
|
||||
});
|
||||
}
|
||||
|
||||
getDeleteErrorHandler() {
|
||||
const self = this;
|
||||
return function(xhr, err) {
|
||||
self.handleError('action.delete.error', xhr, err);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @param {string|object} data
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_parseData(data) {
|
||||
if (typeof data === 'object') {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
getPatchErrorHandler() {
|
||||
const self = this;
|
||||
return function(xhr, err) {
|
||||
self.handleError('action.update.error', xhr, err);
|
||||
};
|
||||
}
|
||||
|
||||
getPostErrorHandler() {
|
||||
const self = this;
|
||||
return function(xhr, err) {
|
||||
self.handleError('action.update.error', xhr, err);
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {jqXHR} xhr
|
||||
* @param {string} err
|
||||
* @param {Response} response
|
||||
*/
|
||||
handleError(message, xhr, err) {
|
||||
let resultError = err;
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
resultError = xhr.responseJSON.message;
|
||||
// find validation errors
|
||||
if (xhr.status === 400 && xhr.responseJSON.errors) {
|
||||
let collected = ['<u>' + resultError + '</u>'];
|
||||
// form errors that are not attached to a field (like extra fields)
|
||||
if (xhr.responseJSON.errors.errors) {
|
||||
for (let error of xhr.responseJSON.errors.errors) {
|
||||
collected.push(error);
|
||||
handleError(message, response) {
|
||||
if (response.headers === undefined) {
|
||||
// this can happen if someone clicks to fast and auto running
|
||||
// requests (e.g. active records) are aborted
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||
response.json().then(data => {
|
||||
let resultError = data.message;
|
||||
// find validation errors
|
||||
if (response.status === 400 && data.errors) {
|
||||
let collected = ['<u>' + resultError + '</u>'];
|
||||
// form errors that are not attached to a field (like extra fields)
|
||||
if (data.errors.errors) {
|
||||
for (let error of data.errors.errors) {
|
||||
collected.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (xhr.responseJSON.errors.children) {
|
||||
for (let field in xhr.responseJSON.errors.children) {
|
||||
let tmpField = xhr.responseJSON.errors.children[field];
|
||||
if (tmpField.hasOwnProperty('errors') && tmpField.errors.length > 0) {
|
||||
for (let error of tmpField.errors) {
|
||||
collected.push(error);
|
||||
if (data.errors.children) {
|
||||
for (let field in data.errors.children) {
|
||||
let tmpField = data.errors.children[field];
|
||||
if (tmpField.errors !== undefined && tmpField.errors.length > 0) {
|
||||
for (let error of tmpField.errors) {
|
||||
collected.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (collected.length > 0) {
|
||||
resultError = collected;
|
||||
}
|
||||
}
|
||||
if (collected.length > 0) {
|
||||
resultError = collected;
|
||||
}
|
||||
}
|
||||
} else if (xhr.status && xhr.statusText) {
|
||||
resultError = '[' + xhr.status + '] ' + xhr.statusText;
|
||||
}
|
||||
|
||||
this.getPlugin('alert').error(message, resultError);
|
||||
this.getPlugin('alert').error(message, resultError);
|
||||
|
||||
});
|
||||
} else {
|
||||
response.text().then(() => {
|
||||
const resultError = '[' + response.statusCode + '] ' + response.statusText;
|
||||
this.getPlugin('alert').error(message, resultError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,15 +22,14 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
document.addEventListener('click', function(event) {
|
||||
document.addEventListener('click', (event) => {
|
||||
let target = event.target;
|
||||
while (target !== null && !target.matches('body')) {
|
||||
if (target.classList.contains(self.selector)) {
|
||||
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
|
||||
if (target.classList.contains(this._selector)) {
|
||||
const attributes = target.dataset;
|
||||
|
||||
let url = attributes['href'];
|
||||
@@ -39,13 +38,13 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
}
|
||||
|
||||
if (attributes.question !== undefined) {
|
||||
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||
this.getContainer().getPlugin('alert').question(attributes.question, (value) => {
|
||||
if (value) {
|
||||
self._callApi(url, attributes);
|
||||
this._callApi(url, attributes);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
self._callApi(url, attributes);
|
||||
this._callApi(url, attributes);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
@@ -57,37 +56,45 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {DOMStringMap} attributes
|
||||
* @private
|
||||
*/
|
||||
_callApi(url, attributes)
|
||||
{
|
||||
const method = attributes['method'];
|
||||
const eventName = attributes['event'];
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
const eventing = this.getContainer().getPlugin('event');
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
const successHandle = function(result) {
|
||||
eventing.trigger(eventName);
|
||||
if (attributes.msgSuccess) {
|
||||
alert.success(attributes.msgSuccess);
|
||||
/** @type {KimaiEvent} EVENTS */
|
||||
const EVENTS = this.getContainer().getPlugin('event');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.getContainer().getPlugin('alert');
|
||||
const successHandle = () => {
|
||||
EVENTS.trigger(eventName);
|
||||
if (attributes['msgSuccess'] !== undefined) {
|
||||
ALERT.success(attributes['msgSuccess']);
|
||||
}
|
||||
};
|
||||
const errorHandle = function(xhr, err) {
|
||||
const errorHandle = (error) => {
|
||||
let message = 'action.update.error';
|
||||
if (attributes.msgError) {
|
||||
message = attributes.msgError;
|
||||
if (attributes['msgError'] !== undefined) {
|
||||
message = attributes['msgError'];
|
||||
}
|
||||
API.handleError(message, xhr, err);
|
||||
API.handleError(message, error);
|
||||
};
|
||||
|
||||
if (method === 'PATCH') {
|
||||
let data = {};
|
||||
if (attributes.payload) {
|
||||
data = attributes.payload;
|
||||
if (attributes['payload'] !== undefined) {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
API.patch(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'POST') {
|
||||
let data = {};
|
||||
if (attributes.payload) {
|
||||
data = attributes.payload;
|
||||
if (attributes['payload'] !== undefined) {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
API.post(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'DELETE') {
|
||||
|
||||
@@ -15,8 +15,8 @@ export default class KimaiActiveRecords extends KimaiPlugin {
|
||||
|
||||
constructor(selector, selectorEmpty) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this.selectorEmpty = selectorEmpty;
|
||||
this._selector = selector;
|
||||
this._selectorEmpty = selectorEmpty;
|
||||
}
|
||||
|
||||
getId() {
|
||||
@@ -24,101 +24,146 @@ export default class KimaiActiveRecords extends KimaiPlugin {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.menu = document.querySelector(this.selector);
|
||||
this._menu = document.querySelector(this._selector);
|
||||
|
||||
// the menu can be hidden if user has no permissions to see it
|
||||
if (this.menu === null) {
|
||||
if (this._menu === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attributes = this.menu.dataset;
|
||||
this.attributes = this._menu.dataset;
|
||||
|
||||
const self = this;
|
||||
const handle = function() { self.reloadActiveRecords(); };
|
||||
const handleUpdate = () => {
|
||||
this.reloadActiveRecords();
|
||||
};
|
||||
|
||||
document.addEventListener('kimai.timesheetUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.timesheetDelete', handleUpdate);
|
||||
document.addEventListener('kimai.activityUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.activityDelete', handleUpdate);
|
||||
document.addEventListener('kimai.projectUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.projectDelete', handleUpdate);
|
||||
document.addEventListener('kimai.customerUpdate', handleUpdate);
|
||||
document.addEventListener('kimai.customerDelete', handleUpdate);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle duration in the visible UI
|
||||
this._updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
|
||||
this._updateDuration();
|
||||
const handle = () => {
|
||||
this._updateDuration();
|
||||
};
|
||||
this._updatesHandler = setInterval(handle, 10000);
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
document.addEventListener('kimai.timesheetDelete', handle);
|
||||
document.addEventListener('kimai.activityUpdate', handle);
|
||||
document.addEventListener('kimai.activityDelete', handle);
|
||||
document.addEventListener('kimai.projectUpdate', handle);
|
||||
document.addEventListener('kimai.projectDelete', handle);
|
||||
document.addEventListener('kimai.customerUpdate', handle);
|
||||
document.addEventListener('kimai.customerDelete', handle);
|
||||
document.addEventListener('kimai.reloadedContent', handle);
|
||||
}
|
||||
|
||||
_toggleMenu(hasEntries) {
|
||||
this.menu.style.display = hasEntries ? 'inline-block' : 'none';
|
||||
// TODO we could unregister all handler and listener
|
||||
// _unregisterHandler() {
|
||||
// clearInterval(this._updatesHandler);
|
||||
// }
|
||||
|
||||
_updateDuration() {
|
||||
const activeRecords = this._menu.querySelectorAll('[data-since]:not([data-since=""])');
|
||||
|
||||
if (activeRecords.length === 0) {
|
||||
if (this._updateBrowserTitle) {
|
||||
if (document.body.dataset['title'] === undefined) {
|
||||
this._updateBrowserTitle = false;
|
||||
} else {
|
||||
document.title = document.body.dataset['title'];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const DATE = this.getDateUtils();
|
||||
let durations = [];
|
||||
|
||||
for (const record of activeRecords) {
|
||||
const duration = DATE.formatDuration(record.dataset['since']);
|
||||
// only use the ones from the menu for the title
|
||||
if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
|
||||
durations.push(duration);
|
||||
}
|
||||
// but update all on the page (running entries in list pages)
|
||||
record.textContent = duration;
|
||||
}
|
||||
|
||||
if (durations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._updateBrowserTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let title = durations.shift();
|
||||
for (const duration of durations.slice(0, 2)) {
|
||||
title += ' | ' + duration;
|
||||
}
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
_setEntries(entries) {
|
||||
const hasEntries = entries.length > 0;
|
||||
|
||||
this._menu.style.display = hasEntries ? 'inline-block' : 'none';
|
||||
if (!hasEntries) {
|
||||
// make sure that template entries in the menu are removed, otherwise they
|
||||
// might still be shown in the browsers title
|
||||
for (let record of this.menu.querySelectorAll('[data-since]')) {
|
||||
for (let record of this._menu.querySelectorAll('[data-since]')) {
|
||||
record.dataset['since'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
const menuEmpty = document.querySelector(this.selectorEmpty);
|
||||
const menuEmpty = document.querySelector(this._selectorEmpty);
|
||||
if (menuEmpty !== null) {
|
||||
menuEmpty.style.display = !hasEntries ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
setEntries(entries) {
|
||||
this._toggleMenu(entries.length > 0);
|
||||
const stop = this._menu.querySelector('.ticktac-stop');
|
||||
|
||||
const template = this.menu.querySelector('[data-template="active-record"]');
|
||||
|
||||
const label = this.menu.querySelector('a > span.label');
|
||||
if (label !== null) {
|
||||
label.innerText = entries.length === 0 ? '' : entries.length;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
if (!hasEntries) {
|
||||
if (stop) {
|
||||
stop.accesskey = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (template === null) {
|
||||
this._replaceInNode(this.menu, entries[0]);
|
||||
} else {
|
||||
const container = template.parentElement;
|
||||
container.innerHTML = '';
|
||||
|
||||
for (let timesheet of entries) {
|
||||
const newNode = template.cloneNode(true);
|
||||
container.appendChild(this._replaceInNode(newNode, timesheet));
|
||||
}
|
||||
if (stop) {
|
||||
stop.accesskey = 's';
|
||||
}
|
||||
|
||||
this.getContainer().getPlugin('timesheet-duration').updateRecords();
|
||||
this._replaceInNode(this._menu, entries[0]);
|
||||
this._updateDuration();
|
||||
}
|
||||
|
||||
_replaceInNode(node, timesheet) {
|
||||
const date = this.getContainer().getPlugin('date');
|
||||
const date = this.getDateUtils();
|
||||
const allReplacer = node.querySelectorAll('[data-replacer]');
|
||||
for (let node of allReplacer) {
|
||||
const replacerName = node.dataset['replacer'];
|
||||
for (let link of allReplacer) {
|
||||
const replacerName = link.dataset['replacer'];
|
||||
if (replacerName === 'url') {
|
||||
node.href = this.attributes['href'].replace('000', timesheet.id);
|
||||
link.href = this.attributes['href'].replace('000', timesheet.id);
|
||||
} else if (replacerName === 'activity') {
|
||||
node.innerText = timesheet.activity.name;
|
||||
link.innerText = timesheet.activity.name;
|
||||
} else if (replacerName === 'project') {
|
||||
node.innerText = timesheet.project.name;
|
||||
link.innerText = timesheet.project.name;
|
||||
} else if (replacerName === 'customer') {
|
||||
node.innerText = timesheet.project.customer.name;
|
||||
link.innerText = timesheet.project.customer.name;
|
||||
} else if (replacerName === 'duration') {
|
||||
node.dataset['since'] = timesheet.begin;
|
||||
node.innerText = date.formatDuration(timesheet.duration);
|
||||
link.dataset['since'] = timesheet.begin;
|
||||
link.innerText = date.formatDuration(timesheet.duration);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
reloadActiveRecords() {
|
||||
const self = this;
|
||||
const API= this.getContainer().getPlugin('api');
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(this.attributes['api'], {}, function(result) {
|
||||
self.setEntries(result);
|
||||
API.get(this.attributes['api'], {}, (result) => {
|
||||
this._setEntries(result);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiActiveRecordsDuration: activate the updates for all active timesheet records on this page
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
return 'timesheet-duration';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
|
||||
this.updateRecords();
|
||||
const self = this;
|
||||
const handle = function() { self.updateRecords(); };
|
||||
this._updatesHandler = setInterval(handle, 10000);
|
||||
// this will probably not work as expected, as other event-handler might need longer to update the DOM
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
}
|
||||
|
||||
unregisterUpdates() {
|
||||
clearInterval(this._updatesHandler);
|
||||
}
|
||||
|
||||
updateRecords() {
|
||||
let durations = [];
|
||||
const activeRecords = document.querySelectorAll('[data-since]:not([data-since=""])');
|
||||
|
||||
if (activeRecords.length === 0) {
|
||||
if (this.updateBrowserTitle) {
|
||||
if (document.body.dataset['title'] === undefined) {
|
||||
this.updateBrowserTitle = false;
|
||||
} else {
|
||||
document.title = document.body.dataset['title'];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const DATE = this.getPlugin('date');
|
||||
|
||||
for (let record of activeRecords) {
|
||||
const since = record.dataset['since'];
|
||||
const duration = DATE.formatDuration(since);
|
||||
// only use the ones from the menu for the title
|
||||
if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
|
||||
durations.push(duration);
|
||||
}
|
||||
// but update all on the page (running entries in list pages)
|
||||
record.textContent = duration;
|
||||
}
|
||||
|
||||
if (durations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.updateBrowserTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let title = durations.shift();
|
||||
let prefix = ' | ';
|
||||
|
||||
for (let duration of durations.slice(0, 2)) {
|
||||
title += prefix + duration;
|
||||
}
|
||||
document.title = title;
|
||||
}
|
||||
}
|
||||
@@ -12,70 +12,87 @@
|
||||
* opening a modal with the content from the URL given in the elements 'data-href' or 'href' attribute
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiReducedClickHandler from "./KimaiReducedClickHandler";
|
||||
import { Modal } from 'bootstrap';
|
||||
|
||||
export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'modal';
|
||||
}
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
this.isDirty = false;
|
||||
init()
|
||||
{
|
||||
this._isDirty = false;
|
||||
|
||||
this.modal = jQuery('#remote_form_modal');
|
||||
this.modal
|
||||
.on('hide.bs.modal', function (e) {
|
||||
if (self.isDirty) {
|
||||
if (jQuery('#remote_form_modal .modal-body .remote_modal_is_dirty_warning').length === 0) {
|
||||
const msg = self.getContainer().getTranslation().get('modal.dirty');
|
||||
jQuery('#remote_form_modal .modal-body').prepend('<p class="'+(self.modal.hasClass('modal-danger') ? 'well well-sm ' : '') + 'text-danger small remote_modal_is_dirty_warning">' + msg + '</p>');
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
const modalElement = this._getModalElement();
|
||||
if (modalElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalElement.addEventListener('hide.bs.modal', (event) => {
|
||||
if (this._isDirty) {
|
||||
if (modalElement.querySelector('.modal-body .remote_modal_is_dirty_warning') === null) {
|
||||
const msg = this.translate('modal.dirty');
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = '<p class="text-danger small remote_modal_is_dirty_warning">' + msg + '</p>';
|
||||
modalElement.querySelector('.modal-body').prepend(temp.firstElementChild);
|
||||
}
|
||||
jQuery(self._getFormIdentifier()).off('change', self._isDirtyHandler);
|
||||
self.isDirty = false;
|
||||
self.getContainer().getPlugin('event').trigger('modal-hide');
|
||||
})
|
||||
.on('hidden.bs.modal', function () {
|
||||
// kill all references, so GC can kick in
|
||||
self.getContainer().getPlugin('form').destroyForm(self._getFormIdentifier());
|
||||
jQuery('#remote_form_modal .modal-body').replaceWith('');
|
||||
})
|
||||
.on('show.bs.modal', function () {
|
||||
self.getContainer().getPlugin('event').trigger('modal-show');
|
||||
});
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this._isDirty = false;
|
||||
document.dispatchEvent(new Event('modal-hide'));
|
||||
});
|
||||
|
||||
this._addClickHandler(this.selector, function(href) {
|
||||
self.openUrlInModal(href);
|
||||
modalElement.addEventListener('hidden.bs.modal', () => {
|
||||
// kill all references, so GC can kick in
|
||||
this.getContainer().getPlugin('form').destroyForm(this._getFormIdentifier());
|
||||
modalElement.querySelector('.modal-body').replaceWith('');
|
||||
});
|
||||
|
||||
modalElement.addEventListener('show.bs.modal', () => {
|
||||
document.dispatchEvent(new Event('modal-show'));
|
||||
});
|
||||
|
||||
this.addClickHandler(this._selector, (href) => {
|
||||
this.openUrlInModal(href);
|
||||
});
|
||||
}
|
||||
|
||||
openUrlInModal(url, errorHandler) {
|
||||
const self = this;
|
||||
_getModal()
|
||||
{
|
||||
return Modal.getOrCreateInstance(this._getModalElement())
|
||||
}
|
||||
|
||||
if (errorHandler === undefined) {
|
||||
errorHandler = function(xhr, err) {
|
||||
if (xhr.status === undefined || xhr.status !== 403) {
|
||||
window.location = url;
|
||||
}
|
||||
};
|
||||
}
|
||||
openUrlInModal(url)
|
||||
{
|
||||
const headers = new Headers();
|
||||
headers.append('X-Requested-With', 'Kimai-Modal');
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
success: function(html) {
|
||||
self._openFormInModal(html);
|
||||
},
|
||||
error: errorHandler
|
||||
this.fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
headers: headers
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
window.location = url;
|
||||
return;
|
||||
}
|
||||
|
||||
return response.text().then(html => {
|
||||
this._openFormInModal(html);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
window.location = url;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,157 +102,185 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_getFormIdentifier() {
|
||||
_getFormIdentifier()
|
||||
{
|
||||
return '#remote_form_modal .modal-content form';
|
||||
}
|
||||
|
||||
_openFormInModal(html) {
|
||||
const self = this;
|
||||
/**
|
||||
* @returns {HTMLElement|null}
|
||||
* @private
|
||||
*/
|
||||
_getModalElement()
|
||||
{
|
||||
return document.getElementById('remote_form_modal');
|
||||
}
|
||||
|
||||
let formIdentifier = this._getFormIdentifier();
|
||||
// if any of these is found in a response, the form will be re-displayed
|
||||
let flashErrorIdentifier = 'div.alert-error';
|
||||
// messages to show above the form
|
||||
let flashMessageIdentifier = 'div.alert';
|
||||
let form = jQuery(formIdentifier);
|
||||
let remoteModal = this.modal;
|
||||
/**
|
||||
* @param {Element|ChildNode} node
|
||||
* @returns {Element}
|
||||
* @private
|
||||
*/
|
||||
_makeScriptExecutable(node) {
|
||||
if (node.tagName !== undefined && node.tagName === 'SCRIPT') {
|
||||
const script = document.createElement('script');
|
||||
script.text = node.innerHTML;
|
||||
node.parentNode.replaceChild(script, node);
|
||||
} else {
|
||||
for (const child of node.childNodes) {
|
||||
this._makeScriptExecutable(child);
|
||||
}
|
||||
}
|
||||
|
||||
// will be (re-)activated later
|
||||
form.off('submit');
|
||||
return node;
|
||||
}
|
||||
|
||||
_openFormInModal(html)
|
||||
{
|
||||
const formIdentifier = this._getFormIdentifier();
|
||||
let remoteModal = this._getModalElement();
|
||||
const newFormHtml = document.createElement('div');
|
||||
newFormHtml.innerHTML = html;
|
||||
const newModalContent = this._makeScriptExecutable(newFormHtml.querySelector('#form_modal .modal-content'));
|
||||
|
||||
// load new form from given content
|
||||
if (jQuery(html).find('#form_modal .modal-content').length > 0) {
|
||||
// Support changing modal importance/types
|
||||
remoteModal.on('hidden.bs.modal', function () {
|
||||
if (remoteModal.hasClass('modal-danger')) {
|
||||
remoteModal.removeClass('modal-danger');
|
||||
}
|
||||
});
|
||||
|
||||
if (jQuery(html).find('#form_modal').hasClass('modal-danger')) {
|
||||
remoteModal.addClass('modal-danger');
|
||||
}
|
||||
|
||||
if (newModalContent !== null) {
|
||||
// Support changing modal sizes
|
||||
let modalDialog = remoteModal.find('.modal-dialog');
|
||||
let largeModal = jQuery(html).find('.modal-dialog').hasClass('modal-lg');
|
||||
if (largeModal && !modalDialog.hasClass('modal-lg')) {
|
||||
modalDialog.addClass('modal-lg');
|
||||
}
|
||||
if (!largeModal && modalDialog.hasClass('modal-lg')) {
|
||||
modalDialog.removeClass('modal-lg');
|
||||
let modalDialog = remoteModal.querySelector('.modal-dialog');
|
||||
let largeModal = newFormHtml.querySelector('.modal-dialog').classList.contains('modal-lg');
|
||||
|
||||
if (largeModal && !modalDialog.classList.contains('modal-lg')) {
|
||||
modalDialog.classList.toggle('modal-lg');
|
||||
}
|
||||
|
||||
jQuery('#remote_form_modal .modal-content').replaceWith(
|
||||
jQuery(html).find('#form_modal .modal-content')
|
||||
);
|
||||
if (!largeModal && modalDialog.classList.contains('modal-lg')) {
|
||||
modalDialog.classList.toggle('modal-lg');
|
||||
}
|
||||
|
||||
jQuery('#remote_form_modal [data-dismiss=modal]').on('click', function() {
|
||||
self.isDirty = false;
|
||||
remoteModal.querySelector('.modal-content').replaceWith(newModalContent);
|
||||
[].slice.call(remoteModal.querySelectorAll('[data-bs-dismiss="modal"]')).map((element) => {
|
||||
element.addEventListener('click', () => {
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
});
|
||||
});
|
||||
|
||||
// activate new loaded widgets
|
||||
self.getContainer().getPlugin('form').activateForm(formIdentifier);
|
||||
this.getContainer().getPlugin('form').activateForm(formIdentifier);
|
||||
}
|
||||
|
||||
// show error flash messages
|
||||
let flashMessages = jQuery(html).find(flashMessageIdentifier);
|
||||
if (flashMessages.length > 0) {
|
||||
jQuery('#remote_form_modal .modal-body').prepend(flashMessages);
|
||||
let flashMessages = newFormHtml.querySelector('div.alert');
|
||||
if (flashMessages !== null) {
|
||||
remoteModal.querySelector('.modal-body').prepend(flashMessages);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// a fix for firefox focus problems with datepicker in modal
|
||||
// see https://github.com/kimai/kimai/issues/618
|
||||
let enforceModalFocusFn = jQuery.fn.modal.Constructor.prototype.enforceFocus;
|
||||
jQuery.fn.modal.Constructor.prototype.enforceFocus = function() {};
|
||||
remoteModal.on('hidden.bs.modal', function () {
|
||||
jQuery.fn.modal.Constructor.prototype.enforceFocus = enforceModalFocusFn;
|
||||
});
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
remoteModal.modal('show');
|
||||
|
||||
// the new form that was loaded via ajax
|
||||
form = jQuery(formIdentifier);
|
||||
const form = document.querySelector(formIdentifier);
|
||||
|
||||
this._isDirtyHandler = function(e) {
|
||||
self.isDirty = true;
|
||||
}
|
||||
form.on('change', this._isDirtyHandler);
|
||||
form.addEventListener('change', () => {
|
||||
this._isDirty = true;
|
||||
});
|
||||
|
||||
// click handler for modal save button, to send forms via ajax
|
||||
form.on('submit', function(event) {
|
||||
// if the form has a target, we let the normal HTML flow happen
|
||||
if (form.attr('target') !== undefined) {
|
||||
return true;
|
||||
}
|
||||
form.addEventListener('submit', this._getEventHandler());
|
||||
|
||||
// otherwise we do some AJAX magic to process the form in the background
|
||||
const btn = jQuery(formIdentifier + ' button[type=submit]').button('loading');
|
||||
const eventName = form.attr('data-form-event');
|
||||
const events = self.getContainer().getPlugin('event');
|
||||
const alert = self.getContainer().getPlugin('alert');
|
||||
this._getModal().show();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
_getEventHandler()
|
||||
{
|
||||
if (this.eventHandler === undefined) {
|
||||
this.eventHandler = (event) => {
|
||||
const form = event.target;
|
||||
|
||||
jQuery.ajax({
|
||||
url: form.attr('action'),
|
||||
type: form.attr('method'),
|
||||
data: form.serialize(),
|
||||
success: function(html) {
|
||||
btn.button('reset');
|
||||
let hasFieldError = jQuery(html).find('#form_modal .modal-content .has-error').length > 0;
|
||||
let hasFormError = jQuery(html).find('#form_modal .modal-content ul.list-unstyled li.text-danger').length > 0;
|
||||
let hasFlashError = jQuery(html).find(flashErrorIdentifier).length > 0;
|
||||
|
||||
if (hasFieldError || hasFormError || hasFlashError) {
|
||||
self._openFormInModal(html);
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
|
||||
// try to find form defined messages first ...
|
||||
let msg = form.attr('data-msg-success');
|
||||
if (msg === null || msg === undefined) {
|
||||
// ... but if none was available, check the response to find server rendered flash-message
|
||||
let flashMessage = jQuery(html).find('section.content div.row div.alert.alert-success');
|
||||
if (flashMessage.length > 0) {
|
||||
let flashContent = flashMessage.contents();
|
||||
if (flashContent.length === 3) {
|
||||
msg = flashContent[2].textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... and if even that is not available, we use a generic fallback message
|
||||
if (msg === null || msg === undefined) {
|
||||
msg = 'action.update.success';
|
||||
}
|
||||
self.isDirty = false;
|
||||
remoteModal.modal('hide');
|
||||
alert.success(msg);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
error: function(xhr, err) {
|
||||
let message = form.attr('data-msg-error');
|
||||
if (message === null || message === undefined) {
|
||||
message = 'action.update.error';
|
||||
}
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
err = xhr.responseJSON.message;
|
||||
} else if (xhr.status && xhr.statusText) {
|
||||
err = '[' + xhr.status +'] ' + xhr.statusText;
|
||||
}
|
||||
alert.error(message, err);
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
setTimeout(function() {
|
||||
btn.button('reset');
|
||||
}, 1500);
|
||||
// if the form has a target, we let the normal HTML flow happen
|
||||
if (form.target !== undefined && form.target !== '') {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// otherwise we do some AJAX magic to process the form in the background
|
||||
/** @type {HTMLButtonElement} btn */
|
||||
const btn = document.querySelector(this._getFormIdentifier() + ' button[type=submit]');
|
||||
btn.textContent = btn.textContent + ' …';
|
||||
btn.disabled = true;
|
||||
|
||||
const eventName = form.dataset['formEvent'];
|
||||
/** @type {KimaiEvent} alert */
|
||||
const events = this.getContainer().getPlugin('event');
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('X-Requested-With', 'Kimai-Modal');
|
||||
const options = {headers: headers};
|
||||
|
||||
this.fetchForm(form, options)
|
||||
.then(response => {
|
||||
response.text().then((html) => {
|
||||
/** @type {HTMLDivElement} responseHtml */
|
||||
const responseHtml = document.createElement('div');
|
||||
responseHtml.innerHTML = html;
|
||||
let hasFieldError = false;
|
||||
let hasFormError = false;
|
||||
let hasFlashError = false;
|
||||
|
||||
// button must be re-enabled anyway
|
||||
btn.textContent = btn.textContent.replace(' …', '');
|
||||
btn.disabled = false;
|
||||
|
||||
// if the request was successful, there will be no form
|
||||
/** @type {Element} modalContent */
|
||||
const modalContent = responseHtml.querySelector('#form_modal .modal-content');
|
||||
if (modalContent !== null) {
|
||||
hasFieldError = modalContent.querySelector('.is-invalid') !== null;
|
||||
if (!hasFieldError) {
|
||||
// happens when an error occurs for a "hidden or non-classical" form element e.g. creating team without users
|
||||
hasFieldError = modalContent.querySelector('.invalid-feedback') !== null;
|
||||
}
|
||||
hasFormError = modalContent.querySelector('ul.list-unstyled li.text-danger') !== null;
|
||||
hasFlashError = responseHtml.querySelector('div.alert-danger') !== null;
|
||||
}
|
||||
|
||||
if (hasFieldError || hasFormError || hasFlashError) {
|
||||
this._openFormInModal(html);
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
|
||||
// try to find form defined message first, but
|
||||
let msg = form.dataset['msgSuccess'];
|
||||
// if that is not available: use a generic fallback message
|
||||
if (msg === null || msg === undefined || msg === '') {
|
||||
msg = 'action.update.success';
|
||||
}
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
alert.success(msg);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
let message = form.dataset['msgError'];
|
||||
if (message === null || message === undefined || message === '') {
|
||||
message = 'action.update.error';
|
||||
}
|
||||
|
||||
alert.error(message, error.message);
|
||||
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
setTimeout(() =>{
|
||||
// critical error, allow to re-submit?
|
||||
btn.textContent = btn.textContent.replace(' …', '');
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return this.eventHandler;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,108 +9,261 @@
|
||||
* [KIMAI] KimaiAlert: notifications for Kimai
|
||||
*/
|
||||
|
||||
import Swal from 'sweetalert2'
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import {Modal, Toast} from "bootstrap";
|
||||
|
||||
export default class KimaiAlert extends KimaiPlugin {
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
getId() {
|
||||
return 'alert';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @param {string|array} message
|
||||
* @param {string|array|undefined} message
|
||||
*/
|
||||
error(title, message) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
const translation = this.getTranslation();
|
||||
if (translation.has(title)) {
|
||||
title = translation.get(title);
|
||||
}
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
title = title.replace('%reason%', '');
|
||||
|
||||
if (message === undefined) {
|
||||
message = null;
|
||||
}
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: title.replace('%reason%', ''),
|
||||
html: message.join('<br>'),
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: title.replace('%reason%', ''),
|
||||
text: message,
|
||||
});
|
||||
if (message !== null) {
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
if (Array.isArray(message)) {
|
||||
message = message.join('<br>');
|
||||
}
|
||||
}
|
||||
|
||||
const id = 'alert_global_error';
|
||||
const oldModalElement = document.getElementById(id);
|
||||
if (oldModalElement !== null) {
|
||||
Modal.getOrCreateInstance(oldModalElement).hide();
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="modal modal-blur fade" id="` + id + `" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-status bg-` + this._mapClass('danger') + `"></div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="fas fa-exclamation-circle fa-3x mb-3 text-danger"></i>
|
||||
<h2>` + title + `</h2>
|
||||
` + (message !== null ? '<div class="text-muted">' + message + '</div>' : '') + `
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="w-100">
|
||||
<div class="row">
|
||||
<div class="col text-center"><a href="#" class="btn btn-primary" data-bs-dismiss="modal">` + translation.get('close') + `</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._showModal(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
warning(message) {
|
||||
this._show('warning', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
success(message) {
|
||||
this._toast('success', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
info(message) {
|
||||
this._show('info', message);
|
||||
}
|
||||
|
||||
_show(type, message) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
/**
|
||||
* @param {string} html
|
||||
* @private
|
||||
*/
|
||||
_showModal(html) {
|
||||
const container = document.body;
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html.trim();
|
||||
const element = template.content.firstChild;
|
||||
container.appendChild(element);
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
icon: type,
|
||||
title: message,
|
||||
});
|
||||
}
|
||||
|
||||
_toast(type, message) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
timer: 2000,
|
||||
timerProgressBar: true,
|
||||
toast: true,
|
||||
position: 'top',
|
||||
showConfirmButton: false,
|
||||
icon: type,
|
||||
title: message,
|
||||
const modal = new Modal(element);
|
||||
element.addEventListener('hidden.bs.modal', function () {
|
||||
container.removeChild(element);
|
||||
});
|
||||
modal.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback receives a value and needs to decide what should happen with it
|
||||
* @param {string} type
|
||||
* @param {string} message
|
||||
* @private
|
||||
*/
|
||||
_show(type, message) {
|
||||
const translation = this.getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="modal modal-blur fade" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-status bg-` + this._mapClass(type) + `"></div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="fas fa-exclamation-circle fa-3x mb-3 text-` + this._mapClass(type) + `"></i>
|
||||
<h2>` + message + `</h2>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="w-100">
|
||||
<div class="row">
|
||||
<div class="col text-center"><a href="#" class="btn btn-primary" data-bs-dismiss="modal">` + translation.get('close') + `</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._showModal(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @return {string}
|
||||
* @private
|
||||
*/
|
||||
_mapClass(type) {
|
||||
if (type === 'info' || type === 'success' || type === 'warning' || type === 'danger') {
|
||||
return type;
|
||||
} else if (type === 'error') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param type
|
||||
* @param message
|
||||
* @private
|
||||
*/
|
||||
_toast(type, message) {
|
||||
const translation = this.getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
let icon = '<i class="fas fa-info me-2"></i>';
|
||||
|
||||
if (type === 'success') {
|
||||
icon = '<i class="fas fa-check me-2"></i>';
|
||||
} else if (type === 'warning') {
|
||||
icon = '<i class="fas fa-exclamation me-2"></i>';
|
||||
} else if (type === 'danger' || type === 'error') {
|
||||
icon = '<i class="fas fa-exclamation-circle me-2"></i>';
|
||||
}
|
||||
|
||||
const html =
|
||||
`<div class="toast align-items-center text-white bg-` + this._mapClass(type) + ` border-0" data-bs-delay="2000" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
` + icon + ' ' + message + `
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="` + translation.get('close') + `"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const container = document.getElementById('toast-container');
|
||||
const template = document.createElement('template');
|
||||
|
||||
template.innerHTML = html.trim();
|
||||
const element = template.content.firstChild;
|
||||
container.appendChild(element);
|
||||
|
||||
const toast = new Toast(element);
|
||||
element.addEventListener('hidden.bs.toast', function () {
|
||||
container.removeChild(element);
|
||||
})
|
||||
toast.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback receives a bool value (true = confirm, false = cancel / close without action).
|
||||
*
|
||||
* @param message
|
||||
* @param callback
|
||||
*/
|
||||
question(message, callback) {
|
||||
const translation = this.getContainer().getTranslation();
|
||||
const translation = this.getTranslation();
|
||||
|
||||
if (translation.has(message)) {
|
||||
message = translation.get(message);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: message,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: translation.get('confirm'),
|
||||
cancelButtonText: translation.get('cancel')
|
||||
}).then((result) => {
|
||||
callback(result.value);
|
||||
});
|
||||
}
|
||||
const css = this._mapClass('info');
|
||||
const html = `
|
||||
<div class="modal modal-blur fade" tabindex="-1" role="dialog" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-status bg-` + css + `"></div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="fas fa-question fa-3x mb-3 text-` + css + `"></i>
|
||||
<h2>` + message + `</h2>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="w-100">
|
||||
<div class="row">
|
||||
<div class="col"><a href="#" class="question-confirm btn btn-primary w-100" data-bs-dismiss="modal">` + translation.get('confirm') + `</a></div>
|
||||
<div class="col"><a href="#" class="question-cancel btn w-100" data-bs-dismiss="modal">` + translation.get('cancel') + `</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.body;
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html.trim();
|
||||
const element = template.content.firstChild;
|
||||
container.appendChild(element);
|
||||
element.querySelector('.question-confirm').addEventListener('click', () => {
|
||||
callback(true);
|
||||
});
|
||||
element.querySelector('.question-cancel').addEventListener('click', () => {
|
||||
callback(false);
|
||||
});
|
||||
|
||||
const modal = new Modal(element);
|
||||
element.addEventListener('hidden.bs.modal', () => {
|
||||
container.removeChild(element);
|
||||
});
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,17 @@
|
||||
* redirecting to the URL given in the elements 'data-href' or 'href' attribute
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiReducedClickHandler from "./KimaiReducedClickHandler";
|
||||
|
||||
export default class KimaiAlternativeLinks extends KimaiReducedClickHandler {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._addClickHandler(this.selector, function(href) {
|
||||
this.addClickHandler(this._selector, function(href) {
|
||||
window.location = href;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
/**
|
||||
* Supporting auto-complete fields via API.
|
||||
* Currently used for timesheet tagging in toolbar and edit dialogs.
|
||||
*/
|
||||
export default class KimaiAutocomplete extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.minChars = this.getConfiguration('autoComplete');
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'autocomplete';
|
||||
}
|
||||
|
||||
splitTagList(val) {
|
||||
return val.split(/,\s*/);
|
||||
}
|
||||
|
||||
extractLastTag(term) {
|
||||
return this.splitTagList(term).pop();
|
||||
}
|
||||
|
||||
activateAutocomplete(selector) {
|
||||
const self = this;
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
const currentField = jQuery(this);
|
||||
const apiUrl = currentField.attr('data-autocomplete-url');
|
||||
const API = self.getContainer().getPlugin('api');
|
||||
|
||||
currentField
|
||||
// don't navigate away from the field on tab when selecting an item
|
||||
.on("keydown", function (event) {
|
||||
if (event.keyCode === jQuery.ui.keyCode.TAB &&
|
||||
jQuery(this).autocomplete("instance").menu.active) {
|
||||
event.preventDefault();
|
||||
}
|
||||
})
|
||||
.autocomplete({
|
||||
source: function (request, response) {
|
||||
const lastEntry = self.extractLastTag(request.term);
|
||||
API.get(apiUrl, {'name': lastEntry}, function(data){
|
||||
response(data);
|
||||
});
|
||||
},
|
||||
search: function () {
|
||||
// custom minLength
|
||||
var term = self.extractLastTag(this.value);
|
||||
if (term.length < self.minChars) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
focus: function () {
|
||||
// prevent value inserted on focus
|
||||
return false;
|
||||
},
|
||||
select: function (event, ui) {
|
||||
var terms = self.splitTagList(this.value);
|
||||
|
||||
// remove the current input
|
||||
terms.pop();
|
||||
|
||||
// check if selected tag is already in list
|
||||
if (!terms.includes(ui.item.value)) {
|
||||
// add the selected item
|
||||
terms.push(ui.item.value);
|
||||
}
|
||||
// add placeholder to get the comma-and-space at the end
|
||||
terms.push("");
|
||||
|
||||
this.value = terms.join(", ");
|
||||
|
||||
$(this).trigger('change');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
)
|
||||
;
|
||||
});
|
||||
}
|
||||
|
||||
destroyAutocomplete(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
const currentField = jQuery(this);
|
||||
currentField.autocomplete("destroy");
|
||||
currentField.removeData('autocomplete');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,15 +19,14 @@ export default class KimaiConfirmationLink extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
document.addEventListener('click', function(event) {
|
||||
document.addEventListener('click', (event) => {
|
||||
let target = event.target;
|
||||
while (target !== null && !target.matches('body')) {
|
||||
if (target.classList.contains(self.selector)) {
|
||||
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
|
||||
if (target.classList.contains(this._selector)) {
|
||||
const attributes = target.dataset;
|
||||
|
||||
// is this a link?
|
||||
@@ -44,7 +43,7 @@ export default class KimaiConfirmationLink extends KimaiPlugin {
|
||||
}
|
||||
|
||||
if (attributes.question !== undefined) {
|
||||
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||
this.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||
if (value) {
|
||||
if (form === null) {
|
||||
document.location = url;
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
* [KIMAI] KimaiDatatable: handles functionality for the datatable
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import KimaiContextMenu from "../widgets/KimaiContextMenu";
|
||||
|
||||
export default class KimaiDatatable extends KimaiPlugin {
|
||||
|
||||
constructor(contentAreaSelector, tableSelector) {
|
||||
super();
|
||||
this.contentArea = contentAreaSelector;
|
||||
this.selector = tableSelector;
|
||||
this._contentArea = contentAreaSelector;
|
||||
this._selector = tableSelector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
@@ -25,24 +25,21 @@ export default class KimaiDatatable extends KimaiPlugin {
|
||||
}
|
||||
|
||||
init() {
|
||||
const dataTable = document.querySelector(this.selector);
|
||||
const dataTable = document.querySelector(this._selector);
|
||||
|
||||
// not every page contains a dataTable
|
||||
if (dataTable === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributes = dataTable.dataset;
|
||||
const events = attributes['reloadEvent'];
|
||||
|
||||
this.fixDropdowns();
|
||||
this.registerContextMenu(this._selector);
|
||||
|
||||
const events = dataTable.dataset['reloadEvent'];
|
||||
if (events === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
const handle = function() { self.reloadDatatable(); };
|
||||
const handle = () => { this.reloadDatatable(); };
|
||||
|
||||
for (let eventName of events.split(' ')) {
|
||||
document.addEventListener(eventName, handle);
|
||||
@@ -52,55 +49,49 @@ export default class KimaiDatatable extends KimaiPlugin {
|
||||
document.addEventListener('filter-change', handle);
|
||||
}
|
||||
|
||||
reloadDatatable() {
|
||||
const self = this;
|
||||
const contentArea = this.contentArea;
|
||||
const durations = this.getContainer().getPlugin('timesheet-duration');
|
||||
const toolbarSelector = this.getContainer().getPlugin('toolbar').getSelector();
|
||||
|
||||
const form = jQuery(toolbarSelector);
|
||||
let loading = '<div class="overlay"><i class="fas fa-sync fa-spin"></i></div>';
|
||||
jQuery(contentArea).append(loading);
|
||||
|
||||
// remove the empty fields to prevent errors
|
||||
let formData = jQuery(toolbarSelector + ' :input')
|
||||
.filter(function(index, element) {
|
||||
return jQuery(element).val() !== '';
|
||||
})
|
||||
.serialize();
|
||||
|
||||
jQuery.ajax({
|
||||
url: form.attr('action'),
|
||||
type: form.attr('method'),
|
||||
data: formData,
|
||||
success: function(html) {
|
||||
jQuery(contentArea).replaceWith(
|
||||
jQuery(html).find(contentArea)
|
||||
);
|
||||
durations.updateRecords();
|
||||
self.fixDropdowns();
|
||||
},
|
||||
error: function(xhr, err) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @private
|
||||
*/
|
||||
registerContextMenu(selector)
|
||||
{
|
||||
KimaiContextMenu.createForDataTable(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* show dropdown menu upwards, if it is outside the visible viewport
|
||||
*/
|
||||
fixDropdowns() {
|
||||
const docHeight = jQuery(document).height();
|
||||
jQuery(this.selector + ' [data-toggle=dropdown]').each(function() {
|
||||
const parent = jQuery(this).parent();
|
||||
const menu = parent.find('.dropdown-menu');
|
||||
reloadDatatable()
|
||||
{
|
||||
const toolbarSelector = this.getContainer().getPlugin('toolbar').getSelector();
|
||||
|
||||
if (parent && menu) {
|
||||
if ((parent.offset().top + parent.outerHeight() + menu.outerHeight()) > docHeight) {
|
||||
parent.addClass('dropup').removeClass('dropdown');
|
||||
}
|
||||
}
|
||||
/** @type {HTMLFormElement} form */
|
||||
const form = document.querySelector(toolbarSelector);
|
||||
const callback = (text) => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = text;
|
||||
const newContent = temp.querySelector(this._contentArea);
|
||||
document.querySelector(this._contentArea).replaceWith(newContent);
|
||||
this.registerContextMenu(this._selector);
|
||||
document.dispatchEvent(new Event('kimai.reloadedContent'));
|
||||
};
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent', {detail: this._contentArea}));
|
||||
|
||||
if (form === null) {
|
||||
this.fetch(document.location)
|
||||
.then(response => {
|
||||
response.text().then(callback);
|
||||
})
|
||||
.catch(() => {
|
||||
document.location.reload();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchForm(form)
|
||||
.then(response => {
|
||||
response.text().then(callback);
|
||||
})
|
||||
.catch(() => {
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
* [KIMAI] KimaiDatatableColumnView: manages the visibility of data-table columns in cookies
|
||||
*/
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiDatatableColumnView extends KimaiPlugin {
|
||||
@@ -29,80 +27,110 @@ export default class KimaiDatatableColumnView extends KimaiPlugin {
|
||||
if (dataTable === null) {
|
||||
return;
|
||||
}
|
||||
this.id = dataTable.getAttribute(this.dataAttribute);
|
||||
this.modal = document.getElementById('modal_' + this.id);
|
||||
this.bindButtons();
|
||||
}
|
||||
|
||||
bindButtons() {
|
||||
let self = this;
|
||||
this.modal.querySelector('button[data-type=save]').addEventListener('click', function() {
|
||||
self.saveVisibility();
|
||||
this._id = dataTable.getAttribute(this.dataAttribute);
|
||||
this._modal = document.getElementById('modal_' + this._id);
|
||||
this._modal.addEventListener('show.bs.modal', () => {
|
||||
this._evaluateCheckboxes();
|
||||
});
|
||||
this.modal.querySelector('button[data-type=reset]').addEventListener('click', function() {
|
||||
self.resetVisibility();
|
||||
this._modal.querySelector('button[data-type=save]').addEventListener('click', () => {
|
||||
this._saveVisibility();
|
||||
});
|
||||
for (let checkbox of this.modal.querySelectorAll('form input[type=checkbox]')) {
|
||||
checkbox.addEventListener('click', function () {
|
||||
self.changeVisibility(checkbox.getAttribute('name'), checkbox.checked);
|
||||
this._modal.querySelector('button[data-type=reset]').addEventListener('click', (event) => {
|
||||
this._resetVisibility(event.currentTarget);
|
||||
});
|
||||
this._modal.querySelectorAll('input[name=datatable_profile]').forEach(element => {
|
||||
element.addEventListener('change', () => {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
this.fetchForm(form, {}, element.getAttribute('data-href'))
|
||||
.then(() => {
|
||||
// the local storage is read in the login screen to set a cookie,
|
||||
// which triggers the session switch in ProfileSubscriber
|
||||
localStorage.setItem('kimai_profile', element.getAttribute('value'));
|
||||
document.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
form.setAttribute('action', element.getAttribute('data-href'));
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
for (let checkbox of this._modal.querySelectorAll('form input[type=checkbox]')) {
|
||||
checkbox.addEventListener('change', () => {
|
||||
this._changeVisibility(checkbox.getAttribute('name'), checkbox.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
saveVisibility() {
|
||||
const form = this.modal.getElementsByTagName('form')[0];
|
||||
let settings = {};
|
||||
for (let checkbox of form.querySelectorAll('input[type=checkbox]')) {
|
||||
settings[checkbox.getAttribute('name')] = checkbox.checked;
|
||||
_evaluateCheckboxes() {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
const table = document.getElementsByClassName('datatable_' + this._id)[0];
|
||||
for (let columnElement of table.getElementsByTagName('th')) {
|
||||
const fieldName = columnElement.getAttribute('data-field');
|
||||
if (fieldName === null) {
|
||||
continue;
|
||||
}
|
||||
const checkbox = form.querySelector('input[name=' + fieldName + ']');
|
||||
if (checkbox === null) {
|
||||
continue;
|
||||
}
|
||||
checkbox.checked = window.getComputedStyle(columnElement).display !== 'none';
|
||||
}
|
||||
Cookies.set(form.getAttribute('name'), JSON.stringify(settings), {expires: 365, SameSite: 'Strict'});
|
||||
jQuery(this.modal).modal('toggle');
|
||||
}
|
||||
|
||||
resetVisibility() {
|
||||
const form = this.modal.getElementsByTagName('form')[0];
|
||||
Cookies.remove(form.getAttribute('name'));
|
||||
for (let checkbox of form.querySelectorAll('input[type=checkbox]')) {
|
||||
if (!checkbox.checked) {
|
||||
checkbox.click();
|
||||
}
|
||||
}
|
||||
jQuery(this.modal).modal('toggle');
|
||||
_saveVisibility() {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
|
||||
this.fetchForm(form)
|
||||
.then(() => {
|
||||
document.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
changeVisibility(columnName, checked) {
|
||||
const tables = document.getElementsByClassName('datatable_' + this.id);
|
||||
for (let tableBox of tables) {
|
||||
let column = 0;
|
||||
let foundColumn = false;
|
||||
let table = tableBox.getElementsByClassName('dataTable')[0];
|
||||
for (let columnElement of table.getElementsByTagName('th')) {
|
||||
if (columnElement.getAttribute('data-field') === columnName) {
|
||||
foundColumn = true;
|
||||
break;
|
||||
_resetVisibility(button) {
|
||||
const form = this._modal.getElementsByTagName('form')[0];
|
||||
|
||||
this.fetchForm(form, {}, button.getAttribute('formaction'))
|
||||
.then(() => {
|
||||
document.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
form.setAttribute('action', button.getAttribute('formaction'));
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
_changeVisibility(columnName, checked) {
|
||||
for (const tableBox of document.getElementsByClassName('datatable_' + this._id)) {
|
||||
let targetClasses = null;
|
||||
for (let element of tableBox.getElementsByClassName('col_' + columnName)) {
|
||||
// only calculate that once and re-use the cached class list
|
||||
if (targetClasses === null) {
|
||||
let removeClass = '-none';
|
||||
let addClass = 'd-table-cell';
|
||||
|
||||
if (!checked) {
|
||||
removeClass = '-table-cell';
|
||||
addClass = 'd-none';
|
||||
}
|
||||
|
||||
targetClasses = '';
|
||||
element.classList.forEach(
|
||||
function (name, index, listObj) { // eslint-disable-line no-unused-vars
|
||||
if (name.indexOf(removeClass) === -1) {
|
||||
targetClasses += ' ' + name;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (targetClasses.indexOf(addClass) === -1) {
|
||||
targetClasses += ' ' + addClass;
|
||||
}
|
||||
}
|
||||
|
||||
if (columnElement.getAttribute('colspan') !== null) {
|
||||
console.log('Tables with colspans are not supported!');
|
||||
}
|
||||
|
||||
column++;
|
||||
}
|
||||
|
||||
if (!foundColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let rowElement of table.getElementsByTagName('tr')) {
|
||||
if (rowElement.children[column] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
rowElement.children[column].classList.remove('hidden');
|
||||
} else {
|
||||
rowElement.children[column].classList.add('hidden');
|
||||
}
|
||||
element.className = targetClasses;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiDatePicker: single date selects (currently unused)
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiDatePicker extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'date-picker';
|
||||
}
|
||||
|
||||
activateDatePicker(selector) {
|
||||
const TRANSLATE = this.getContainer().getTranslation();
|
||||
const DATE_UTILS = this.getContainer().getPlugin('date');
|
||||
const firstDow = this.getConfiguration('first_dow_iso') % 7;
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
let localeFormat = jQuery(this).data('format');
|
||||
jQuery(this).daterangepicker({
|
||||
singleDatePicker: true,
|
||||
showDropdowns: true,
|
||||
autoUpdateInput: false,
|
||||
drops: 'down',
|
||||
locale: {
|
||||
format: localeFormat,
|
||||
firstDay: firstDow,
|
||||
applyLabel: TRANSLATE.get('confirm'),
|
||||
cancelLabel: TRANSLATE.get('cancel'),
|
||||
customRangeLabel: TRANSLATE.get('customRange'),
|
||||
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
|
||||
monthNames: DATE_UTILS.getMonthNames(),
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('show.daterangepicker', function (ev, picker) {
|
||||
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
|
||||
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
|
||||
picker.drops = 'auto';
|
||||
picker.move();
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
|
||||
jQuery(this).val(picker.startDate.format(localeFormat));
|
||||
jQuery(this).trigger("change");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyDatePicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
if (jQuery(this).data('daterangepicker') !== undefined) {
|
||||
jQuery(this).data('daterangepicker').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiDateRangePicker: activate the (daterange picker) compound field in toolbar
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class KimaiDateRangePicker extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'date-range-picker';
|
||||
}
|
||||
|
||||
activateDateRangePicker(selector) {
|
||||
const TRANSLATE = this.getContainer().getTranslation();
|
||||
const DATE_UTILS = this.getContainer().getPlugin('date');
|
||||
const firstDow = this.getConfiguration('first_dow_iso') % 7;
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
let localeFormat = jQuery(this).data('format');
|
||||
let separator = jQuery(this).data('separator');
|
||||
let rangesList = {};
|
||||
|
||||
rangesList[TRANSLATE.get('today')] = [moment(), moment()];
|
||||
rangesList[TRANSLATE.get('yesterday')] = [moment().subtract(1, 'days'), moment().subtract(1, 'days')];
|
||||
rangesList[TRANSLATE.get('thisWeek')] = [moment().startOf('isoWeek'), moment().endOf('isoWeek')];
|
||||
rangesList[TRANSLATE.get('lastWeek')] = [moment().subtract(1, 'week').startOf('isoWeek'), moment().subtract(1, 'week').endOf('isoWeek')];
|
||||
if (firstDow === 0) { // sunday = 0
|
||||
rangesList[TRANSLATE.get('thisWeek')] = [moment().startOf('isoWeek').subtract(1, 'day'), moment().endOf('isoWeek').subtract(1, 'day')];
|
||||
rangesList[TRANSLATE.get('lastWeek')] = [moment().subtract(1, 'week').startOf('isoWeek').subtract(1, 'day'), moment().subtract(1, 'week').endOf('isoWeek').subtract(1, 'day')];
|
||||
}
|
||||
rangesList[TRANSLATE.get('thisMonth')] = [moment().startOf('month'), moment().endOf('month')];
|
||||
rangesList[TRANSLATE.get('lastMonth')] = [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')];
|
||||
rangesList[TRANSLATE.get('thisYear')] = [moment().startOf('year'), moment().endOf('year')];
|
||||
rangesList[TRANSLATE.get('lastYear')] = [moment().subtract(1, 'year').startOf('year'), moment().subtract(1, 'year').endOf('year')];
|
||||
|
||||
jQuery(this).daterangepicker({
|
||||
showDropdowns: true,
|
||||
autoUpdateInput: false,
|
||||
autoApply: false,
|
||||
linkedCalendars: true,
|
||||
drops: 'down',
|
||||
locale: {
|
||||
separator: separator,
|
||||
format: localeFormat,
|
||||
firstDay: firstDow,
|
||||
applyLabel: TRANSLATE.get('confirm'),
|
||||
cancelLabel: TRANSLATE.get('cancel'),
|
||||
customRangeLabel: TRANSLATE.get('customRange'),
|
||||
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
|
||||
monthNames: DATE_UTILS.getMonthNames(),
|
||||
},
|
||||
ranges: rangesList,
|
||||
alwaysShowCalendars: true
|
||||
});
|
||||
|
||||
jQuery(this).on('show.daterangepicker', function (ev, picker) {
|
||||
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
|
||||
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
|
||||
picker.drops = 'auto';
|
||||
picker.move();
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
|
||||
jQuery(this).val(picker.startDate.format(localeFormat) + ' - ' + picker.endDate.format(localeFormat));
|
||||
jQuery(this).data('begin', picker.startDate.format(localeFormat));
|
||||
jQuery(this).data('end', picker.endDate.format(localeFormat));
|
||||
jQuery(this).trigger("change");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyDateRangePicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
if (jQuery(this).data('daterangepicker') !== undefined) {
|
||||
jQuery(this).data('daterangepicker').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiDateTimePicker: activate the (datetime picker) field in timesheet edit dialog
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiDateTimePicker extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'date-time-picker';
|
||||
}
|
||||
|
||||
activateDateTimePicker(selector) {
|
||||
const TRANSLATE = this.getContainer().getTranslation();
|
||||
const DATE_UTILS = this.getContainer().getPlugin('date');
|
||||
const firstDow = this.getConfiguration('first_dow_iso') % 7;
|
||||
const is24hours = this.getConfiguration('twentyFourHours');
|
||||
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
let localeFormat = jQuery(this).data('format');
|
||||
jQuery(this).daterangepicker({
|
||||
singleDatePicker: true,
|
||||
timePicker: true,
|
||||
timePicker24Hour: is24hours,
|
||||
showDropdowns: true,
|
||||
autoUpdateInput: false,
|
||||
drops: 'down',
|
||||
locale: {
|
||||
format: localeFormat,
|
||||
firstDay: firstDow,
|
||||
applyLabel: TRANSLATE.get('confirm'),
|
||||
cancelLabel: TRANSLATE.get('cancel'),
|
||||
customRangeLabel: TRANSLATE.get('customRange'),
|
||||
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
|
||||
monthNames: DATE_UTILS.getMonthNames(),
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('show.daterangepicker', function (ev, picker) {
|
||||
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
|
||||
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
|
||||
picker.drops = 'auto';
|
||||
picker.move();
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
|
||||
jQuery(this).val(picker.startDate.format(localeFormat));
|
||||
jQuery(this).trigger("change");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyDateTimePicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).each(function(index) {
|
||||
if (jQuery(this).data('daterangepicker') !== undefined) {
|
||||
jQuery(this).data('daterangepicker').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,71 +10,241 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
import moment from 'moment';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
|
||||
export default class KimaiDateUtils extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'date';
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
if (this.getConfigurations().is24Hours()) {
|
||||
this.timeFormat = 'HH:mm';
|
||||
} else {
|
||||
this.timeFormat = 'hh:mm a';
|
||||
}
|
||||
this.durationFormat = this.getConfiguration('formatDuration');
|
||||
this.dateFormat = this.getConfiguration('formatDate');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dateTime
|
||||
* @see https://moment.github.io/luxon/#/formatting?id=table-of-tokens
|
||||
* @param {string} format
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_parseFormat(format)
|
||||
{
|
||||
format = format.replace('DD', 'dd');
|
||||
format = format.replace('D', 'd');
|
||||
format = format.replace('MM', 'LL');
|
||||
format = format.replace('M', 'L');
|
||||
format = format.replace('YYYY', 'yyyy');
|
||||
format = format.replace('YY', 'yy');
|
||||
format = format.replace('A', 'a');
|
||||
|
||||
return format;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} format
|
||||
* @param {string|Date|null|undefined} dateTime
|
||||
* @returns {string}
|
||||
*/
|
||||
getFormattedDate(dateTime) {
|
||||
return moment(dateTime).format(this.getConfiguration('formatDate'));
|
||||
}
|
||||
format(format, dateTime)
|
||||
{
|
||||
let newDate = null;
|
||||
|
||||
getWeekDaysShort() {
|
||||
return moment.localeData().weekdaysShort();
|
||||
}
|
||||
if (dateTime === null || dateTime === undefined) {
|
||||
newDate = DateTime.now();
|
||||
} else if (dateTime instanceof Date) {
|
||||
newDate = DateTime.fromJSDate(dateTime);
|
||||
} else {
|
||||
newDate = DateTime.fromISO(dateTime);
|
||||
}
|
||||
|
||||
getMonthNames() {
|
||||
return moment.localeData().months();
|
||||
}
|
||||
|
||||
formatDuration(since) {
|
||||
const duration = moment.duration(moment(new Date()).diff(moment(since)));
|
||||
|
||||
return this.formatMomentDuration(duration);
|
||||
}
|
||||
|
||||
formatSeconds(seconds) {
|
||||
const duration = moment.duration('PT' + seconds + 'S');
|
||||
|
||||
return this.formatMomentDuration(duration);
|
||||
// using locale english here prevents that that AM/PM is translated to the
|
||||
// locale variant: e.g. "ko" translates it to 오후 / 오전
|
||||
return newDate.toFormat(this._parseFormat(format), { locale: 'en-us' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {moment.Duration} duration
|
||||
* @returns {string|*}
|
||||
* @param {string|Date} dateTime
|
||||
* @returns {string}
|
||||
*/
|
||||
formatMomentDuration(duration) {
|
||||
const hours = parseInt(duration.asHours());
|
||||
const minutes = duration.minutes();
|
||||
|
||||
return this.formatTime(hours, minutes);
|
||||
getFormattedDate(dateTime)
|
||||
{
|
||||
return this.format(this._parseFormat(this.dateFormat), dateTime);
|
||||
}
|
||||
|
||||
formatTime(hours, minutes) {
|
||||
let format = this.getConfiguration('formatDuration');
|
||||
/**
|
||||
* Returns a "YYYY-MM-DDTHH:mm:ss" formatted string in local time.
|
||||
* This can take Date objects (e.g. from FullCalendar) and turn them into the correct format.
|
||||
*
|
||||
* @param {Date|DateTime} date
|
||||
* @param {boolean|undefined} isUtc
|
||||
* @return {string}
|
||||
*/
|
||||
formatForAPI(date, isUtc = false)
|
||||
{
|
||||
if (date instanceof Date) {
|
||||
date = DateTime.fromJSDate(date);
|
||||
}
|
||||
|
||||
if (isUtc === undefined || !isUtc) {
|
||||
date = date.toUTC();
|
||||
}
|
||||
|
||||
return date.toISO({ includeOffset: false, suppressMilliseconds: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} date
|
||||
* @param {string} format
|
||||
* @return {DateTime}
|
||||
*/
|
||||
fromFormat(date, format)
|
||||
{
|
||||
// using locale en-us here prevents that Luxon expects the localized
|
||||
// version of AM/PM (e.g. 오후 / 오전 for locale "ko")
|
||||
return DateTime.fromFormat(date, this._parseFormat(format), { locale: 'en-us' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null} date
|
||||
* @param {string|null} time
|
||||
* @return {DateTime}
|
||||
*/
|
||||
fromHtml5Input(date, time)
|
||||
{
|
||||
date = date ?? '';
|
||||
time = time ?? '';
|
||||
|
||||
if (date === '' && time === '') {
|
||||
return DateTime.invalid('Empty date and time given');
|
||||
}
|
||||
|
||||
if (date !== '' && time !== '') {
|
||||
date = date + 'T' + time;
|
||||
}
|
||||
|
||||
return DateTime.fromISO(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} date
|
||||
* @param {string} format
|
||||
* @return {boolean}
|
||||
*/
|
||||
isValidDateTime(date, format)
|
||||
{
|
||||
return this.fromFormat(date, format).isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a string like "00:30:00" or "01:15" to a given date.
|
||||
*
|
||||
* @param {Date} date
|
||||
* @param {string} duration
|
||||
* @return {Date}
|
||||
*/
|
||||
addHumanDuration(date, duration)
|
||||
{
|
||||
/** @type {DateTime} newDate */
|
||||
let newDate = null;
|
||||
|
||||
if (date instanceof Date) {
|
||||
newDate = DateTime.fromJSDate(date);
|
||||
} else if (date instanceof DateTime) {
|
||||
newDate = date;
|
||||
} else {
|
||||
throw 'addHumanDuration() needs a JS Date';
|
||||
}
|
||||
|
||||
const parsed = DateTime.fromISO(duration);
|
||||
const today = DateTime.now().startOf('day');
|
||||
const timeOfDay = parsed.diff(today);
|
||||
|
||||
return newDate.plus(timeOfDay).toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|integer|null} since
|
||||
* @return {string}
|
||||
*/
|
||||
formatDuration(since)
|
||||
{
|
||||
let duration = null;
|
||||
|
||||
if (typeof since === 'string') {
|
||||
duration = DateTime.now().diff(DateTime.fromISO(since));
|
||||
} else {
|
||||
duration = Duration.fromISO('PT' + (since === null ? 0 : since) + 'S');
|
||||
}
|
||||
|
||||
return this.formatLuxonDuration(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {integer} seconds
|
||||
* @return {string}
|
||||
*/
|
||||
formatSeconds(seconds)
|
||||
{
|
||||
return this.formatLuxonDuration(Duration.fromObject({seconds: seconds}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Duration} duration
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
formatLuxonDuration(duration)
|
||||
{
|
||||
duration = duration.shiftTo('hours', 'minutes', 'seconds');
|
||||
|
||||
return this.formatAsDuration(duration.hours, duration.minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @param {boolean|undefined} isUtc
|
||||
* @return {string}
|
||||
*/
|
||||
formatTime(date, isUtc = false)
|
||||
{
|
||||
let newDate = DateTime.fromJSDate(date);
|
||||
|
||||
if (isUtc === undefined || !isUtc) {
|
||||
newDate = newDate.toUTC();
|
||||
}
|
||||
|
||||
// .utc() is required for calendar
|
||||
return newDate.toFormat(this.timeFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO remove seconds
|
||||
*
|
||||
* @param {int} hours
|
||||
* @param {int} minutes
|
||||
* @return {string}
|
||||
*/
|
||||
formatAsDuration(hours, minutes)
|
||||
{
|
||||
let format = this.durationFormat;
|
||||
|
||||
if (hours < 0 || minutes < 0) {
|
||||
hours = Math.abs(hours);
|
||||
minutes = Math.abs(minutes);
|
||||
if (minutes > 0 || hours > 0) {
|
||||
format = '-' + format;
|
||||
}
|
||||
format = '-' + format;
|
||||
}
|
||||
|
||||
// special case for hours, as they can overflow the 24h barrier - Kimai does not support days as duration unit
|
||||
if (hours < 10) {
|
||||
hours = '0' + hours;
|
||||
}
|
||||
|
||||
|
||||
return format.replace('%h', hours).replace('%m', ('0' + minutes).substr(-2));
|
||||
return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2));
|
||||
//return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2)).replace('%s', ('0' + seconds).slice(-2));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,32 +253,52 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
*/
|
||||
getSecondsFromDurationString(duration)
|
||||
{
|
||||
duration = duration.trim().toUpperCase();
|
||||
let momentDuration = moment.duration(NaN);
|
||||
const luxonDuration = this.parseDuration(duration);
|
||||
|
||||
if (duration.indexOf(':') !== -1) {
|
||||
momentDuration = moment.duration(duration);
|
||||
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
|
||||
duration = duration.replace(/,/, '.');
|
||||
duration = (parseFloat(duration) * 3600).toString();
|
||||
momentDuration = moment.duration('PT' + duration + 'S');
|
||||
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
|
||||
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
|
||||
momentDuration = moment.duration('PT' + duration);
|
||||
} else {
|
||||
let c = parseInt(duration);
|
||||
let d = parseInt(duration).toFixed();
|
||||
if (!isNaN(c) && duration === d) {
|
||||
duration = (c * 3600).toString();
|
||||
momentDuration = moment.duration('PT' + duration + 'S');
|
||||
}
|
||||
}
|
||||
|
||||
if (!momentDuration.isValid()) {
|
||||
if (luxonDuration === null || !luxonDuration.isValid) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return momentDuration.asSeconds();
|
||||
return luxonDuration.as('seconds');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} duration
|
||||
* @returns {Duration}
|
||||
*/
|
||||
parseDuration(duration)
|
||||
{
|
||||
if (duration === undefined || duration === null || duration === '') {
|
||||
return new Duration({seconds: 0});
|
||||
}
|
||||
|
||||
duration = duration.trim().toUpperCase();
|
||||
let luxonDuration = null;
|
||||
|
||||
if (duration.indexOf(':') !== -1) {
|
||||
const [, hours, minutes, seconds] = duration.match(/(\d+):(\d+)(?::(\d+))*/);
|
||||
luxonDuration = Duration.fromObject({hours: hours, minutes: minutes, seconds: seconds});
|
||||
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
|
||||
duration = duration.replace(/,/, '.');
|
||||
duration = (parseFloat(duration) * 3600).toString();
|
||||
luxonDuration = Duration.fromISO('PT' + duration + 'S');
|
||||
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
|
||||
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
|
||||
luxonDuration = Duration.fromISO('PT' + duration);
|
||||
} else {
|
||||
let c = parseInt(duration);
|
||||
const d = parseInt(duration).toFixed();
|
||||
if (!isNaN(c) && duration === d) {
|
||||
duration = (c * 3600).toString();
|
||||
luxonDuration = Duration.fromISO('PT' + duration + 'S');
|
||||
}
|
||||
}
|
||||
|
||||
if (luxonDuration === null || !luxonDuration.isValid) {
|
||||
return new Duration({seconds: 0});
|
||||
}
|
||||
|
||||
return luxonDuration;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export default class KimaiEscape extends KimaiPlugin {
|
||||
* @returns {string}
|
||||
*/
|
||||
escapeForHtml(title) {
|
||||
if (title === undefined || title === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tagsToReplace = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
@@ -31,5 +35,5 @@ export default class KimaiEscape extends KimaiPlugin {
|
||||
return title.replace(/[&<>]/g, function(tag) {
|
||||
return tagsToReplace[tag] || tag;
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,24 @@ import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiEvent extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'event';
|
||||
}
|
||||
|
||||
trigger(name, details) {
|
||||
if (name === null || name === undefined) {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string|array|object|null} details
|
||||
*/
|
||||
trigger(name, details = null)
|
||||
{
|
||||
if (name === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
for(let event of name.split(' ')) {
|
||||
for (const event of name.split(' ')) {
|
||||
let triggerEvent = new Event(event);
|
||||
if (details !== undefined) {
|
||||
if (details !== null) {
|
||||
triggerEvent = new CustomEvent(event, {detail: details});
|
||||
}
|
||||
document.dispatchEvent(triggerEvent);
|
||||
|
||||
80
assets/js/plugins/KimaiFetch.js
Normal file
80
assets/js/plugins/KimaiFetch.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiEscape: sanitize strings
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiFetch extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
return 'fetch';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
fetch(url, options = {}) {
|
||||
if (options.headers === undefined) {
|
||||
options.headers = new Headers();
|
||||
}
|
||||
options.headers.append('X-Requested-With', 'Kimai');
|
||||
|
||||
options = {...{
|
||||
redirect: 'follow',
|
||||
}, ...options};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(url, options).then(response => {
|
||||
if (response.ok) {
|
||||
if (response.status === 201 && response.headers.has('x-modal-redirect')) {
|
||||
window.location = response.headers.get('x-modal-redirect');
|
||||
return;
|
||||
}
|
||||
|
||||
// "ok" is only in status code range of 2xx
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
|
||||
let stopPropagation = false;
|
||||
switch (response.status) {
|
||||
case 403: {
|
||||
if (response.headers.has('login-required')) {
|
||||
const loginUrl = this.getConfiguration('login').toString();
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
alert.question(this.translate('login.required'), (result) => {
|
||||
if (result === true) {
|
||||
window.location.replace(loginUrl);
|
||||
}
|
||||
});
|
||||
stopPropagation = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.log('Some error occurred');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stopPropagation) {
|
||||
reject(response);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error occurred while talking to Kimai backend', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,35 +10,44 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import KimaiFormPlugin from "../forms/KimaiFormPlugin";
|
||||
|
||||
export default class KimaiForm extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'form';
|
||||
}
|
||||
|
||||
activateForm(formSelector, container) {
|
||||
this.getContainer().getPlugin('date-range-picker').activateDateRangePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-time-picker').activateDateTimePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-picker').activateDatePicker(formSelector);
|
||||
this.getContainer().getPlugin('autocomplete').activateAutocomplete(formSelector);
|
||||
this.getContainer().getPlugin('form-select').activateSelectPicker(formSelector, container);
|
||||
activateForm(formSelector)
|
||||
{
|
||||
[].slice.call(document.querySelectorAll(formSelector)).map((form) => {
|
||||
for (const plugin of this.getContainer().getPlugins()) {
|
||||
if (plugin instanceof KimaiFormPlugin && plugin.supportsForm(form)) {
|
||||
plugin.activateForm(form);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroyForm(formSelector) {
|
||||
this.getContainer().getPlugin('form-select').destroySelectPicker(formSelector);
|
||||
this.getContainer().getPlugin('autocomplete').destroyAutocomplete(formSelector);
|
||||
this.getContainer().getPlugin('date-picker').destroyDatePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-time-picker').destroyDateTimePicker(formSelector);
|
||||
this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector);
|
||||
|
||||
destroyForm(formSelector)
|
||||
{
|
||||
[].slice.call(document.querySelectorAll(formSelector)).map((form) => {
|
||||
for (const plugin of this.getContainer().getPlugins()) {
|
||||
if (plugin instanceof KimaiFormPlugin && plugin.supportsForm(form)) {
|
||||
plugin.destroyForm(form);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {Object} overwrites
|
||||
* @param {boolean} removeEmpty
|
||||
* @returns {string}
|
||||
*/
|
||||
convertFormDataToQueryString(form, overwrites = {})
|
||||
convertFormDataToQueryString(form, overwrites = {}, removeEmpty = false)
|
||||
{
|
||||
let serialized = [];
|
||||
let data = new FormData(form);
|
||||
@@ -48,7 +57,9 @@ export default class KimaiForm extends KimaiPlugin {
|
||||
}
|
||||
|
||||
for (let row of data) {
|
||||
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
|
||||
if (!removeEmpty || row[1] !== '') {
|
||||
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return serialized.join('&');
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiFormSelect: enhanced functionality for HTML select's
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiFormSelect extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'form-select';
|
||||
}
|
||||
|
||||
init() {
|
||||
// selects the original value inside select2 dropdowns, as the "reset" event (the updated option)
|
||||
// is not automatically catched by select2
|
||||
jQuery('body').on('reset', 'form', function(event) {
|
||||
setTimeout(function() {
|
||||
jQuery(event.target).find(this.selector).trigger('change');
|
||||
}, 10);
|
||||
});
|
||||
|
||||
const self = this;
|
||||
|
||||
// Function to match the name of the parent and not only the names of the children
|
||||
// Based on the original matcher function of Select2: https://github.com/select2/select2/blob/5765090318c4d382ae56463cfa25ba8ca7bdd495/src/js/select2/defaults.js#L272
|
||||
// More information: https://select2.org/searching | https://github.com/select2/docs/blob/develop/pages/11.searching/docs.md
|
||||
this.matcher = function (params, data) {
|
||||
// Always return the object if there is nothing to compare
|
||||
if (jQuery.trim(params.term) === '') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Check whether options has children
|
||||
let hasChildren = data.children && data.children.length > 0;
|
||||
|
||||
// Split search param by space to search for all terms and convert all to uppercase
|
||||
let terms = params.term.toUpperCase().split(' ');
|
||||
let original = data.text.toUpperCase();
|
||||
|
||||
// Always return the parent option including its children, when the name matches one of the params
|
||||
// Check if the text contains all or at least one of the terms
|
||||
let foundAll = true;
|
||||
let foundOne = false;
|
||||
let missingTerms = [];
|
||||
terms.forEach(function(item, index) {
|
||||
if (original.indexOf(item) > -1) {
|
||||
foundOne = true;
|
||||
} else {
|
||||
foundAll = false;
|
||||
missingTerms.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// If the option element contains all terms, return it
|
||||
if (foundAll) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Do a recursive check for options with children
|
||||
if (hasChildren) {
|
||||
// If the parent already contains one or more search terms, proceed only with the missing ones
|
||||
// First: Clone the original params object...
|
||||
let newParams = jQuery.extend(true, {}, params);
|
||||
if (foundOne) {
|
||||
newParams.term = missingTerms.join(' ');
|
||||
} else {
|
||||
newParams.term = params.term;
|
||||
}
|
||||
|
||||
// Clone the data object if there are children
|
||||
// This is required as we modify the object to remove any non-matches
|
||||
let match = jQuery.extend(true, {}, data);
|
||||
|
||||
// Check each child of the option
|
||||
for (let c = data.children.length - 1; c >= 0; c--) {
|
||||
let child = data.children[c];
|
||||
|
||||
let matches = self.matcher(newParams, child);
|
||||
|
||||
// If there wasn't a match, remove the object in the array
|
||||
if (matches === null) {
|
||||
match.children.splice(c, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If any children matched, return the new object
|
||||
if (match.children.length > 0) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
// If the option or its children do not contain the term, don't return anything
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
activateSelectPickerByElement(node, container) {
|
||||
let options = {};
|
||||
if (container !== undefined) {
|
||||
options = {
|
||||
dropdownParent: jQuery(container),
|
||||
};
|
||||
}
|
||||
|
||||
options = {...options, ...{
|
||||
language: this.getConfiguration('locale').replace('_', '-'),
|
||||
theme: "bootstrap",
|
||||
matcher: this.matcher,
|
||||
dropdownAutoWidth: true,
|
||||
width: "resolve"
|
||||
}};
|
||||
|
||||
const element = jQuery(node);
|
||||
|
||||
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
|
||||
const templateResultFunc = function (state) {
|
||||
return jQuery('<span><span style="background-color:'+state.id+'; width: 20px; height: 20px; display: inline-block; margin-right: 10px;"> </span>' + state.text + '</span>');
|
||||
};
|
||||
|
||||
const colorOptions = {...options, ...{
|
||||
templateSelection: templateResultFunc,
|
||||
templateResult: templateResultFunc
|
||||
}};
|
||||
|
||||
element.select2(colorOptions);
|
||||
} else {
|
||||
element.select2(options);
|
||||
}
|
||||
|
||||
// this is a bugfix for safari, which does render the dropdown only with correct width upon the second opening
|
||||
// see https://github.com/select2/select2/issues/4678
|
||||
element.on('select2:open', function (ev) {
|
||||
if (element.data('performing-reopen') === undefined || element.data('performing-reopen') === null) {
|
||||
element.data('performing-reopen', true);
|
||||
element.select2('close');
|
||||
element.select2('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
activateSelectPicker(selector, container) {
|
||||
const self = this;
|
||||
jQuery(selector + ' ' + this.selector).each(function(i, el) {
|
||||
self.activateSelectPickerByElement(el, container);
|
||||
});
|
||||
}
|
||||
|
||||
destroySelectPicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).select2('destroy');
|
||||
}
|
||||
|
||||
updateOptions(selectIdentifier, data) {
|
||||
let select = jQuery(selectIdentifier);
|
||||
let emptyOption = jQuery(selectIdentifier + ' option[value=""]');
|
||||
const selectedValue = select.val();
|
||||
|
||||
select.find('option').remove().end().find('optgroup').remove().end();
|
||||
|
||||
if (emptyOption.length !== 0) {
|
||||
select.append(this._createOption(emptyOption.text(), ''));
|
||||
}
|
||||
|
||||
let emptyOpts = [];
|
||||
let options = [];
|
||||
let titlePattern = null;
|
||||
if (select[0] !== undefined && select[0].dataset !== undefined && select[0].dataset['optionPattern'] !== undefined) {
|
||||
titlePattern = select[0].dataset['optionPattern'];
|
||||
}
|
||||
if (titlePattern === null || titlePattern === '') {
|
||||
titlePattern = '{name}';
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === '__empty__') {
|
||||
for (const entity of value) {
|
||||
emptyOpts.push(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let optGroup = this._createOptgroup(key);
|
||||
for (const entity of value) {
|
||||
optGroup.appendChild(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
options.push(optGroup);
|
||||
}
|
||||
|
||||
select.append(options);
|
||||
select.append(emptyOpts);
|
||||
|
||||
// if available, re-select the previous selected option (mostly usable for global activities)
|
||||
select.val(selectedValue);
|
||||
|
||||
// pre-select an option if it is the only available one
|
||||
if (select.val() === '' || select.val() === null) {
|
||||
const allOptions = select.find('option');
|
||||
const optionLength = allOptions.length;
|
||||
let selectOption = '';
|
||||
|
||||
if (optionLength === 1) {
|
||||
selectOption = allOptions[0].value;
|
||||
} else if (optionLength === 2 && emptyOption.length === 1) {
|
||||
selectOption = allOptions[1].value;
|
||||
}
|
||||
|
||||
if (selectOption !== '') {
|
||||
select.val(selectOption);
|
||||
}
|
||||
}
|
||||
|
||||
// if we don't trigger the change, the other selects won't reset
|
||||
select.trigger('change');
|
||||
|
||||
// if select2 is active, this will tell the select to refresh
|
||||
if (select.hasClass('selectpicker')) {
|
||||
select.trigger('change.select2');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pattern
|
||||
* @param {array} entity
|
||||
* @private
|
||||
*/
|
||||
_getTitleFromPattern(pattern, entity) {
|
||||
const DATE_UTILS = this.getPlugin('date');
|
||||
const regexp = new RegExp('{[^}]*?}','g');
|
||||
let title = pattern;
|
||||
let match = null;
|
||||
|
||||
while ((match = regexp.exec(pattern)) !== null) {
|
||||
const field = match[0].substr(1, match[0].length - 2);
|
||||
let value = entity[field] === undefined ? null : entity[field];
|
||||
if ((field === 'start' || field === 'end')) {
|
||||
if (value === null) {
|
||||
value = '?';
|
||||
} else {
|
||||
value = DATE_UTILS.getFormattedDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
title = title.replace(new RegExp('{' + field + '}', 'g'), value ?? '');
|
||||
}
|
||||
title = title.replace(/- \?-\?/, '');
|
||||
title = title.replace(/\r\n|\r|\n/g, ' ');
|
||||
title = title.substr(0, 110);
|
||||
|
||||
const chars = '- ';
|
||||
let start = 0, end = title.length;
|
||||
|
||||
while (start < end && chars.indexOf(title[start]) >= 0) {
|
||||
++start;
|
||||
}
|
||||
|
||||
while (end > start && chars.indexOf(title[end - 1]) >= 0) {
|
||||
--end;
|
||||
}
|
||||
|
||||
return (start > 0 || end < title.length) ? title.substring(start, end) : title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @returns {HTMLElement}
|
||||
* @private
|
||||
*/
|
||||
_createOption(label, value) {
|
||||
let option = document.createElement('option');
|
||||
option.innerText = label;
|
||||
option.value = value;
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @returns {HTMLElement}
|
||||
* @private
|
||||
*/
|
||||
_createOptgroup(label) {
|
||||
let optGroup = document.createElement('optgroup');
|
||||
optGroup.label = label;
|
||||
return optGroup;
|
||||
}
|
||||
}
|
||||
51
assets/js/plugins/KimaiHotkeys.js
Normal file
51
assets/js/plugins/KimaiHotkeys.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiHotkeys extends KimaiPlugin {
|
||||
|
||||
getId()
|
||||
{
|
||||
return 'hotkeys';
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
|
||||
|
||||
const selector = '[data-hotkey="ctrl+Enter"]';
|
||||
|
||||
window.addEventListener('keyup', (ev) => {
|
||||
if (ev.ctrlKey && ev.key === 'Enter') {
|
||||
const elements = [...document.querySelectorAll(selector)].filter(element => this.isVisible(element));
|
||||
|
||||
if (elements.length > 1) {
|
||||
console.warn('KimaiHotkeys: More than one visible element matches ${selector}. No action triggered.');
|
||||
}
|
||||
|
||||
if (elements.length === 1) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
elements[0].click();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// adopted from Bootstrap 5.1.1, MIT
|
||||
isVisible (element)
|
||||
{
|
||||
if (!element || element.getClientRects().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getComputedStyle(element).getPropertyValue('visibility') === 'visible';
|
||||
}
|
||||
}
|
||||
@@ -10,63 +10,79 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiMultiUpdateTable extends KimaiPlugin {
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
|
||||
jQuery('body').
|
||||
on('change', '#multi_update_all', function(event) {
|
||||
jQuery('.multi_update_single').prop('checked', jQuery(event.target).prop('checked'));
|
||||
self.toggleForm();
|
||||
})
|
||||
.on('change', '.multi_update_single', function(event) {
|
||||
self.toggleForm();
|
||||
})
|
||||
.on('change', '#multi_update_table_action', function(event) {
|
||||
const selectedItem = jQuery('#multi_update_table_action option:selected');
|
||||
const selectedVal = selectedItem.val();
|
||||
init()
|
||||
{
|
||||
if (document.getElementById('multi_update_all') === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedVal === '') {
|
||||
return;
|
||||
// we have to attach it to the "page-body" div, because section.content can be replaced
|
||||
// via KimaiDatable and everything inside will be removed, including event listeners
|
||||
const element = document.querySelector('div.page-body');
|
||||
element.addEventListener('change', (event) => {
|
||||
if (event.target.matches('#multi_update_all')) {
|
||||
// the "check all" checkbox in the upper start corner of the table
|
||||
const checked = event.target.checked;
|
||||
for (const element of document.querySelectorAll('.multi_update_single')) {
|
||||
element.checked = checked;
|
||||
}
|
||||
|
||||
const form = jQuery('#multi_update_form form');
|
||||
const selectedText = selectedItem.text();
|
||||
const ids = self.getSelectedIds();
|
||||
const question = form.attr('data-question').replace(/%action%/, selectedText).replace(/%count%/, ids.length);
|
||||
|
||||
self.getContainer().getPlugin('alert').question(question, function(value) {
|
||||
this._toggleForm();
|
||||
event.stopPropagation();
|
||||
} else if (event.target.matches('.multi_update_single')) {
|
||||
// single checkboxes in front of each row
|
||||
this._toggleForm();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('click', (event) => {
|
||||
if (event.target.matches('.multi_update_table_action')) {
|
||||
const selectedItem = event.target;
|
||||
const ids = this._getSelectedIds();
|
||||
const form = document.getElementById('multi_update_form');
|
||||
const question = form.dataset['question'].replace(/%action%/, selectedItem.textContent).replace(/%count%/, ids.length.toString());
|
||||
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.getPlugin('alert');
|
||||
ALERT.question(question, function(value) {
|
||||
if (value) {
|
||||
form.attr('action', selectedVal).submit();
|
||||
} else {
|
||||
jQuery('#multi_update_table_action').val('').trigger('change');
|
||||
const form = document.getElementById('multi_update_form');
|
||||
form.action = selectedItem.dataset['href'];
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSelectedIds()
|
||||
_getSelectedIds()
|
||||
{
|
||||
let ids = [];
|
||||
jQuery('.multi_update_single:checked').each(function(i){
|
||||
ids[i] = $(this).val();
|
||||
});
|
||||
for (const box of document.querySelectorAll('input.multi_update_single:checked')) {
|
||||
ids.push(box.value);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
toggleForm()
|
||||
_toggleForm()
|
||||
{
|
||||
const ids = this.getSelectedIds();
|
||||
jQuery('#multi_update_table_entities').val(ids.join(','));
|
||||
const ids = this._getSelectedIds();
|
||||
document.getElementById('multi_update_table_entities').value = ids.join(',');
|
||||
|
||||
if (ids.length > 0) {
|
||||
jQuery('#multi_update_form').show();
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
element.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
document.getElementById('multi_update_form').style.display = null;//'block';
|
||||
} else {
|
||||
jQuery('#multi_update_form').hide();
|
||||
document.getElementById('multi_update_form').style.setProperty('display', 'none', 'important');
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
element.style.display = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
assets/js/plugins/KimaiNotification.js
Normal file
101
assets/js/plugins/KimaiNotification.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] Notification: notifications for Kimai
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiNotification extends KimaiPlugin {
|
||||
|
||||
getId()
|
||||
{
|
||||
return 'notification';
|
||||
}
|
||||
|
||||
isSupported()
|
||||
{
|
||||
if (!window.Notification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Notification.permission === "granted";
|
||||
}
|
||||
|
||||
request(callback)
|
||||
{
|
||||
try {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
callback(true);
|
||||
} else if (permission === "default") {
|
||||
callback(null);
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Notification.requestPermission((permission) => {
|
||||
if (permission === "granted") {
|
||||
callback(true);
|
||||
} else if (permission === "default") {
|
||||
callback(null);
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
notify(title, message, icon, options)
|
||||
{
|
||||
this.request((permission) => {
|
||||
|
||||
if (permission !== true) {
|
||||
/** @type KimaiAlert */
|
||||
const ALERT = this.getPlugin('alert');
|
||||
ALERT.info(message);
|
||||
}
|
||||
|
||||
let opts = {
|
||||
body: message,
|
||||
dir: this.getConfigurations().isRTL() ? 'rtl' : 'ltr',
|
||||
};
|
||||
//opts.requireInteraction = true;
|
||||
//opts.renotify = true;
|
||||
/*
|
||||
if (options.tag === undefined) {
|
||||
opts.tag = 'kimai';
|
||||
}
|
||||
*/
|
||||
if (icon !== undefined && icon !== null) {
|
||||
opts.icon = icon;
|
||||
}
|
||||
|
||||
let nTitle = 'Kimai';
|
||||
if (title !== null) {
|
||||
nTitle = nTitle + ': ' + title;
|
||||
}
|
||||
|
||||
if (options !== undefined && options !== null) {
|
||||
opts = { ...opts, ...options};
|
||||
}
|
||||
|
||||
const notification = new window.Notification(nTitle, opts);
|
||||
|
||||
notification.onclick = function () {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiPauseRecord
|
||||
*
|
||||
* allows to pause records
|
||||
* THIS IS JUST A DRAFT FOR THE DOM, IT IS NOT SUPPORTED IN KIMAI ITSELF!
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiPauseRecord extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.activate(this.selector);
|
||||
}
|
||||
|
||||
activate(selector) {
|
||||
jQuery(selector + ' .pull-left i').hover(function () {
|
||||
let link = jQuery(this).parents('a');
|
||||
link.attr('href', link.attr('href').replace('/stop', '/pause'));
|
||||
jQuery(this).removeClass('fa-stop-circle').addClass('fa-pause-circle').addClass('text-orange');
|
||||
},function () {
|
||||
let link = jQuery(this).parents('a');
|
||||
link.attr('href', link.attr('href').replace('/pause', '/stop'));
|
||||
jQuery(this).removeClass('fa-pause-circle').removeClass('text-orange').addClass('fa-stop-circle');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,32 +13,23 @@ import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiRecentActivities extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
getId()
|
||||
{
|
||||
return 'recent-activities';
|
||||
}
|
||||
|
||||
init() {
|
||||
const menu = document.querySelector(this.selector);
|
||||
init()
|
||||
{
|
||||
this.menu = document.querySelector('header .notifications-menu');
|
||||
// the menu can be hidden if user has no permissions to see it
|
||||
if (menu === null) {
|
||||
// or no timesheet was recorded yet
|
||||
if (this.menu === null || this.menu.dataset['reload'] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropdown = menu.querySelector('ul.dropdown-menu');
|
||||
|
||||
this.attributes = dropdown.dataset;
|
||||
this.itemList = dropdown.querySelector('li > ul.menu');
|
||||
|
||||
const self = this;
|
||||
const handle = function() { self.reloadRecentActivities(); };
|
||||
|
||||
// don't block initial browser rendering
|
||||
setTimeout(handle, 500);
|
||||
const handle = () => {
|
||||
this._reloadMenu(this.menu.dataset['reload']);
|
||||
};
|
||||
|
||||
document.addEventListener('kimai.recentActivities', handle);
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
@@ -49,45 +40,44 @@ export default class KimaiRecentActivities extends KimaiPlugin {
|
||||
document.addEventListener('kimai.projectDelete', handle);
|
||||
document.addEventListener('kimai.customerUpdate', handle);
|
||||
document.addEventListener('kimai.customerDelete', handle);
|
||||
|
||||
this._attachAddRemoveFavorite();
|
||||
}
|
||||
|
||||
emptyList() {
|
||||
this.itemList.innerHTML = '';
|
||||
}
|
||||
_attachAddRemoveFavorite()
|
||||
{
|
||||
[].slice.call(this.menu.querySelectorAll('a.list-group-item-actions')).map((element) => {
|
||||
element.addEventListener('click', (event) => {
|
||||
this._reloadMenu(event.currentTarget.href);
|
||||
|
||||
setEntries(entries) {
|
||||
if (entries.length === 0) {
|
||||
this.emptyList();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let htmlToInsert = '';
|
||||
|
||||
for (let timesheet of entries) {
|
||||
let label = this.attributes['template']
|
||||
.replace('%customer%', this.escape(timesheet.project.customer.name))
|
||||
.replace('%project%', this.escape(timesheet.project.name))
|
||||
.replace('%activity%', this.escape(timesheet.activity.name))
|
||||
;
|
||||
|
||||
htmlToInsert +=
|
||||
`<li>` +
|
||||
`<a href="${ this.attributes['href'].replace('000', timesheet.id) }" data-event="kimai.timesheetStart kimai.timesheetUpdate" class="api-link" data-method="PATCH" data-msg-error="timesheet.start.error" data-msg-success="timesheet.start.success">` +
|
||||
`<i class="${ this.attributes['icon'] }"></i> ${ label }` +
|
||||
`</a>` +
|
||||
`</li>`;
|
||||
}
|
||||
|
||||
this.itemList.innerHTML = htmlToInsert;
|
||||
}
|
||||
|
||||
reloadRecentActivities() {
|
||||
const self = this;
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(this.attributes['api'], {}, function(result) {
|
||||
self.setEntries(result);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_reloadMenu(url)
|
||||
{
|
||||
this.fetch(url, {method: 'GET'})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
//this.menu.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
return response.text().then(html => {
|
||||
const newFormHtml = document.createElement('div');
|
||||
newFormHtml.innerHTML = html;
|
||||
this.menu.replaceWith(newFormHtml.firstElementChild);
|
||||
|
||||
this.menu = document.querySelector('header .notifications-menu');
|
||||
this._attachAddRemoveFavorite();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
//this.menu.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,57 @@
|
||||
* [KIMAI] KimaiReducedClickHandler: abstract class
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiReducedClickHandler extends KimaiPlugin {
|
||||
|
||||
_addClickHandler(selector, callback) {
|
||||
jQuery('body').on('click', selector, function(event) {
|
||||
// just in case an inner element is editable, than this should not be triggered
|
||||
if (event.target.parentNode.isContentEditable || event.target.isContentEditable) {
|
||||
/**
|
||||
* No _underscore naming for now, as it would be mangled otherwise
|
||||
* @param selector
|
||||
* @param callback
|
||||
*/
|
||||
addClickHandler(selector, callback) {
|
||||
document.body.addEventListener('click', (event) => {
|
||||
// event.currentTarget is ALWAYS the body
|
||||
|
||||
let target = event.target;
|
||||
while (target !== null) {
|
||||
const tagName = target.tagName.toUpperCase();
|
||||
if (tagName === 'BODY') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.matches(selector)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// when an element is clicked, which can trigger stuff itself, we don't want the event to be processed
|
||||
if (tagName === 'A' || tagName === 'BUTTON' || tagName === 'INPUT' || tagName === 'LABEL') {
|
||||
return;
|
||||
}
|
||||
|
||||
target = target.parentNode;
|
||||
}
|
||||
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// handles the "click" on table rows or list elements
|
||||
let target = event.target;
|
||||
if (event.currentTarget.matches('tr') || event.currentTarget.matches('li')) {
|
||||
while (target !== null && !target.matches('body')) {
|
||||
// when an element within the row is clicked, that can trigger stuff itself, we don't want the event to be processed
|
||||
// don't act if a link, button or form element was clicked
|
||||
if (target.matches('a') || target.matches ('button') || target.matches ('input')) {
|
||||
return;
|
||||
}
|
||||
target = target.parentNode;
|
||||
}
|
||||
// just in case an inner element is editable, then this should not be triggered
|
||||
if (target.isContentEditable || target.parentNode.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.matches(selector)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let href = jQuery(this).attr('data-href');
|
||||
if (!href) {
|
||||
href = jQuery(this).attr('href');
|
||||
let href = target.dataset['href'];
|
||||
if (href === undefined || href === null) {
|
||||
href = target.href;
|
||||
}
|
||||
|
||||
if (href === undefined || href === null || href === '') {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiSelectDataAPI: <select> boxes with dynamic data from API
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import moment from 'moment';
|
||||
|
||||
export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'select-data-api';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.activateApiSelects(this.selector);
|
||||
}
|
||||
|
||||
activateApiSelects(selector) {
|
||||
const self = this;
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
jQuery('body').on('change', selector, function(event) {
|
||||
const targetSelect = '#' + this.dataset['relatedSelect'];
|
||||
|
||||
// if the related target select does not exist, we do not need to load the related data
|
||||
if (jQuery(targetSelect).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let formPrefix = this.dataset['formPrefix'];
|
||||
if (formPrefix === undefined || formPrefix === null) {
|
||||
formPrefix = '';
|
||||
} else if (formPrefix.length > 0) {
|
||||
formPrefix += '_';
|
||||
}
|
||||
|
||||
let newApiUrl = self._buildUrlWithFormFields(this.dataset['apiUrl'], formPrefix);
|
||||
|
||||
const selectValue = jQuery(this).val();
|
||||
|
||||
// Problem: select a project with activities and then select a customer that has no project
|
||||
// results in a wrong URL, it triggers "activities?project=" instead of using the "emptyUrl"
|
||||
if (selectValue === undefined || selectValue === null || selectValue === '' || (Array.isArray(selectValue) && selectValue.length === 0)) {
|
||||
if (this.dataset['emptyUrl'] === undefined) {
|
||||
self._updateSelect(targetSelect, {});
|
||||
jQuery(targetSelect).attr('disabled', 'disabled');
|
||||
return;
|
||||
}
|
||||
newApiUrl = self._buildUrlWithFormFields(this.dataset['emptyUrl'], formPrefix);
|
||||
}
|
||||
|
||||
jQuery(targetSelect).removeAttr('disabled');
|
||||
|
||||
API.get(newApiUrl, {}, function(data){
|
||||
self._updateSelect(targetSelect, data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_buildUrlWithFormFields(apiUrl, formPrefix) {
|
||||
let newApiUrl = apiUrl;
|
||||
|
||||
apiUrl.split('?')[1].split('&').forEach(item => {
|
||||
const [key, value] = item.split('=');
|
||||
const decoded = decodeURIComponent(value);
|
||||
const test = decoded.match(/%(.*)%/);
|
||||
if (test !== null) {
|
||||
const targetField = jQuery('#' + formPrefix + test[1]);
|
||||
let newValue = '';
|
||||
if (targetField.length === 0) {
|
||||
// happens for example:
|
||||
// - in duration only mode, when the end field is not found
|
||||
// console.log('ERROR: Cannot find field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
|
||||
} else {
|
||||
if (targetField.val() !== null) {
|
||||
newValue = targetField.val();
|
||||
|
||||
if (newValue !== '') {
|
||||
// having that special case here is far from being perfect... but for now it works ;-)
|
||||
if (targetField.data('daterangepicker') !== undefined) {
|
||||
if (key === 'begin' || key === 'start' || targetField.data('daterangepicker').singleDatePicker) {
|
||||
newValue = targetField.data('daterangepicker').startDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
} else if (key === 'end') {
|
||||
newValue = targetField.data('daterangepicker').endDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
}
|
||||
} else if (targetField.data('format') !== undefined) {
|
||||
if (moment(newValue, targetField.data('format')).isValid()) {
|
||||
newValue = moment(newValue, targetField.data('format')).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// happens for example:
|
||||
// - when the end date is not set on a timesheet record and the project list is loaded (as the URL contains the %end% replacer)
|
||||
// console.log('Empty value found for field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
|
||||
}
|
||||
} else {
|
||||
// happens for example:
|
||||
// - when a customer without projects is selected
|
||||
// console.log('ERROR: Empty field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(newValue)) {
|
||||
newValue = newValue.join(',');
|
||||
}
|
||||
|
||||
newApiUrl = newApiUrl.replace(value, newValue);
|
||||
}
|
||||
});
|
||||
|
||||
return newApiUrl;
|
||||
}
|
||||
|
||||
_updateSelect(selectName, data) {
|
||||
const options = {};
|
||||
for (const apiData of data) {
|
||||
let title = '__empty__';
|
||||
if (apiData.hasOwnProperty('parentTitle') && apiData.parentTitle !== null) {
|
||||
title = apiData.parentTitle;
|
||||
}
|
||||
if (!options.hasOwnProperty(title)) {
|
||||
options[title] = [];
|
||||
}
|
||||
options[title].push(apiData);
|
||||
}
|
||||
|
||||
const ordered = {};
|
||||
Object.keys(options).sort().forEach(function(key) {
|
||||
ordered[key] = options[key];
|
||||
});
|
||||
|
||||
/** @var {KimaiFormSelect} select */
|
||||
const select = this.getContainer().getPlugin('form-select');
|
||||
select.updateOptions(selectName, ordered);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,93 +9,80 @@
|
||||
* [KIMAI] KimaiThemeInitializer: initialize theme functionality
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import { Tooltip } from 'bootstrap';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
|
||||
init() {
|
||||
this.registerGlobalAjaxErrorHandler();
|
||||
this.registerAutomaticAlertRemove('div.alert-success', 5000);
|
||||
// activate the dropdown functionality
|
||||
jQuery('.dropdown-toggle').dropdown();
|
||||
// activate the tooltip functionality
|
||||
jQuery('[data-toggle="tooltip"]').tooltip();
|
||||
// activate all form plugins
|
||||
this.getContainer().getPlugin('form').activateForm('.content-wrapper form', 'body');
|
||||
this.getContainer().getPlugin('form').activateForm('form.searchform', 'body');
|
||||
init()
|
||||
{
|
||||
// the tooltip do not use data-bs-toggle="tooltip" so they can be mixed with data-toggle="modal"
|
||||
[].slice.call(document.querySelectorAll('[data-toggle="tooltip"]')).map(function (tooltipTriggerEl) {
|
||||
return new Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
this.registerModalAutofocus('#modal_search');
|
||||
this.registerModalAutofocus('#remote_form_modal');
|
||||
// activate all form plugins
|
||||
/** @type {KimaiForm} FORMS */
|
||||
const FORMS = this.getContainer().getPlugin('form');
|
||||
FORMS.activateForm('div.page-wrapper form');
|
||||
|
||||
this._registerModalAutofocus('#remote_form_modal');
|
||||
|
||||
this.overlay = null;
|
||||
|
||||
// register a global event listener, which displays an overlays upon notification
|
||||
document.addEventListener('kimai.reloadContent', (event) => {
|
||||
// do not allow more than one loading screen at a time
|
||||
if (this.overlay !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// at which element we append the loading screen
|
||||
let container = 'body';
|
||||
if (event.detail !== undefined && event.detail !== null) {
|
||||
container = event.detail;
|
||||
}
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = '<div class="overlay"><div class="fas fa-sync fa-spin"></div></div>';
|
||||
this.overlay = temp.firstElementChild;
|
||||
document.querySelector(container).append(this.overlay);
|
||||
});
|
||||
|
||||
// register a global event listener, which hides an overlay upon notification
|
||||
document.addEventListener('kimai.reloadedContent', () => {
|
||||
if (this.overlay !== null) {
|
||||
this.overlay.remove();
|
||||
this.overlay = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* workaround for autofocus attribute, as the modal "steals" it
|
||||
* Helps to set the autofocus on modals.
|
||||
*
|
||||
* @param {string} selector
|
||||
*/
|
||||
registerModalAutofocus(selector) {
|
||||
let modal = jQuery(selector);
|
||||
if (modal.length === 0) {
|
||||
_registerModalAutofocus(selector) {
|
||||
// on mobile you do not want to trigger the virtual keyboard upon modal open
|
||||
if (this.isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.on('shown.bs.modal', function () {
|
||||
let form = modal.find('form');
|
||||
let formAutofocus = form.find('[autofocus]');
|
||||
const modal = document.querySelector(selector);
|
||||
if (modal === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
const form = modal.querySelector('form');
|
||||
let formAutofocus = form.querySelectorAll('[autofocus]');
|
||||
if (formAutofocus.length < 1) {
|
||||
formAutofocus = form.find('input[type=text],textarea,select');
|
||||
formAutofocus = form.querySelectorAll('input[type=text],input[type=date],textarea,select');
|
||||
}
|
||||
formAutofocus.filter(':not("[data-datetimepicker=on]")').filter(':visible:first').focus().delay(1000).focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect access denied / session timeouts to login page
|
||||
*/
|
||||
registerGlobalAjaxErrorHandler() {
|
||||
const loginUrl = this.getConfiguration('login');
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
const translation = this.getContainer().getTranslation().get('login.required');
|
||||
jQuery(document).ajaxError(function(event, jqxhr, settings, thrownError) {
|
||||
if (jqxhr.status !== undefined && jqxhr.status === 403) {
|
||||
const loginRequired = jqxhr.getResponseHeader('login-required');
|
||||
if (loginRequired !== null) {
|
||||
alert.question(translation, function (result) {
|
||||
if (result === true) {
|
||||
window.location.replace(loginUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (formAutofocus.length > 0) {
|
||||
formAutofocus[0].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* auto hide success messages, as they are just meant as user feedback and not as a permanent information
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {integer} interval
|
||||
*/
|
||||
registerAutomaticAlertRemove(selector, interval) {
|
||||
const self = this;
|
||||
this._alertRemoveHandler = setInterval(
|
||||
function() {
|
||||
self.hideAlert(selector);
|
||||
},
|
||||
interval
|
||||
);
|
||||
}
|
||||
|
||||
unregisterAutomaticAlertRemove() {
|
||||
clearInterval(this._alertRemoveHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
*/
|
||||
hideAlert(selector) {
|
||||
jQuery(selector).alert('close');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
* [KIMAI] KimaiToolbar: some event listener to handle the toolbar/data-table filter, toolbar and navigation
|
||||
*/
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiToolbar extends KimaiPlugin {
|
||||
|
||||
constructor(formSelector, formSubmitActionClass) {
|
||||
super();
|
||||
this.formSelector = formSelector;
|
||||
this.actionClass = formSubmitActionClass;
|
||||
this._formSelector = formSelector;
|
||||
this._actionClass = formSubmitActionClass;
|
||||
}
|
||||
|
||||
getId() {
|
||||
@@ -26,50 +25,52 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
|
||||
init() {
|
||||
const formSelector = this.getSelector();
|
||||
const self = this;
|
||||
const EVENT = self.getContainer().getPlugin('event');
|
||||
|
||||
this._registerPagination(formSelector, EVENT);
|
||||
this._registerSortableTables(formSelector, EVENT);
|
||||
this._registerAlternativeSubmitActions(formSelector, this.actionClass);
|
||||
this._registerPagination(formSelector);
|
||||
this._registerSortableTables(formSelector);
|
||||
this._registerAlternativeSubmitActions(formSelector, this._actionClass);
|
||||
|
||||
// Reset the page if filter values are changed, otherwise we might end up with a limited set of data,
|
||||
// which does not support the given page - and it would be just wrong to stay in the same page
|
||||
jQuery(formSelector +' input').change(function (event) {
|
||||
switch (event.target.id) {
|
||||
case 'order':
|
||||
case 'orderBy':
|
||||
case 'page':
|
||||
break;
|
||||
default:
|
||||
jQuery(formSelector + ' input#page').val(1);
|
||||
break;
|
||||
}
|
||||
self.triggerChange();
|
||||
[].slice.call(document.querySelectorAll(formSelector + ' input')).map((element) => {
|
||||
element.addEventListener('change', (event) => {
|
||||
switch (event.target.id) {
|
||||
case 'order':
|
||||
case 'orderBy':
|
||||
case 'page':
|
||||
break;
|
||||
default:
|
||||
document.querySelector(formSelector + ' input#page').value = 1;
|
||||
break;
|
||||
}
|
||||
});
|
||||
this.triggerChange();
|
||||
});
|
||||
|
||||
|
||||
// when user selected a new customer or project, reset the pagination back to 1
|
||||
// and then find out if the results should be reloaded
|
||||
jQuery(formSelector + ' select').change(function (event) {
|
||||
let reload = true;
|
||||
switch (event.target.id) {
|
||||
case 'customer':
|
||||
if (jQuery(formSelector + ' select#project').length > 0) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
[].slice.call(document.querySelectorAll(formSelector + ' select')).map((element) => {
|
||||
element.addEventListener('change', (event) => {
|
||||
let reload = true;
|
||||
switch (event.target.id) {
|
||||
case 'customer':
|
||||
if (document.querySelector(formSelector + ' select#project') !== null) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'project':
|
||||
if (jQuery(formSelector + ' select#activity').length > 0) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
jQuery(formSelector + ' input#page').val(1);
|
||||
case 'project':
|
||||
if (document.querySelector(formSelector + ' select#activity') !== null) {
|
||||
reload = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
document.querySelector(formSelector + ' input#page').value = 1;
|
||||
|
||||
if (reload) {
|
||||
self.triggerChange();
|
||||
}
|
||||
if (reload) {
|
||||
this.triggerChange();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,15 +81,17 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
_registerAlternativeSubmitActions(toolbarSelector, actionBtnClass) {
|
||||
document.addEventListener('click', function(event) {
|
||||
let target = event.target;
|
||||
while (target !== null && !target.matches('body')) {
|
||||
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
|
||||
if (target.classList.contains(actionBtnClass)) {
|
||||
const form = document.querySelector(toolbarSelector);
|
||||
if (form === null) {
|
||||
return;
|
||||
}
|
||||
const prevAction = form.action;
|
||||
const prevMethod = form.method;
|
||||
form.target = '_blank';
|
||||
const prevAction = form.getAttribute('action');
|
||||
const prevMethod = form.getAttribute('method');
|
||||
if (target.dataset.target !== undefined) {
|
||||
form.target = target.dataset.target;
|
||||
}
|
||||
form.action = target.href;
|
||||
if (target.dataset.method !== undefined) {
|
||||
form.method = target.dataset.method;
|
||||
@@ -104,51 +107,65 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
|
||||
target = target.parentNode;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortable datatables use hidden fields in the toolbar filter/search form
|
||||
* @private
|
||||
*/
|
||||
_registerSortableTables(formSelector, EVENT) {
|
||||
jQuery('body').on('click', 'th.sortable', function(event){
|
||||
var $header = jQuery(event.target);
|
||||
var order = 'DESC';
|
||||
var orderBy = $header.data('order');
|
||||
if ($header.hasClass('sorting_desc')) {
|
||||
_registerSortableTables(formSelector) {
|
||||
document.body.addEventListener('click', (event) => {
|
||||
if (!event.target.matches('th.sortable')) {
|
||||
return;
|
||||
}
|
||||
let order = 'DESC';
|
||||
let orderBy = event.target.dataset['order'];
|
||||
if (event.target.classList.contains('sorting_desc')) {
|
||||
order = 'ASC';
|
||||
}
|
||||
|
||||
jQuery(formSelector + ' #orderBy').val(orderBy);
|
||||
jQuery(formSelector + ' #order').val(order);
|
||||
document.querySelector(formSelector + ' #orderBy').value = orderBy;
|
||||
document.querySelector(formSelector + ' #order').value = order;
|
||||
|
||||
// re-render the selectboxes
|
||||
jQuery(formSelector + ' #orderBy').trigger('change');
|
||||
jQuery(formSelector + ' #order').trigger('change');
|
||||
// re-render the selectbox
|
||||
document.querySelector(formSelector + ' #orderBy').dispatchEvent(new Event('change'));
|
||||
document.querySelector(formSelector + ' #order').dispatchEvent(new Event('change'));
|
||||
|
||||
// triggers the datatable reload - search for the event name
|
||||
EVENT.trigger('filter-change');
|
||||
document.dispatchEvent(new Event('filter-change'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This catches all clicks on the pagination and prevents the default action, as we want to reload the page via JS
|
||||
* This catches all clicks on the pagination and prevents the default action,
|
||||
* as we want to reload the page via JS.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_registerPagination(formSelector, EVENT) {
|
||||
jQuery('body').on('click', 'div.navigation ul.pagination li a', function(event) {
|
||||
let pager = jQuery(formSelector + " input#page");
|
||||
if (pager.length === 0) {
|
||||
_registerPagination(formSelector) {
|
||||
document.body.addEventListener('click', (event) => {
|
||||
if (!event.target.matches('ul.pagination li a') && (event.target.parentNode === null || !event.target.parentNode.matches('ul.pagination li a'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pager = document.querySelector(formSelector + " input#page");
|
||||
if (pager === null) {
|
||||
return;
|
||||
}
|
||||
let target = event.target;
|
||||
|
||||
// this happens for the arrows, which can be an icon <i> element
|
||||
if (!target.matches('a')) {
|
||||
target = target.parentNode;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let urlParts = jQuery(this).attr('href').split('/');
|
||||
let page = urlParts[urlParts.length-1];
|
||||
pager.val(page);
|
||||
pager.trigger('change');
|
||||
EVENT.trigger('pagination-change');
|
||||
let urlParts = target.href.split('/');
|
||||
pager.value = urlParts[urlParts.length-1];
|
||||
pager.dispatchEvent(new Event('change'));
|
||||
document.dispatchEvent(new Event('pagination-change'));
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -158,7 +175,7 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
* Triggers an event, that everyone can listen for.
|
||||
*/
|
||||
triggerChange() {
|
||||
this.getContainer().getPlugin('event').trigger('toolbar-change');
|
||||
document.dispatchEvent(new Event('toolbar-change'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +184,7 @@ export default class KimaiToolbar extends KimaiPlugin {
|
||||
* @returns {string}
|
||||
*/
|
||||
getSelector() {
|
||||
return this.formSelector;
|
||||
return this._formSelector;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
618
assets/js/widgets/KimaiCalendar.js
Normal file
618
assets/js/widgets/KimaiCalendar.js
Normal file
@@ -0,0 +1,618 @@
|
||||
/*
|
||||
* 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] KimaiCalendar: wrapping Fullcalendar.io
|
||||
*/
|
||||
import { Popover } from 'bootstrap';
|
||||
import { Calendar } from '@fullcalendar/core';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import bootstrap5Plugin, { BootstrapTheme } from '@fullcalendar/bootstrap5';
|
||||
import googlePlugin from '@fullcalendar/google-calendar';
|
||||
import iCalendarPlugin from '@fullcalendar/icalendar'
|
||||
import interactionPlugin, { Draggable } from '@fullcalendar/interaction';
|
||||
import arLocale from '@fullcalendar/core/locales/ar';
|
||||
import csLocale from '@fullcalendar/core/locales/cs';
|
||||
import daLocale from '@fullcalendar/core/locales/da';
|
||||
import deLocale from '@fullcalendar/core/locales/de';
|
||||
import deAtLocale from '@fullcalendar/core/locales/de-at';
|
||||
import elLocale from '@fullcalendar/core/locales/el';
|
||||
import esLocale from '@fullcalendar/core/locales/es';
|
||||
import euLocale from '@fullcalendar/core/locales/eu';
|
||||
import faLocale from '@fullcalendar/core/locales/fa';
|
||||
import fiLocale from '@fullcalendar/core/locales/fi';
|
||||
import frLocale from '@fullcalendar/core/locales/fr';
|
||||
import heLocale from '@fullcalendar/core/locales/he';
|
||||
import hrLocale from '@fullcalendar/core/locales/hr';
|
||||
import huLocale from '@fullcalendar/core/locales/hu';
|
||||
import itLocale from '@fullcalendar/core/locales/it';
|
||||
import jaLocale from '@fullcalendar/core/locales/ja';
|
||||
import koLocale from '@fullcalendar/core/locales/ko';
|
||||
import nbLocale from '@fullcalendar/core/locales/nb';
|
||||
import nlLocale from '@fullcalendar/core/locales/nl';
|
||||
import plLocale from '@fullcalendar/core/locales/pl';
|
||||
import ptLocale from '@fullcalendar/core/locales/pt';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import roLocale from '@fullcalendar/core/locales/ro';
|
||||
import ruLocale from '@fullcalendar/core/locales/ru';
|
||||
import skLocale from '@fullcalendar/core/locales/sk';
|
||||
import svLocale from '@fullcalendar/core/locales/sv';
|
||||
import trLocale from '@fullcalendar/core/locales/tr';
|
||||
import zhLocale from '@fullcalendar/core/locales/zh-cn';
|
||||
import viLocale from '@fullcalendar/core/locales/vi';
|
||||
import enGbLocale from '@fullcalendar/core/locales/en-gb';
|
||||
import enUsLocale from '@fullcalendar/core/locales/en-gb';
|
||||
import KimaiColor from './KimaiColor';
|
||||
import KimaiContextMenu from "./KimaiContextMenu";
|
||||
|
||||
export default class KimaiCalendar {
|
||||
|
||||
/**
|
||||
* Options is a huge JSON object.
|
||||
*
|
||||
* @param {KimaiContainer} kimai
|
||||
* @param {HTMLElement} element
|
||||
* @param {Object} options
|
||||
*/
|
||||
constructor(kimai, element, options) {
|
||||
this.kimai = kimai;
|
||||
this.options = options;
|
||||
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.kimai.getPlugin('api');
|
||||
/** @type {KimaiDateUtils} DATES */
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
/** @type {KimaiAjaxModalForm} MODAL */
|
||||
const MODAL = this.kimai.getPlugin('modal');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
|
||||
let initialView = 'dayGridMonth';
|
||||
switch (options['initialView']) {
|
||||
case 'month':
|
||||
initialView = 'dayGridMonth';
|
||||
break;
|
||||
case 'agendaWeek':
|
||||
case 'week':
|
||||
initialView = 'timeGridWeek';
|
||||
break;
|
||||
case 'agendaDay':
|
||||
case 'day':
|
||||
initialView = 'timeGridDay';
|
||||
break;
|
||||
}
|
||||
|
||||
// Instead of using "buttonIcons" the theme needs to be adjusted directly
|
||||
// https://fullcalendar.io/docs/buttonIcons
|
||||
BootstrapTheme.prototype.classes = {
|
||||
root: 'fc-theme-bootstrap5',
|
||||
tableCellShaded: 'fc-theme-bootstrap5-shaded',
|
||||
buttonGroup: 'btn-group',
|
||||
button: 'btn btn-primary btn-icon', // required for Tabler
|
||||
buttonActive: 'active',
|
||||
popover: 'popover',
|
||||
popoverHeader: 'popover-header',
|
||||
popoverContent: 'popover-body',
|
||||
};
|
||||
BootstrapTheme.prototype.baseIconClass = ''; // required for Fontawesome
|
||||
BootstrapTheme.prototype.iconOverridePrefix = ''; // required for Fontawesome
|
||||
BootstrapTheme.prototype.iconClasses = {
|
||||
close: 'fa-times',
|
||||
prev: this.options['icons']['previous'],
|
||||
next: this.options['icons']['next'],
|
||||
prevYear: this.options['icons']['previousYear'],
|
||||
nextYear: this.options['icons']['nextYear'],
|
||||
};
|
||||
BootstrapTheme.prototype.rtlIconClasses = {
|
||||
prev: this.options['icons']['next'],
|
||||
next: this.options['icons']['previous'],
|
||||
prevYear: this.options['icons']['nextYear'],
|
||||
nextYear: this.options['icons']['previousYear'],
|
||||
};
|
||||
|
||||
let calendarOptions = {
|
||||
locales: [ enGbLocale, enUsLocale, arLocale, csLocale, daLocale, deLocale, deAtLocale, elLocale,
|
||||
esLocale, euLocale, faLocale, fiLocale, frLocale, heLocale, hrLocale, huLocale, itLocale, jaLocale, koLocale,
|
||||
nbLocale, nlLocale, plLocale, ptLocale, ptBrLocale, roLocale, ruLocale, skLocale, svLocale, trLocale, zhLocale, viLocale ],
|
||||
plugins: [ bootstrap5Plugin, dayGridPlugin, timeGridPlugin, googlePlugin, iCalendarPlugin, interactionPlugin ],
|
||||
initialView: initialView,
|
||||
// https://fullcalendar.io/docs/theming
|
||||
themeSystem: 'bootstrap5',
|
||||
// https://fullcalendar.io/docs/headerToolbar
|
||||
headerToolbar: {
|
||||
start: 'title',
|
||||
center: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
end: 'today prev,next'
|
||||
},
|
||||
direction: this.kimai.getConfiguration().get('direction'),
|
||||
locale: this.kimai.getConfiguration().getLanguage().toLowerCase(),
|
||||
|
||||
// https://fullcalendar.io/docs/height
|
||||
// auto makes the calendar too small
|
||||
// height: 'auto',
|
||||
height: '80vh',
|
||||
|
||||
// allow clicking e.g. week-numbers to change the view to this week
|
||||
navLinks: true,
|
||||
nowIndicator: true,
|
||||
weekends: this.options['showWeekends'],
|
||||
weekNumbers: this.options['showWeekNumbers'],
|
||||
weekNumberCalculation: 'ISO',
|
||||
firstDay: this.kimai.getConfiguration().getFirstDayOfWeek(true),
|
||||
|
||||
now: this.options['now'],
|
||||
businessHours: {
|
||||
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: this.options['businessTimeBegin'],
|
||||
endTime: this.options['businessTimeEnd']
|
||||
},
|
||||
slotDuration: this.options['slotDuration'],
|
||||
slotMinTime: this.options['timeframeBegin'] + ':00',
|
||||
slotMaxTime: this.options['timeframeEnd'] === '23:59' ? '24:00:00' : (this.options['timeframeEnd'] + ':59'),
|
||||
|
||||
// auto calculation seems to do the better job, therefor deactivated
|
||||
//slotLabelInterval: this.options['slotDuration'],
|
||||
|
||||
// how long should entries look like when they don't have an end
|
||||
defaultTimedEventDuration: this.options['slotDuration'],
|
||||
|
||||
// https://fullcalendar.io/docs/timeZone
|
||||
timeZone: this.options['timezone'],
|
||||
|
||||
// TODO implement me later on
|
||||
// https://fullcalendar.io/docs/validRange
|
||||
// limit to the users registration date or a configuration for the first day in job
|
||||
|
||||
// https://fullcalendar.io/docs/hiddenDays
|
||||
// once we can configure working days
|
||||
// hiddenDays: [ 2, 4 ]
|
||||
|
||||
// when we support holidays and other full day events
|
||||
// allDaySlot: false,
|
||||
// dropAccept
|
||||
|
||||
dayMaxEventRows: true,
|
||||
eventMaxStack: this.options['dayLimit'],
|
||||
dayMaxEvents: this.options['dayLimit'],
|
||||
|
||||
views: {
|
||||
dayGrid: {
|
||||
dayMaxEventRows: this.options['dayLimit']
|
||||
}
|
||||
},
|
||||
|
||||
// ============= POPOVER =============
|
||||
viewClassNames: () => {
|
||||
document.querySelector('.fc-dayGridMonth-button').classList.remove('btn-icon');
|
||||
document.querySelector('.fc-timeGridWeek-button').classList.remove('btn-icon');
|
||||
document.querySelector('.fc-timeGridDay-button').classList.remove('btn-icon');
|
||||
},
|
||||
|
||||
// DESTROY TO PREVENT MEMORY LEAKS
|
||||
eventWillUnmount: (unmountInfo) => {
|
||||
// this happens when a user drags an external event to the calendar (view: week and day) and moves it around
|
||||
// for some reason the "eventWillUnmount" is triggered for this "potential but not yet existing event"
|
||||
if (unmountInfo.event.source === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isKimaiSource(unmountInfo.event)) {
|
||||
return;
|
||||
}
|
||||
const popover = Popover.getInstance(unmountInfo.element);
|
||||
if (popover !== null) {
|
||||
popover.dispose();
|
||||
}
|
||||
},
|
||||
|
||||
// SHOW POPOVER FOR TIMESHEETS
|
||||
eventMouseEnter: (mouseEnterInfo) => {
|
||||
const event = mouseEnterInfo.event;
|
||||
|
||||
if (!this.isKimaiSource(event)) {
|
||||
// TODO allow to copy into kimai
|
||||
return;
|
||||
}
|
||||
|
||||
const element = mouseEnterInfo.el;
|
||||
let popover = Popover.getInstance(element);
|
||||
|
||||
if (popover === null) {
|
||||
// https://getbootstrap.com/docs/5.0/components/popovers/#options
|
||||
popover = new Popover(element, {
|
||||
title: DATES.getFormattedDate(event.start) + ' | ' + DATES.formatTime(event.start) + ' - ' + (event.end ? DATES.formatTime(event.end) : ''),
|
||||
placement: 'top',
|
||||
html: true,
|
||||
content: this.renderEventPopoverContent(event),
|
||||
trigger: 'focus',
|
||||
});
|
||||
}
|
||||
|
||||
popover.show();
|
||||
},
|
||||
|
||||
// HIDE POPOVER
|
||||
eventMouseLeave: (mouseLeaveInfo) => {
|
||||
if (!this.isKimaiSource(mouseLeaveInfo.event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hidePopover(mouseLeaveInfo.el);
|
||||
},
|
||||
|
||||
// ContextMenu
|
||||
eventDidMount: (arg) => {
|
||||
arg.el.addEventListener('contextmenu', (jsEvent) => {
|
||||
jsEvent.preventDefault();
|
||||
const event = arg.event;
|
||||
if (!event.allDay) {
|
||||
const url = this.options.url.actions(event.extendedProps.timesheet);
|
||||
API.get(url, {}, result => {
|
||||
const contextMenu = new KimaiContextMenu('calendar_contextMenu');
|
||||
contextMenu.createFromApi(jsEvent, result);
|
||||
}, (e) => { console.log('Failed to load actions for context menu', e); });
|
||||
}
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
// ============= DRAG & DROP =============
|
||||
|
||||
if (!this.hasPermission('punch') && this.hasPermission('create') && this.options.dragdrop !== undefined) {
|
||||
const draggableList = [].slice.call(document.querySelectorAll(this.options.dragdrop.container));
|
||||
draggableList.map((containerEl) => {
|
||||
return new Draggable(containerEl, {
|
||||
itemSelector: this.options.dragdrop.items
|
||||
});
|
||||
});
|
||||
|
||||
calendarOptions = {...calendarOptions, ...{
|
||||
droppable: true,
|
||||
// drop function handles external draggable events
|
||||
drop: (dropInfo) => {
|
||||
const entry = dropInfo.draggedEl;
|
||||
const source = entry.parentElement;
|
||||
let data = JSON.parse(entry.dataset.entry);
|
||||
|
||||
const urlReplacer = JSON.parse(source.dataset.routeReplacer);
|
||||
let apiUrl = source.dataset.route;
|
||||
|
||||
for (const [key, value] of Object.entries(urlReplacer)) {
|
||||
apiUrl = apiUrl.replace(key, data[value]);
|
||||
}
|
||||
|
||||
let begin = dropInfo.date;
|
||||
|
||||
if (dropInfo.view.type === 'dayGridMonth') {
|
||||
let defaultStartTime = this.options.defaultStartTime;
|
||||
if (defaultStartTime === null) {
|
||||
const now = new Date();
|
||||
defaultStartTime = (now.getHours() < 10 ? '0' : '') + now.getHours() + ':' + (now.getMinutes() < 10 ? '0' : '') + now.getMinutes();
|
||||
}
|
||||
begin = DATES.addHumanDuration(begin, defaultStartTime);
|
||||
}
|
||||
|
||||
let end = DATES.addHumanDuration(begin, this.options['slotDuration']);
|
||||
|
||||
if (!this.hasPermission('punch')) {
|
||||
if (this.hasPermission('edit_begin')) {
|
||||
data.begin = DATES.formatForAPI(begin);
|
||||
}
|
||||
if (this.hasPermission('edit_end')) {
|
||||
data.end = DATES.formatForAPI(end);
|
||||
}
|
||||
}
|
||||
|
||||
data = this.options.preparePayloadForUpdate(data);
|
||||
|
||||
if (source.dataset.method === 'PATCH') {
|
||||
API.patch(
|
||||
apiUrl,
|
||||
JSON.stringify(data),
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
ALERT.success('action.update.success');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
API.post(
|
||||
apiUrl,
|
||||
JSON.stringify(data),
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
ALERT.success('action.update.success');
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
}};
|
||||
}
|
||||
|
||||
// ============= CREATE NEW RECORDS =============
|
||||
|
||||
// After click or selection, not allowed for everyone
|
||||
if (!this.hasPermission('punch') && this.hasPermission('create')) {
|
||||
calendarOptions = {...calendarOptions, ...{
|
||||
dateClick: (dateClickInfo) => {
|
||||
// Day-clicks are always triggered, unless a selection was created.
|
||||
// So clicking in a day (month view) or any slot (week and day view) will trigger a dayClick
|
||||
// BEFORE triggering a select - make sure not two create dialogs are requested
|
||||
if (dateClickInfo.view.type !== 'dayGridMonth') {
|
||||
return;
|
||||
}
|
||||
|
||||
const createUrl = this.options.url.create(dateClickInfo.dateStr);
|
||||
MODAL.openUrlInModal(createUrl);
|
||||
},
|
||||
selectable: true,
|
||||
select: (selectionInfo) => {
|
||||
if(selectionInfo.view.type === 'dayGridMonth') {
|
||||
// Multi-day clicks are NOT allowed in the month view, as simple day clicks would also trigger
|
||||
// a select - there is no way to distinguish a simple click and a two-day selection
|
||||
return;
|
||||
}
|
||||
|
||||
const createUrl = this.options.url.create(selectionInfo.startStr, selectionInfo.endStr);
|
||||
MODAL.openUrlInModal(createUrl);
|
||||
},
|
||||
}};
|
||||
}
|
||||
|
||||
// ============= EDIT TIMESHEET =============
|
||||
|
||||
if (this.hasPermission('edit')) {
|
||||
calendarOptions = {...calendarOptions, ...{
|
||||
eventClick: (eventClickInfo) => {
|
||||
const event = eventClickInfo.event;
|
||||
if (!this.isKimaiSource(event)) {
|
||||
eventClickInfo.jsEvent.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.hidePopover(eventClickInfo.el);
|
||||
MODAL.openUrlInModal(this.options.url.edit(event.id));
|
||||
},
|
||||
}};
|
||||
|
||||
// UPDATE TIMESHEET - MOVE THEM OR EXTEND THEM
|
||||
if (!this.hasPermission('punch')) {
|
||||
calendarOptions = {...calendarOptions, ...{
|
||||
// https://fullcalendar.io/docs/event-dragging-resizing
|
||||
dragRevertDuration: 0,
|
||||
eventStartEditable: this.hasPermission('edit_begin'),
|
||||
eventDurationEditable: this.hasPermission('edit_end') || this.hasPermission('edit_duration'),
|
||||
eventDragStart: (info) => {
|
||||
this.hidePopover(info.el);
|
||||
},
|
||||
eventDrop: (eventDropInfo) => {
|
||||
this.changeHandler(eventDropInfo)
|
||||
},
|
||||
eventResizeStart: (info) => {
|
||||
this.hidePopover(info.el);
|
||||
},
|
||||
eventResize: (eventResizeInfo) => {
|
||||
this.changeHandler(eventResizeInfo)
|
||||
},
|
||||
}};
|
||||
}
|
||||
}
|
||||
|
||||
// ============= GOOGLE CALENDAR =============
|
||||
|
||||
if (this.options['googleCalendarApiKey'] !== undefined) {
|
||||
calendarOptions = {...calendarOptions, ...{
|
||||
// https://fullcalendar.io/docs/google-calendar
|
||||
googleCalendarApiKey: this.options['googleCalendarApiKey'],
|
||||
}};
|
||||
}
|
||||
|
||||
// ============= EVENT SOURCES =============
|
||||
|
||||
let eventSources = [];
|
||||
for (const source of this.options['eventSources']) {
|
||||
let calendarSource = {};
|
||||
if (source.type === 'timesheet') {
|
||||
calendarSource = {...calendarSource, ...{
|
||||
id: 'kimai-' + source.id,
|
||||
events: (fetchInfo, successCallback, failureCallback) => {
|
||||
let url = source.url.replace('{from}', DATES.formatForAPI(fetchInfo.start));
|
||||
url = url.replace('{to}', DATES.formatForAPI(fetchInfo.end));
|
||||
|
||||
API.get(url, {}, result => {
|
||||
let apiEvents = [];
|
||||
for (const record of result) {
|
||||
apiEvents.push(this.convertSourceForCalendar(record));
|
||||
}
|
||||
successCallback(apiEvents);
|
||||
}, failureCallback);
|
||||
},
|
||||
}};
|
||||
} else if (source.type === 'google') {
|
||||
calendarSource = {...calendarSource, ...{
|
||||
id: 'google-' + source.id,
|
||||
name: 'google',
|
||||
editable: false,
|
||||
}};
|
||||
} else if (source.type === 'ical') {
|
||||
calendarSource = {...calendarSource, ...{
|
||||
id: 'ical-' + source.id,
|
||||
url: source.url,
|
||||
format: 'ics',
|
||||
editable: false,
|
||||
}};
|
||||
} else {
|
||||
console.log('Unknown source type given, skipping to load events from: ' + source.id);
|
||||
continue;
|
||||
}
|
||||
if (source.options !== undefined) {
|
||||
calendarSource = {...calendarSource, ...source.options};
|
||||
}
|
||||
eventSources.push(calendarSource);
|
||||
}
|
||||
|
||||
if (eventSources.length > 0) {
|
||||
calendarOptions = {...calendarOptions, ...{
|
||||
eventSources: eventSources,
|
||||
}};
|
||||
}
|
||||
|
||||
// INITIALIZE CALENDAR
|
||||
this.calendar = new Calendar(element, calendarOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventApi} event
|
||||
* @return {boolean}
|
||||
* @private
|
||||
*/
|
||||
isKimaiSource(event) {
|
||||
if (event === null) {
|
||||
return false;
|
||||
}
|
||||
if (event.source === null) {
|
||||
return false;
|
||||
}
|
||||
return (event.source.id.indexOf('kimai-') === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @return {boolean}
|
||||
* @private
|
||||
*/
|
||||
hasPermission(name) {
|
||||
return this.options['permissions'][name];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Calendar}
|
||||
*/
|
||||
getCalendar() {
|
||||
return this.calendar;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.calendar.render();
|
||||
}
|
||||
|
||||
reloadEvents() {
|
||||
this.calendar.getEventSources().forEach(source => source.refetch());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} apiItem
|
||||
* @return {{activity, color: *, start, description, project, end, id, title: *, textColor: *, customer, tags: ([number,number,[],string,string]|*)}}
|
||||
* @private
|
||||
*/
|
||||
convertSourceForCalendar(apiItem) {
|
||||
const defaultColor = this.kimai.getConfiguration().get('defaultColor');
|
||||
let color = apiItem.activity.color;
|
||||
if (color === null || color === defaultColor) {
|
||||
color = apiItem.project.color;
|
||||
if (color === null || color === defaultColor) {
|
||||
color = apiItem.project.customer.color;
|
||||
}
|
||||
}
|
||||
if (color == null) {
|
||||
color = defaultColor;
|
||||
}
|
||||
|
||||
let title = this.options['patterns']['title'];
|
||||
title = title.replace('{project}', apiItem.project.name);
|
||||
title = title.replace('{customer}', apiItem.project.customer.name);
|
||||
title = title.replace('{description}', apiItem.description ?? '');
|
||||
title = title.replace('{activity}', apiItem.activity.name ?? '');
|
||||
|
||||
if (title === '' || title === null) {
|
||||
title = apiItem.activity.name;
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id,
|
||||
timesheet: apiItem.id,
|
||||
title: title,
|
||||
description: apiItem.description,
|
||||
start: apiItem.begin,
|
||||
end: apiItem.end,
|
||||
activity: apiItem.activity.name,
|
||||
project: apiItem.project.name,
|
||||
customer: apiItem.project.customer.name,
|
||||
tags: apiItem.tags,
|
||||
color: color,
|
||||
textColor: KimaiColor.calculateContrastColor(color),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventApi} event
|
||||
* @return {string}
|
||||
* @private
|
||||
*/
|
||||
renderEventPopoverContent(event) {
|
||||
const eventObj = event.extendedProps;
|
||||
/** @type {KimaiEscape} escaper */
|
||||
const escaper = this.kimai.getPlugin('escape');
|
||||
|
||||
return `
|
||||
<div class="calendar-entry">
|
||||
<ul>
|
||||
<li>` + this.options['translations']['customer'] + `: ` + escaper.escapeForHtml(eventObj.customer) + `</li>
|
||||
<li>` + this.options['translations']['project'] + `: ` + escaper.escapeForHtml(eventObj.project) + `</li>
|
||||
<li>` + this.options['translations']['activity'] + `: ` + escaper.escapeForHtml(eventObj.activity) + `</li>
|
||||
</ul>` +
|
||||
(eventObj.description !== null || eventObj.tags.length > 0 ? '<hr>' : '') +
|
||||
(eventObj.description ? '<p>' + eventObj.description + '</p>' : '') +
|
||||
(eventObj.tags !== null && eventObj.tags.length > 0 ? '<span class="badge bg-green">' + eventObj.tags.join('</span> <span class="badge bg-green">') + '</span>' : '') + `
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @private
|
||||
*/
|
||||
hidePopover(element) {
|
||||
let popover = Popover.getInstance(element);
|
||||
|
||||
if (popover !== null) {
|
||||
popover.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventDropArg} eventArg
|
||||
* @private
|
||||
*/
|
||||
changeHandler(eventArg) {
|
||||
/** @type {EventApi} event */
|
||||
const event = eventArg.event;
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.kimai.getPlugin('api');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
/** @type {KimaiDateUtils} DATE */
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
|
||||
let payload = {'begin': DATES.formatForAPI(event.start)};
|
||||
|
||||
if (event.end !== null && event.end !== undefined) {
|
||||
payload.end = DATES.formatForAPI(event.end);
|
||||
} else {
|
||||
payload.end = null;
|
||||
}
|
||||
|
||||
const updateUrl = this.options.url.update(event.id);
|
||||
API.patch(updateUrl, JSON.stringify(payload), () => {
|
||||
ALERT.success('action.update.success');
|
||||
}, (error) => {
|
||||
eventArg.revert();
|
||||
API.handleError('action.update.error', error);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
38
assets/js/widgets/KimaiColor.js
Normal file
38
assets/js/widgets/KimaiColor.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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] KimaiColor: handle colors
|
||||
*/
|
||||
|
||||
export default class KimaiColor {
|
||||
|
||||
/**
|
||||
* @param {string} hexcolor
|
||||
* @return {string}
|
||||
*/
|
||||
static calculateContrastColor(hexcolor)
|
||||
{
|
||||
if (hexcolor.slice(0, 1) === '#') {
|
||||
hexcolor = hexcolor.slice(1);
|
||||
}
|
||||
|
||||
if (hexcolor.length === 3) {
|
||||
hexcolor = hexcolor.split('').map(function (hex) { return hex + hex; }).join('');
|
||||
}
|
||||
|
||||
const r = parseInt(hexcolor.substring(0,2),16);
|
||||
const g = parseInt(hexcolor.substring(2,4),16);
|
||||
const b = parseInt(hexcolor.substring(4,6),16);
|
||||
|
||||
// https://gomakethings.com/dynamically-changing-the-text-color-based-on-background-color-contrast-with-vanilla-js/
|
||||
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
||||
|
||||
return (yiq >= 128) ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
}
|
||||
139
assets/js/widgets/KimaiContextMenu.js
Normal file
139
assets/js/widgets/KimaiContextMenu.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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] KimaiContextMenu: help to create, position and display context menus
|
||||
*/
|
||||
|
||||
export default class KimaiContextMenu {
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
constructor(id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
getContextMenuElement()
|
||||
{
|
||||
if (document.getElementById(this.id) === null) {
|
||||
const temp = document.createElement('div');
|
||||
temp.id = this.id;
|
||||
temp.classList.add('dropdown-menu', 'd-none');
|
||||
document.body.appendChild(temp);
|
||||
}
|
||||
|
||||
return document.getElementById(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
* @param {object} json
|
||||
*/
|
||||
createFromApi(event, json)
|
||||
{
|
||||
let html = '';
|
||||
|
||||
for (const options of json) {
|
||||
if (options['divider'] === true) {
|
||||
html += '<div class="dropdown-divider"></div>';
|
||||
}
|
||||
|
||||
if (options['url'] !== null) {
|
||||
html += '<a class="dropdown-item ' + (options['class'] !== null ? options['class'] : '') + '" href="' + options['url'] + '"';
|
||||
|
||||
if (options['attr'] !== undefined) {
|
||||
for (const attrName in options['attr']) {
|
||||
html += ' ' + attrName + '="' + options['attr'][attrName].replaceAll('"', '"') + '"';
|
||||
}
|
||||
}
|
||||
html += '>' + options['title'] + '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
this.createFromClickEvent(event, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
* @param {string} html
|
||||
*/
|
||||
createFromClickEvent(event, html)
|
||||
{
|
||||
const dropdownElement = this.getContextMenuElement();
|
||||
|
||||
dropdownElement.style.zIndex = '1021'; // stay on top of sticky elements (like table header)
|
||||
dropdownElement.innerHTML = html;
|
||||
dropdownElement.style.position = 'fixed';
|
||||
dropdownElement.style.top = (event.clientY) + 'px';
|
||||
dropdownElement.style.left = (event.clientX) + 'px';
|
||||
|
||||
const dropdownListener = () => {
|
||||
dropdownElement.classList.remove('d-block');
|
||||
if (!dropdownElement.classList.contains('d-none')) {
|
||||
dropdownElement.classList.add('d-none');
|
||||
}
|
||||
dropdownElement.removeEventListener('click', dropdownListener);
|
||||
document.removeEventListener('click', dropdownListener);
|
||||
}
|
||||
|
||||
dropdownElement.addEventListener('click', dropdownListener);
|
||||
document.addEventListener('click', dropdownListener);
|
||||
|
||||
dropdownElement.classList.remove('d-none');
|
||||
if (!dropdownElement.classList.contains('d-block')) {
|
||||
dropdownElement.classList.add('d-block');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
*/
|
||||
static createForDataTable(selector)
|
||||
{
|
||||
[].slice.call(document.querySelectorAll(selector)).map((dataTable) => {
|
||||
const actions = dataTable.querySelector('td.actions div.dropdown-menu');
|
||||
if (actions === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataTable.addEventListener('contextmenu', (jsEvent) => {
|
||||
let target = jsEvent.target;
|
||||
while (target !== null) {
|
||||
const tagName = target.tagName.toUpperCase();
|
||||
if (tagName === 'TH' || tagName === 'TABLE' || tagName === 'BODY') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagName === 'TR') {
|
||||
break;
|
||||
}
|
||||
|
||||
target = target.parentNode;
|
||||
}
|
||||
|
||||
if (target === null || !target.matches('table.dataTable tbody tr')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = target.querySelector('td.actions div.dropdown-menu');
|
||||
if (actions === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsEvent.preventDefault();
|
||||
|
||||
const contextMenu = new KimaiContextMenu(dataTable.dataset['contextMenu']);
|
||||
contextMenu.createFromClickEvent(jsEvent, actions.innerHTML);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +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] KimaiCookies: simple wrapper to handle cookies
|
||||
*/
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export default class KimaiCookies {
|
||||
|
||||
static set(name, values, options) {
|
||||
Cookies.set(name, values, options);
|
||||
}
|
||||
|
||||
static get(name) {
|
||||
return Cookies.getJSON(name);
|
||||
}
|
||||
|
||||
static remove(name) {
|
||||
Cookies.remove(name);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,32 +9,42 @@
|
||||
* [KIMAI] KimaiPaginatedBoxWidget: handles box widgets that have a pagination
|
||||
*/
|
||||
|
||||
import jQuery from "jquery";
|
||||
import KimaiContextMenu from "./KimaiContextMenu";
|
||||
|
||||
export default class KimaiPaginatedBoxWidget {
|
||||
|
||||
constructor(boxId) {
|
||||
this.selector = boxId;
|
||||
this.overlay = jQuery('<div class="overlay"><div class="fas fa-sync fa-spin"></div></div>');
|
||||
this.widget = jQuery(this.selector);
|
||||
this.href = this.widget.data('href');
|
||||
this.events = this.widget.data('reload').split(' ');
|
||||
const widget = document.querySelector(this.selector);
|
||||
this.href = widget.dataset['href'];
|
||||
|
||||
const self = this;
|
||||
if (widget.dataset['reload'] !== undefined) {
|
||||
this.events = widget.dataset['reload'].split(' ');
|
||||
const reloadPage = () => {
|
||||
let url = null;
|
||||
if (document.querySelector(this.selector).dataset['reloadHref'] !== undefined) {
|
||||
url = document.querySelector(this.selector).dataset['reloadHref'];
|
||||
} else {
|
||||
url = document.querySelector(this.selector + ' ul.pagination li.active a').href;
|
||||
}
|
||||
this.loadPage(url);
|
||||
};
|
||||
|
||||
const reloadPage = function (event) {
|
||||
const page = jQuery(self.selector + ' .box-tools').data('page');
|
||||
const url = self._buildUrl(page);
|
||||
self.loadPage(url);
|
||||
};
|
||||
|
||||
for (const eventName of this.events) {
|
||||
document.addEventListener(eventName, reloadPage);
|
||||
for (const eventName of this.events) {
|
||||
document.addEventListener(eventName, reloadPage);
|
||||
}
|
||||
}
|
||||
|
||||
this.widget.on('click', '.box-tools ul.pagination a', function (event) {
|
||||
event.preventDefault();
|
||||
self.loadPage(jQuery(event.currentTarget).attr('href'));
|
||||
document.body.addEventListener('click', (event) => {
|
||||
let link = event.target;
|
||||
// could be an icon
|
||||
if (!link.matches(this.selector + ' a.pagination-link')) {
|
||||
link = link.parentNode;
|
||||
}
|
||||
if (link.matches(this.selector + ' a.pagination-link')) {
|
||||
event.preventDefault();
|
||||
this.loadPage(link.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,56 +52,54 @@ export default class KimaiPaginatedBoxWidget {
|
||||
return new KimaiPaginatedBoxWidget(elementId);
|
||||
}
|
||||
|
||||
_showOverlay() {
|
||||
this.widget.append(this.overlay);
|
||||
}
|
||||
|
||||
_hideOverlay() {
|
||||
jQuery(this.overlay).remove();
|
||||
}
|
||||
|
||||
loadPage(url) {
|
||||
const self = this;
|
||||
const selector = this.selector;
|
||||
|
||||
self._showOverlay();
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
data: {},
|
||||
success: function (response) {
|
||||
const html = jQuery(response);
|
||||
jQuery(selector + ' .box-tools').replaceWith(html.find('.box-tools'));
|
||||
jQuery(selector + ' .box-body').replaceWith(html.find('.box-body'));
|
||||
jQuery(selector + ' .box-title').replaceWith(html.find('.box-title'));
|
||||
if (jQuery(selector + ' .box-footer').length > 0) {
|
||||
jQuery(selector + ' .box-footer').replaceWith(html.find('.box-footer'));
|
||||
}
|
||||
jQuery(selector + ' .box-body [data-toggle="tooltip"]').tooltip();
|
||||
self.widget.removeData('error-retry');
|
||||
self._hideOverlay();
|
||||
},
|
||||
dataType: 'html',
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
if (jqXHR.status !== undefined && jqXHR.status === 500) {
|
||||
if (self.widget.data('error-retry') !== undefined) {
|
||||
// TODO show error message ?
|
||||
return;
|
||||
}
|
||||
const page = jQuery(selector + ' .box-tools').data('page');
|
||||
if (page > 1) {
|
||||
self.widget.data('error-retry', 1);
|
||||
var url = self._buildUrl(page - 1);
|
||||
self.loadPage(url);
|
||||
}
|
||||
}
|
||||
self._hideOverlay();
|
||||
// this event will render a spinning loader
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent', {detail: this.selector}));
|
||||
|
||||
// and this event will hide it afterwards
|
||||
const hideOverlay = () => {
|
||||
document.dispatchEvent(new Event('kimai.reloadedContent'));
|
||||
}
|
||||
|
||||
window.kimai.getPlugin('fetch').fetch(url)
|
||||
.then(response => {
|
||||
response.text().then((text) => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = text;
|
||||
// previously the parts .card-header .card-body .card-title .card-footer were replaced
|
||||
// but the layout allows eg. ".list-group .list-group-flush" instead of .card-body
|
||||
// so we directly replace the entire HTML
|
||||
// the HTML needs to be parsed for script tags, which can be included (e.g. paginated chart widget)
|
||||
document.querySelector(selector).replaceWith(this._makeScriptExecutable(temp.firstElementChild));
|
||||
KimaiContextMenu.createForDataTable(selector + ' table.dataTable');
|
||||
hideOverlay();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// this is not yet a plugin, so the alert is not available here
|
||||
window.kimai.getPlugin('alert').error('Failed loading selected page');
|
||||
hideOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
_buildUrl(page) {
|
||||
return this.href.replace('1', page);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,21 +9,14 @@
|
||||
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
|
||||
*/
|
||||
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiReloadPageWidget {
|
||||
|
||||
constructor(events, fullReload) {
|
||||
this.overlay = jQuery('<div class="overlay-wrapper"><div class="overlay"><div class="fas fa-sync fa-spin"></div></div></div>');
|
||||
this.widget = jQuery('div.content-wrapper');
|
||||
|
||||
const self = this;
|
||||
|
||||
const reloadPage = function (event) {
|
||||
const reloadPage = () => {
|
||||
if (fullReload) {
|
||||
document.location.reload(true);
|
||||
document.location.reload();
|
||||
} else {
|
||||
self.loadPage(document.location);
|
||||
this._loadPage(document.location);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,34 +33,31 @@ export default class KimaiReloadPageWidget {
|
||||
}
|
||||
|
||||
_showOverlay() {
|
||||
this.widget.append(this.overlay);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent', {detail: 'div.page-wrapper'}));
|
||||
}
|
||||
|
||||
_hideOverlay() {
|
||||
jQuery(this.overlay).remove();
|
||||
}
|
||||
|
||||
loadPage(url) {
|
||||
const self = this;
|
||||
|
||||
self._showOverlay();
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
data: {},
|
||||
success: function (response) {
|
||||
jQuery('section.content').replaceWith(
|
||||
jQuery(response).find('section.content')
|
||||
);
|
||||
document.dispatchEvent(new Event('kimai.reloadPage'));
|
||||
self._hideOverlay();
|
||||
},
|
||||
dataType: 'html',
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
self._hideOverlay();
|
||||
_hideOverlay() {
|
||||
document.dispatchEvent(new Event('kimai.reloadedContent'));
|
||||
}
|
||||
|
||||
_loadPage(url) {
|
||||
this._showOverlay();
|
||||
|
||||
window.kimai.getPlugin('fetch').fetch(url)
|
||||
.then(response => {
|
||||
response.text().then((text) => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = text;
|
||||
const newContent = temp.querySelector('section.content');
|
||||
document.querySelector('section.content').replaceWith(newContent);
|
||||
document.dispatchEvent(new Event('kimai.reloadPage'));
|
||||
this._hideOverlay();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this._hideOverlay();
|
||||
document.location = url;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user