/* Use with: * * $('#edit-recipient-modal form').modalHandler({ ... }) */ class ModalHandler { constructor(form, options) { if (typeof options.csrf === "undefined") { throw new Error("ModalHandler::Missing CSRF token!"); } this.element = form; this.dialog = $(form).closest(".modal"); this.options = options; this.csrf = options.csrf; this.dontDisableSubmit = options.dontDisableSubmit; this.observer = new MutationObserver((list) => { this.bindFormValidation(); this.toggleFormSubmission(); this.initDataPatterns(); }); this.observer.observe(this.element[0], { childList: true, subtree: true }); this.initialState = null; this.currentState = null; this.firstCloseAttempt = false; this.isSubmitting = false; const submitButton = $(this.element).find(`[type='submit']`); if (submitButton.length == 0) { throw new Error("ModalHandler::The submit button was not found inside the form!"); } this.toggleFormSubmission(); } initDataPatterns() { NtopUtils.initDataPatterns(); } /** * Create a form's snapshot to save a form state */ createFormSnapshot() { const snapshot = { inputs: {}, hidden: [] }; $(this.element).find('textarea,select,input[type!="radio"]').each(function () { const type = $(this).prop('nodeName').toLowerCase(); const name = $(this).attr('name'); snapshot.inputs[`${type}[name='${name}']`] = $(this).val(); }); $(this.element).find(`[style='display: none;'], span.invalid-feedback`).each(function () { snapshot.hidden.push($(this)); }); return snapshot; } compareFormSnaphsot(s1, s2) { if (s1 == null || s2 == null) return true; for (let [key, value] of Object.entries(s1.inputs)) { if (s2.inputs[key] != value) return false; } return true; } delegateModalClosing() { const self = this; $(this.dialog).find('button.cancel').off('click').click(function () { self.firstCloseAttempt = false; $(self.element)[0].reportValidity(); $(self.dialog).find('.confirm-closing').fadeOut(100, function () { $(self.dialog).find('button.btn-close').fadeIn(100); }); }); $(this.dialog).off('hide.bs.modal').on('hide.bs.modal', function (event) { if (self.isSubmitting) { event.preventDefault(); return; } // if the form state hasn't changed then don't show the message if (self.compareFormSnaphsot(self.currentState, self.initialState)) { return; } if (self.firstCloseAttempt) return; // abort the modal closing event event.preventDefault(); // flag a close attempt has been invoked self.firstCloseAttempt = true; // show an alert to inform the user $(self.dialog).find('button.btn-close').fadeOut(100, function () { $(self.dialog).find('.confirm-closing').fadeIn(100); }); return; }); $(this.dialog).off('hidden.bs.modal').on('hidden.bs.modal', function (event) { // for each input inside the form restore the initial value // from the snapshot taken at init for (const [selector, value] of Object.entries(self.initialState.inputs)) { $(self.dialog).find(selector).val(value); $(self.dialog).find(selector).removeClass('is-invalid'); } // hide the shwon elements self.initialState.hidden.forEach(($hidden) => { $hidden.hide(); }); self.element.find(`[type='submit']`).attr("disabled", "disabled"); self.currentState = null; self.firstCloseAttempt = false; $(self.dialog).find('.confirm-closing').fadeOut(100, function () { $(self.dialog).find('button.btn-close').fadeIn(100); }); // clean the form when the modal is closed // to prevent the fields flickering self.cleanForm(); }); } fillFormModal() { return this.options.loadFormData(); } invokeModalInit(data = {}) { const self = this; // reset form values when the modal closes this.delegateModalClosing(); this.data = data || this.fillFormModal(); this.options.onModalInit(this.data, this); $(this.element).parents('.modal').on('show.bs.modal', function () { self.options.onModalShow(); }); // create a initial form snapshot to restore elements on closing this.initialState = this.createFormSnapshot(); this.currentState = null; this.delegateResetButton(); } delegateSubmit() { this.bindFormValidation(); const self = this; this.submitHandler = function (e) { if (!self.options.isSyncRequest) { e.preventDefault(); e.stopPropagation(); self.makeRequest(); } }; $(this.element).on('submit', this.submitHandler); } bindFormValidation() { const self = this; // handle input validation $(this.element).find(`input,select,textarea`).each(async function (i, input) { // jQuery object of the current input const $input = $(this); // id to handle the current timeout set to show errors let timeoutId = -1; const validHostname = async () => { // show the spinner to the user and set the input to readonly const $spinner = $input.parent().find('.spinner-border'); $input.attr("readonly", true); $spinner.show(); const response = await NtopUtils.resolveDNS($(input).val()); // hide the spinner and renable write to the input $input.removeAttr("readonly"); $spinner.hide(); // if the response was negative then alert the user if (response.rc < 0) { input.setCustomValidity(response.rc_str); return [false, response.rc_str_hr]; } // return success for valid resolved hostnmae input.setCustomValidity(""); return [true, "Success"]; } const validInput = async (validation) => { // if the input require to validate host name then perform a DNS resolve if (validation.data.resolveDNS && $input.val().match(NtopUtils.REGEXES.domainName)) { return await validHostname(); } if (validation.data.cannotBeEmpty && validation.isInputEmpty) { // trigger input validation flag input.setCustomValidity("Please fill the input."); return [false, validation.data.validationEmptyMessage || i18n_ext.missing_field]; } if (input.validity.patternMismatch) { input.setCustomValidity("Pattern mismatch."); return [false, validation.data.validationMessage || i18n_ext.invalid_field]; } if (input.validity.rangeOverflow) { input.setCustomValidity("Value exceed the maximum value."); return [false, validation.data.rangeOverflowMessage || i18n_ext.invalid_field]; } if (input.validity.rangeUnderflow) { input.setCustomValidity("Value is under the minimum value."); return [false, validation.data.rangeUnderflowMessage || i18n_ext.invalid_field]; } if (input.validity.typeMismatch) { input.setCustomValidity("Pattern mismatch."); return [false, validation.data.validationMessage || i18n_ext.invalid_field]; } // set validation to true input.setCustomValidity(""); return [true, "Success"]; } const checkValidation = async () => { const validation = { data: { validationMessage: $input.data('validationMessage'), validationEmptyMessage: $input.data('validationEmptyMessage'), cannotBeEmpty: ($input.attr('required') === "required") || ($input.data("validationNotEmpty") == true), resolveDNS: $input.data('validationResolvedns'), rangeOverflowMessage: $input.data('validationRangeOverflowMessage'), rangeUnderflowMessage: $input.data('validationUnderflowOverflowMessage'), }, isInputEmpty: (typeof($input.val()) === "string" ? $input.val().trim() == "" : false) }; const [isValid, messageToShow] = await validInput(validation); let $error = $input.parent().find(`.invalid-feedback`); // if the error element doesn't exist then create a new one if ($error.length == 0) { $error = $(``); } // display the errors and color the input box if (!isValid) { $input.addClass('is-invalid'); $input.parent().append($error); $error.text(messageToShow); } else { // clean the validation message and remove the error $input.removeClass('is-invalid'); $error.fadeOut(500, function () { $(this).remove(); }); } } $(this).off('input').on('input', function (e) { self.currentState = self.createFormSnapshot(); // if exists already a Timeout then clear it if (timeoutId != -1) clearTimeout(timeoutId); if (!$input.attr("formnovalidate")) { // trigger input validation after 300msec timeoutId = setTimeout(() => { checkValidation(); // trigger form validation to enable the submit button self.toggleFormSubmission(); }, 300); // the user has changed the input, we can abort the first close attempt self.firstCloseAttempt = false; } }); $(this).off('invalid').on('invalid', function (e) { e.preventDefault(); if (!$input.attr("formnovalidate")) { checkValidation(); } }); }); } getModalID() { return $(this.element).parents('.modal').attr('id'); } toggleFormSubmission() { let isValid = true; // if each input is marked as valid then enable the form submit button $(this.element).find('input:not(:disabled),select:not(:disabled),textarea:not(:disabled)').each(function (idx, input) { // make a concatenate & between valid flags isValid &= input.validity.valid; }); isValid ? $(this.element).find(`[type='submit'],[type='test']`).removeAttr("disabled") : $(this.element).find(`[type='submit'],[type='test']`).attr("disabled", "disabled"); } cleanForm() { /* remove validation class from fields */ $(this.element).find('input,textarea,select').each(function (i, input) { $(this).removeClass(`is-valid`).removeClass(`is-invalid`); }); /* reset all the values */ $(this.element)[0].reset(); } makeRequest() { const $feedbackLabel = $(this.element).find(`.invalid-feedback`); const submitButton = $(this.element).find(`[type='submit']`); let dataToSend = this.options.beforeSumbit(this.data); dataToSend.csrf = this.csrf; dataToSend = $.extend(dataToSend, this.options.submitOptions); /* clean previous state and disable button */ submitButton.attr("disabled", "disabled"); const self = this; if (this.options.endpoint) { let request; if (self.options.method == "post") { request = $.ajax({ url: this.options.endpoint, data: JSON.stringify(dataToSend), method: self.options.method, dataType: "json", contentType: "application/json; charset=utf-8" }); } else { request = $.get(this.options.endpoint, dataToSend); } this.isSubmitting = true; request.done(function (response, textStatus) { // clear submitting state self.isSubmitting = false; // clear the current form state self.currentState = null; if (self.options.resetAfterSubmit) self.cleanForm(); $feedbackLabel.hide(); const success = self.options.onSubmitSuccess(response, dataToSend, self); // if the submit return a true boolean then close the modal if (success) { if(self.dialog.modal) self.dialog.modal('hide') else { self.dialog[0].hidden = true; $(`.modal-backdrop.fade.show`).remove() } } /* unbind the old closure on submit event and bind a new one */ $(self.element).off('submit', self.submitHandler); self.delegateSubmit(); }) .fail(function (jqxhr, textStatus, errorThrown) { self.isSubmitting = false; const response = jqxhr.responseJSON; if (response.rc !== undefined && response.rc < 0) { $feedbackLabel.html(response.rc_str_hr).show(); } self.options.onSubmitError(response, dataToSend, textStatus, errorThrown); }) .always(function (d) { submitButton.removeAttr("disabled"); }); } else { // no endpoint // clear the current form state self.currentState = null; //if (self.options.resetAfterSubmit) self.cleanForm(); $feedbackLabel.hide(); const success = self.options.onSubmitSuccess({}, dataToSend, self); // if the submit return a true boolean then close the modal if (success) { if(self.dialog.modal) self.dialog.modal('hide'); else self.dialog[0].hidden = true; } /* unbind the old closure on submit event and bind a new one */ $(self.element).off('submit', self.submitHandler); self.delegateSubmit(); submitButton.removeAttr("disabled"); } } delegateResetButton() { const self = this; const resetButton = $(this.element).find(`[type='reset']`); if (resetButton.length == 0) return; const defaultValues = NtopUtils.serializeFormArray($(this.element).serializeArray()); resetButton.click(function (e) { e.preventDefault(); // reset the previous values $(self.element).find('input:visible,select').each(function (i, input) { const key = $(input).attr('name'); $(input).val(defaultValues[key]) .removeClass('is-invalid').removeClass('is-valid'); }); }); } } const modalHandler = function (args) { if (this.length != 1) throw new Error("Only a form element can by initialized!"); const options = $.extend({ csrf: '', endpoint: '', resetAfterSubmit: true, /* True to skip the are-you-sure check on the dialog */ dontDisableSubmit: false, /* True if the request isn't done by AJAX request */ isSyncRequest: false, method: 'get', /** * Fetch data asynchronusly from the server or * loads data directly from the current page. * The function must returns the fetched data. * * @returns Returns the fetched data. * @example Below there is an example showing * how to use the function when fetching data from the server * ``` * loadFormData: async function() { * const data = await fetch(`endpoint/to/data`); * const user = await data.json(); * return user; * } * ``` */ loadFormData: function () { }, /** * onModalInit() is invoked when the plugin has been initialized. * This function is used to load the fetched data from `loadFormData()` * inside the form modal inputs. * * @param {object} loadedData This argument contains the fetched data obtained * from `loadFormData()` * @example Below there is an example showing how to use * the function (we suppose that loadFormData() returns the following * object: `loadedUser = {firstname: 'Foo', lastname: 'Bar', id: 1428103}`) * ``` * onModalInit: function(loadedUser) { * $(`#userModal form input#firstname`).val(loadedUser.firstname); * $(`#userModal form input#lastname`).val(loadedUser.lastname); * $(`#userModal form input#id`).val(loadedUser.id); * } * ``` */ onModalInit: function (loadedData) { }, onModalShow: function () { }, /** * The function beforeSubmit() is invoked after the user * submit the form. The function must return the data to * send to the endpoint. If the chosen method is `post` * a csrf will be add to the returned object. * * @example We show below a simple example how to use the function: * ``` * beforeSubmit: function() { * const body = { * action: 'edit', * JSON: JSON.stringify(serializeArrayForm($(`form`).serializeArray())) * }; * return body; * } * ``` */ beforeSumbit: function () { return {} }, /** * This function is invoked when the request to the endpoint * terminates successfully (200). Before the call of this function * a new csrf retrived from the server will be set for * future calls. * * @param {object} response This object contains the response * from the server * * @example Below there is an example showing a simple user case: * ``` * onSubmitSuccess: function(response) { * if (response.success) { * console.log(`The user info has been edit with success!`); * } * } * ``` */ onSubmitSuccess: function (response) { }, /** * This function is invoked when the request to the endpoint * terminates with failure (!= 200). Before the call of this function * a new csrf retrived from the server will be set for * future calls. * * @param {object} sent This object contains the sent data to the endpoint * @param {string} textStatus It contains the error text status obtained * @param {object} errorThrown This object contains info about the error * * @example Below there is an example showing a simple user case: * ``` * onSubmitError: function(sent, textStatus, errorThrown) { * if (errorThrown) { * console.error(`Ops, something went wrong!`); * console.error(errorThrown); * } * } * ``` */ onSubmitError: function (sent, textStatus, errorThrown) { }, /** * This function is invoked when the user click the reset input * inside the form. * * @param {object} defaultData It contains the fetched data from * `loadFormData()`. * * @example Below there is an example how to use the function: * ``` * onModalReset: function(defaultData) { * $(`input#id`).val(defaultData.id); * $(`input#name`).val(defaultData.name); * $(`input#address`).val(defaultData.address); * } * ``` */ onModalReset: function (defaultData) { }, }, args); const mh = new ModalHandler(this, options); mh.delegateSubmit(); return mh; } export default modalHandler