-
+
' + msg + '
'); - } - 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 = '' + msg + '
'; + 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; } } diff --git a/assets/js/plugins/KimaiAlert.js b/assets/js/plugins/KimaiAlert.js index 5ef2a15ef..c29276f0a 100644 --- a/assets/js/plugins/KimaiAlert.js +++ b/assets/js/plugins/KimaiAlert.js @@ -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('