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

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

View 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('"', '&quot;') + '"';
}
}
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);
});
});
}
}

View File

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

View File

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

View File

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