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

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

View File

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

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

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

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

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

View 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
{
}
}

View 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">&nbsp;</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">&nbsp;</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 = '&nbsp;';
} 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);
}
}

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

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