cerebrate/webroot/js/bootstrap-helper.js

1364 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/** Class containing common UI functionalities */
class UIFactory {
/**
* Create and display a toast
* @param {Object} options - The options to be passed to the Toaster class
* @return {Object} The Toaster object
*/
toast(options) {
const theToast = new Toaster(options);
theToast.makeToast()
theToast.show()
return theToast
}
/**
* Create and display a modal
* @param {Object} options - The options to be passed to the ModalFactory class
* @return {Object} The ModalFactory object
*/
modal(options) {
const theModal = new ModalFactory(options);
theModal.makeModal()
theModal.show()
return theModal
}
/**
* Create a popover
* @param {Object} element - The target element on which to attach the popover
* @param {Object} options - The options to be passed to the PopoverFactory class
* @return {Object} The PopoverFactory object
*/
popover(element, options) {
const thePopover = new PopoverFactory(element, options);
thePopover.makePopover()
return thePopover
}
/**
* Create and display a modal where the modal's content is fetched from the provided URL. Link an AJAXApi to the submission button
* @param {string} url - The URL from which the modal's content should be fetched
* @param {ModalFactory~POSTSuccessCallback} POSTSuccessCallback - The callback that handles successful form submission
* @param {ModalFactory~POSTFailCallback} POSTFailCallback - The callback that handles form submissions errors and validation errors.
* @param {Object=[]} modalOptions - Additional options to be passed to the modal constructor
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
async submissionModal(url, POSTSuccessCallback, POSTFailCallback, modalOptions={}) {
return AJAXApi.quickFetchURL(url).then((modalHTML) => {
const defaultOptions = {
rawHtml: modalHTML,
POSTSuccessCallback: POSTSuccessCallback !== undefined ? POSTSuccessCallback : () => {},
POSTFailCallback: POSTFailCallback !== undefined ? POSTFailCallback : (errorMessage) => {},
}
const options = Object.assign({}, defaultOptions, modalOptions)
const theModal = new ModalFactory(options);
theModal.makeModal()
theModal.show()
theModal.$modal.data('modalObject', theModal)
return [theModal, theModal.ajaxApi]
}).catch((error) => {
UI.toast({
variant: 'danger',
title: 'Error while loading the modal',
body: error.message
})
})
}
/**
* Create and display a modal where the modal's content is fetched from the provided URL
* @param {string} url - The URL from which the modal's content should be fetched
* @param {Object=[]} modalOptions - Additional options to be passed to the modal constructor
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
async modalFromUrl(url, modalOptions={}) {
return AJAXApi.quickFetchURL(url).then((modalHTML) => {
const defaultOptions = {
rawHtml: modalHTML,
}
const options = Object.assign({}, defaultOptions, modalOptions)
const theModal = new ModalFactory(options);
theModal.makeModal()
theModal.show()
theModal.$modal.data('modalObject', theModal)
return [theModal, theModal.ajaxApi]
}).catch((error) => {
UI.toast({
variant: 'danger',
title: 'Error while loading the modal',
body: error.message
})
})
}
/**
* Creates and displays a modal where the modal's content is fetched from the provided URL. Reloads the single page view after a successful operation.
* Supports `displayOnSuccess` option to show another modal after the submission
* @param {string} url - The URL from which the modal's content should be fetched
* @param {(boolean|string)} [reloadUrl=false] - The URL from which the data should be fetched after confirming
* @param {(jQuery|string)} [$table=false] - The table ID which should be reloaded on success
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
submissionModalForSinglePage(url, reloadUrl=false, $table=false) {
let $statusNode, $reloadedElement
if (reloadUrl === false) {
reloadUrl = location.pathname
}
if ($table === false) { // Try to get information from the DOM
const $elligibleTable = $('table[id^="single-view-table-"]')
const $container = $elligibleTable.closest('div[id^="single-view-table-container-"]')
$reloadedElement = $container
$statusNode = $elligibleTable
} else {
if ($table instanceof jQuery) {
$reloadedElement = $table
$statusNode = $table.find('table[id^="single-view-table-"]')
} else {
$reloadedElement = $(`single-view-table-container-${$table}`)
$statusNode = $(`single-view-table-${$table}`)
}
}
if ($reloadedElement.length == 0) {
UI.toast({
variant: 'danger',
title: 'Could not find element to be reloaded',
body: 'The content of this page may have changed and has not been reflected. Reloading the page is advised.'
})
return
}
return UI.submissionReloaderModal(url, reloadUrl, $reloadedElement, $statusNode);
}
getContainerForTable($table) {
const tableRandomID = $table.data('table-random-value')
return $table.closest(`#table-container-${tableRandomID}`)
}
/**
* Creates and displays a modal where the modal's content is fetched from the provided URL. Reloads the index table after a successful operation.
* Supports `displayOnSuccess` option to show another modal after the submission
* @param {string} url - The URL from which the modal's content should be fetched
* @param {(boolean|string)} [reloadUrl=false] - The URL from which the data should be fetched after confirming
* @param {(jQuery|string)} [$table=false] - The table ID which should be reloaded on success
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
submissionModalForIndex(url, reloadUrl=false, $table=false) {
let $statusNode, $reloadedElement
if (reloadUrl === false) {
const currentModel = location.pathname.split('/')[1]
if (currentModel.length > 0) {
reloadUrl = `/${currentModel}/index`
} else {
UI.toast({
variant: 'danger',
title: 'Could not find URL for the reload',
body: 'The content of this page may have changed and has not been reflected. Reloading the page is advised.'
})
return
}
}
if ($table === false) { // Try to get information from the DOM
const $elligibleTable = $('table.table')
const $container = $elligibleTable.closest('div[id^="table-container-"]')
$reloadedElement = $container
$statusNode = $elligibleTable
} else {
if ($table instanceof jQuery) {
$reloadedElement = this.getContainerForTable($table)
$statusNode = $table.find('table.table')
} else {
$reloadedElement = $(`#table-container-${$table}`)
$statusNode = $(`#table-container-${$table} table.table`)
}
}
if ($reloadedElement.length == 0) {
UI.toast({
variant: 'danger',
title: 'Could not find element to be reloaded',
body: 'The content of this page may have changed and has not been reflected. Reloading the page is advised.'
})
return
}
return UI.submissionReloaderModal(url, reloadUrl, $reloadedElement, $statusNode);
}
/**
* Creates and displays a modal where the modal's content is fetched from the provided URL. Reloads the index table after a successful operation.
* Supports `displayOnSuccess` option to show another modal after the submission
* @param {string} url - The URL from which the modal's content should be fetched
* @param {(boolean|string)} [reloadUrl=false] - The URL from which the data should be fetched after confirming
* @param {(jQuery|string)} [$table=false] - The table ID which should be reloaded on success
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
submissionModalAutoGuess(url, reloadUrl=false, $table=false) {
let currentAction = location.pathname.split('/')[2]
if (currentAction !== undefined) {
if (currentAction === 'index') {
return UI.submissionModalForIndex(url, reloadUrl, $table)
} else if (currentAction === 'view') {
return UI.submissionModalForSinglePage(url, reloadUrl, $table)
}
}
const successCallback = () => {
UI.toast({
variant: 'danger',
title: 'Could not reload the page',
body: 'Reloading the page manually is advised.'
})
}
return UI.submissionModal(url, successCallback)
}
/**
* Creates and displays a modal where the modal's content is fetched from the provided URL. Reloads the provided element after a successful operation.
* Supports `displayOnSuccess` option to show another modal after the submission
* @param {string} url - The URL from which the modal's content should be fetched
* @param {string} reloadUrl - The URL from which the data should be fetched after confirming
* @param {(jQuery|string)} $reloadedElement - The element which should be reloaded on success
* @param {(jQuery|string)} [$statusNode=null] - A reference to a HTML node on which the loading animation should be displayed. If not provided, $container will be used
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
submissionReloaderModal(url, reloadUrl, $reloadedElement, $statusNode=null) {
const successCallback = function ([data, modalObject]) {
UI.reload(reloadUrl, $reloadedElement, $statusNode)
if (data.additionalData !== undefined) {
if (data.additionalData.displayOnSuccess !== undefined) {
UI.modal({
rawHtml: data.additionalData.displayOnSuccess
})
} else if (data.additionalData.redirect !== undefined) {
window.location = data.additionalData.redirect
}
}
}
return UI.submissionModal(url, successCallback)
}
/**
* Fetch HTML from the provided URL and override the $container's content. $statusNode allows to specify another HTML node to display the loading
* @param {string} url - The URL from which the $container's content should be fetched
* @param {(jQuery|string)} $container - The container that should hold the data fetched
* @param {(jQuery|string)} [$statusNode=null] - A reference to a HTML node on which the loading animation should be displayed. If not provided, $container will be used
* @param {array} [additionalStatusNodes=[]] - A list of other node on which to apply overlay. Must contain the node and possibly the overlay configuration
* @return {Promise<jQuery>} Promise object resolving to the $container object after its content has been replaced
*/
reload(url, $container, $statusNode=null, additionalStatusNodes=[]) {
$container = $($container)
$statusNode = $($statusNode)
if (!$statusNode) {
$statusNode = $container
}
const otherStatusNodes = []
additionalStatusNodes.forEach(otherStatusNode => {
const loadingOverlay = new OverlayFactory(otherStatusNode.node, otherStatusNode.config)
loadingOverlay.show()
otherStatusNodes.push(loadingOverlay)
})
return AJAXApi.quickFetchURL(url, {
statusNode: $statusNode[0],
}).then((theHTML) => {
var $tmp = $(theHTML);
$container.replaceWith($tmp)
return $tmp;
}).finally(() => {
otherStatusNodes.forEach(overlay => {
overlay.hide()
})
})
}
/**
* Place an overlay onto a node and remove it whenever the promise resolves
* @param {(jQuery|string)} node - The node on which the overlay should be placed
* @param {Promise} promise - A promise to be fulfilled
* @param {Object} [overlayOptions={} - The options to be passed to the overlay class
* @return {Promise} Result of the passed promised
*/
overlayUntilResolve(node, promise, overlayOptions={}) {
const $node = $(node)
const loadingOverlay = new OverlayFactory($node[0], overlayOptions);
loadingOverlay.show()
promise.finally(() => {
loadingOverlay.hide()
})
return promise
}
/**
* Place an overlay onto a node and remove it whenever the promise resolves
* @param {(jQuery|string)} node - The node on which the confirm popover should be palced
* @param {Object} options - The options to be passed to the overlay class
* @return {Promise} Result of the passed promised
*/
quickConfirm(node, options={}) {
const $node = $(node)
const defaultOptions = {
title: 'Confirm action',
description: '',
descriptionHtml: false,
container: 'body',
variant: 'success',
confirmText: 'Confirm',
confirm: function() {}
}
options = Object.assign({}, defaultOptions, options)
options.description = options.descriptionHtml ? options.descriptionHtml : sanitize(options.description)
const popoverOptions = {
title: options.title,
titleHtml: options.titleHtml,
container: options.container,
html: true,
}
var promiseResolve, promiseReject;
const confirmPromise = new Promise(function (resolve, reject) {
promiseResolve = resolve;
promiseReject = reject;
})
popoverOptions.content = function() {
const $node = $(this)
const $container = $('<div>')
const $buttonCancel = $('<a class="btn btn-secondary btn-sm me-2">Cancel</a>')
.click(function() {
const popover = bootstrap.Popover.getInstance($node[0])
popover.dispose()
})
const $buttonSubmit = $(`<a class="submit-button btn btn-${options.variant} btn-sm">${options.confirmText}</a>`)
.click(function() {
options.confirm()
.then(function(result) {
promiseResolve(result)
})
.catch(function(error) {
promiseReject(error)
})
const popover = bootstrap.Popover.getInstance($node[0])
popover.dispose()
})
$container.append(`<p>${options.description}</p>`)
$container.append($(`<div>`).append($buttonCancel, $buttonSubmit))
return $container
}
const thePopover = this.popover($node, popoverOptions)
thePopover.show()
return confirmPromise // have to return a promise to avoid closing the modal
}
}
/** Class representing a Toast */
class Toaster {
/**
* Create a Toast.
* @param {Object} options - The options supported by Toaster#defaultOptions
*/
constructor(options) {
this.options = Object.assign({}, Toaster.defaultOptions, options)
if (this.options.delay == 'auto') {
this.options.delay = this.computeDelay()
}
this.bsToastOptions = {
autohide: this.options.autohide,
delay: this.options.delay,
}
}
/**
* @namespace
* @property {number} id - The ID to be used for the toast's container
* @property {string} title - The title's content of the toast
* @property {string} muted - The muted's content of the toast
* @property {string} body - The body's content of the toast
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the toast
* @property {boolean} autohide - If the toast show be hidden after some time defined by the delay
* @property {(number|string)} delay - The number of milliseconds the toast should stay visible before being hidden or 'auto' to deduce the delay based on the content
* @property {(jQuery|string)} titleHtml - The raw HTML title's content of the toast
* @property {(jQuery|string)} mutedHtml - The raw HTML muted's content of the toast
* @property {(jQuery|string)} bodyHtml - The raw HTML body's content of the toast
* @property {boolean} closeButton - If the toast's title should include a close button
*/
static defaultOptions = {
id: false,
title: false,
muted: false,
body: false,
variant: 'default',
autohide: true,
delay: 'auto',
titleHtml: false,
mutedHtml: false,
bodyHtml: false,
closeButton: true,
}
/** Create the HTML of the toast and inject it into the DOM */
makeToast() {
if (this.isValid()) {
this.$toast = Toaster.buildToast(this.options)
this.$toast.data('toastObject', this)
$('#mainToastContainer').append(this.$toast)
}
}
/** Display the toast to the user and remove it from the DOM once it get hidden */
show() {
if (this.isValid()) {
var that = this
this.$toast.toast(this.bsToastOptions)
.toast('show')
.on('hide.bs.toast', function (evt) {
const $toast = $(this)
const hoveredElements = $(':hover').filter(function() {
return $(this).is($toast)
});
if (hoveredElements.length > 0) {
evt.preventDefault()
setTimeout(() => {
$toast.toast('hide')
}, that.options.delay);
}
})
.on('hidden.bs.toast', function () {
that.removeToast()
})
}
}
/** Remove the toast from the DOM */
removeToast() {
this.$toast.remove();
}
/**
* Check wheter a toast is valid
* @return {boolean} Return true if the toast contains at least data to be rendered
*/
isValid() {
return this.options.title !== false || this.options.titleHtml !== false ||
this.options.muted !== false || this.options.mutedHtml !== false ||
this.options.body !== false || this.options.bodyHtml !== false
}
/**
* Build the toast HTML
* @param {Object} options - The options supported by Toaster#defaultOptions to build the toast
* @return {jQuery} The toast jQuery object
*/
static buildToast(options) {
var $toast = $('<div class="toast" role="alert" aria-live="assertive" aria-atomic="true"/>')
if (options.id !== false) {
$toast.attr('id', options.id)
}
$toast.addClass('toast-' + options.variant)
if (options.title !== false || options.titleHtml !== false || options.muted !== false || options.mutedHtml !== false || options.closeButton) {
var $toastHeader = $('<div class="toast-header"/>')
$toastHeader.addClass('toast-' + options.variant)
let $toastHeaderText = $('<span class="me-auto"/>')
if (options.titleHtml !== false) {
$toastHeaderText = $('<div class="me-auto"/>').html(options.titleHtml);
} else if (options.title !== false) {
$toastHeaderText = $('<strong class="me-auto"/>').text(options.title)
}
$toastHeader.append($toastHeaderText)
if (options.muted !== false || options.mutedHtml !== false) {
var $toastHeaderMuted
if (options.mutedHtml !== false) {
$toastHeaderMuted = $('<div/>').html(options.mutedHtml)
} else {
$toastHeaderMuted = $('<small class="text-muted"/>').text(options.muted)
}
$toastHeader.append($toastHeaderMuted)
}
if (options.closeButton) {
var $closeButton = $('<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>')
.click(function() {
$(this).closest('.toast').data('toastObject').removeToast()
})
$toastHeader.append($closeButton)
}
$toast.append($toastHeader)
}
if (options.body !== false || options.bodyHtml !== false) {
var $toastBody
if (options.bodyHtml !== false) {
$toastBody = $('<div class="toast-body"/>').html(options.bodyHtml)
} else {
$toastBody = $('<div class="toast-body"/>').append($('<div style="white-space: break-spaces;"/>').text(options.body))
}
$toast.append($toastBody)
}
return $toast
}
computeDelay() {
return 3000
+ 40*((this.options.title?.length ?? 0) + (this.options.body?.length ?? 0))
+ (['danger', 'warning'].includes(this.options.variant) ? 5000 : 0)
}
}
/** Class representing a Modal */
class ModalFactory {
/**
* Create a Modal.
* @param {Object} options - The options supported by ModalFactory#defaultOptions
*/
constructor(options) {
this.options = Object.assign({}, ModalFactory.defaultOptions, options)
if (options.POSTSuccessCallback !== undefined) {
if (!this.options.rawHtml) {
UI.toast({
variant: 'danger',
bodyHtml: '<b>POSTSuccessCallback</b> can only be used in conjuction with the <i>rawHtml</i> option. Instead, use the promise instead returned by the API call in <b>APIConfirm</b>.'
})
}
}
if (this.options.rawHtml) {
this.attachSubmitButtonListener = true
}
if (options.type === undefined && options.cancel !== undefined) {
this.options.type = 'confirm'
}
this.bsModalOptions = {
show: true
}
if (this.options.backdropStatic) {
this.bsModalOptions['backdrop'] = 'static'
}
this.ajaxApi = new AJAXApi()
}
/**
* @callback ModalFactory~closeModalFunction
*/
/**
* @callback ModalFactory~confirm
* @param {ModalFactory~confirm} closeFunction - A function that will close the modal if called
* @param {Object} modalFactory - The instance of the ModalFactory
* @param {Object} evt - The event that triggered the confirm operation
*/
/**
* @callback ModalFactory~cancel
* @param {ModalFactory~cancel} closeFunction - A function that will close the modal if called
* @param {Object} modalFactory - The instance of the ModalFactory
* @param {Object} evt - The event that triggered the confirm operation
*/
/**
* @callback ModalFactory~APIConfirm
* @param {AJAXApi} ajaxApi - An instance of the AJAXApi with the AJAXApi.statusNode linked to the modal confirm button
*/
/**
* @callback ModalFactory~APIError
* @param {ModalFactory~closeModalFunction} closeFunction - A function that will close the modal if called
* @param {Object} modalFactory - The instance of the ModalFactory
* @param {Object} evt - The event that triggered the confirm operation
*/
/**
* @callback ModalFactory~shownCallback
* @param {Object} modalFactory - The instance of the ModalFactory
*/
/**
* @callback ModalFactory~hiddenCallback
* @param {Object} modalFactory - The instance of the ModalFactory
*/
/**
* @callback ModalFactory~POSTSuccessCallback
* @param {Object} data - The data received from the successful POST operation
*/
/**
* @callback ModalFactory~POSTFailCallback
* @param {string} errorMessage
*/
/**
* @namespace
* @property {number} id - The ID to be used for the modal's container
* @property {string=('sm'|'lg'|'xl'|'')} size - The size of the modal
* @property {boolean} centered - Should the modal be vertically centered
* @property {boolean} scrollable - Should the modal be scrollable
* @property {boolean} backdropStatic - When set, the modal will not close when clicking outside it.
* @property {string} title - The title's content of the modal
* @property {string} titleHtml - The raw HTML title's content of the modal
* @property {string} body - The body's content of the modal
* @property {string} bodyHtml - The raw HTML body's content of the modal
* @property {string} rawHtml - The raw HTML of the whole modal. If provided, will override any other content
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the modal
* @property {string} modalClass - Classes to be added to the modal's container
* @property {string} headerClass - Classes to be added to the modal's header
* @property {string} bodyClass - Classes to be added to the modal's body
* @property {string} footerClass - Classes to be added to the modal's footer
* @property {string=('ok-only','confirm','confirm-success','confirm-warning','confirm-danger')} type - Pre-configured template covering most use cases
* @property {string} confirmText - The text to be placed in the confirm button
* @property {string} cancelText - The text to be placed in the cancel button
* @property {boolean} closeManually - If true, the modal will be closed automatically whenever a footer's button is pressed
* @property {boolean} closeOnSuccess - If true, the modal will be closed if the operation is successful
* @property {ModalFactory~confirm} confirm - The callback that should be called if the user confirm the modal
* @property {ModalFactory~cancel} cancel - The callback that should be called if the user cancel the modal
* @property {ModalFactory~APIConfirm} APIConfirm - The callback that should be called if the user confirm the modal. Behaves like the confirm option but provides an AJAXApi object that can be used to issue requests
* @property {ModalFactory~APIError} APIError - The callback called if the APIConfirm callback fails.
* @property {ModalFactory~shownCallback} shownCallback - The callback that should be called whenever the modal is shown
* @property {ModalFactory~hiddenCallback} hiddenCallback - The callback that should be called whenever the modal is hiddenAPIConfirm
* @property {ModalFactory~POSTSuccessCallback} POSTSuccessCallback - The callback that should be called if the POST operation has been a success. Works in confunction with the `rawHtml`
* @property {ModalFactory~POSTFailCallback} POSTFailCallback - The callback that should be called if the POST operation has been a failure (Either the request failed or the form validation did not pass)
*/
static defaultOptions = {
id: false,
size: 'md',
centered: false,
scrollable: false,
backdropStatic: false,
title: '',
titleHtml: false,
body: false,
bodyHtml: false,
rawHtml: false,
variant: '',
modalClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
type: 'ok-only',
confirmText: 'Confirm',
cancelText: 'Cancel',
closeManually: false,
closeOnSuccess: true,
confirm: function() {},
cancel: function() {},
APIConfirm: null,
APIError: function() {},
shownCallback: function() {},
hiddenCallback: function() {},
POSTSuccessCallback: function() {},
POSTFailCallback: function() {},
}
static availableType = [
'ok-only',
'confirm',
'confirm-success',
'confirm-warning',
'confirm-danger',
]
static closeButtonHtml = '<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>'
/** Create the HTML of the modal and inject it into the DOM */
makeModal() {
if (this.isValid()) {
this.$modal = this.buildModal()
$('#mainModalContainer').append(this.$modal)
this.modalInstance = new bootstrap.Modal(this.$modal[0], this.bsModalOptions)
} else {
console.log('Modal not valid')
}
}
/** Display the modal and remove it form the DOM once it gets hidden */
show() {
if (this.isValid()) {
var that = this
this.modalInstance.show()
this.$modal
.on('hidden.bs.modal', function () {
that.removeModal()
that.options.hiddenCallback(that)
})
.on('shown.bs.modal', function () {
that.options.shownCallback(that)
if (that.attachSubmitButtonListener) {
that.findSubmitButtonAndAddListener()
}
})
// this.$modal.modal(this.bsModalOptions)
// .on('hidden.bs.modal', function () {
// that.removeModal()
// that.options.hiddenCallback(that)
// })
// .on('shown.bs.modal', function () {
// that.options.shownCallback(that)
// if (that.attachSubmitButtonListener) {
// that.findSubmitButtonAndAddListener()
// }
// })
} else {
console.log('Modal not valid')
}
}
/** Hide the modal using the bootstrap modal's hide command */
hide() {
// this.$modal.modal('hide')
this.modalInstance.hide()
}
/** Remove the modal from the DOM */
removeModal() {
this.$modal.remove();
}
/**
* Check wheter a modal is valid
* @return {boolean} Return true if the modal contains at least data to be rendered
*/
isValid() {
return this.options.title !== false || this.options.titleHtml !== false ||
this.options.body !== false || this.options.bodyHtml !== false ||
this.options.rawHtml !== false
}
/**
* Build the modal HTML
* @return {jQuery} The modal jQuery object
*/
buildModal() {
const $modal = $('<div class="modal fade" tabindex="-1"/>')
if (this.options.id !== false) {
$modal.attr('id', this.options.id)
$modal.attr('aria-labelledby', this.options.id)
}
if (this.options.modalClass) {
$modal.addClass(this.options.modalClass)
}
let $modalDialog
if (this.options.rawHtml) {
$modalDialog = $(this.options.rawHtml)
if ($modalDialog.data('backdrop') == 'static') {
this.bsModalOptions['backdrop'] = 'static'
}
} else {
$modalDialog = $('<div class="modal-dialog"/>')
if (this.options.size !== false) {
$modalDialog.addClass(`modal-${this.options.size}`)
}
const $modalContent = $('<div class="modal-content"/>')
if (this.options.title !== false || this.options.titleHtml !== false) {
const $modalHeader = $('<div class="modal-header"/>')
if (this.options.headerClass) {
$modalHeader.addClass(this.options.headerClass)
}
let $modalHeaderText
if (this.options.titleHtml !== false) {
$modalHeaderText = $('<div/>').html(this.options.titleHtml);
} else {
$modalHeaderText = $('<h5 class="modal-title"/>').text(this.options.title)
}
$modalHeader.append($modalHeaderText, ModalFactory.getCloseButton())
$modalContent.append($modalHeader)
}
if (this.options.body !== false || this.options.bodyHtml !== false) {
const $modalBody = $('<div class="modal-body"/>')
if (this.options.bodyClass) {
$modalBody.addClass(this.options.bodyClass)
}
let $modalBodyText
if (this.options.bodyHtml !== false) {
$modalBodyText = $('<div/>').html(this.options.bodyHtml);
} else {
$modalBodyText = $('<div/>').text(this.options.body)
}
$modalBody.append($modalBodyText)
$modalContent.append($modalBody)
}
const $modalFooter = $('<div class="modal-footer"/>')
if (this.options.footerClass) {
$modalFooter.addClass(this.options.footerClass)
}
$modalFooter.append(this.getFooterBasedOnType())
$modalContent.append($modalFooter)
$modalDialog.append($modalContent)
}
$modal.append($modalDialog)
return $modal
}
/** Returns the correct footer data based on the provided type */
getFooterBasedOnType() {
if (this.options.type == 'ok-only') {
return this.getFooterOkOnly()
} else if (this.options.type.includes('confirm')) {
return this.getFooterConfirm()
} else {
return this.getFooterOkOnly()
}
}
/** Generate the ok-only footer type */
getFooterOkOnly() {
return [
$('<button type="button" class="btn btn-primary">OK</button>')
.attr('data-bs-dismiss', 'modal'),
]
}
/** Generate the confirm-* footer type */
getFooterConfirm() {
let variant = this.options.type.split('-')[1]
variant = variant !== undefined ? variant : 'primary'
const $buttonCancel = $('<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"></button>')
.text(this.options.cancelText)
.click(
(evt) => {
this.options.cancel(() => { this.hide() }, this, evt)
}
)
.attr('data-bs-dismiss', (this.options.closeManually || !this.options.closeOnSuccess) ? '' : 'modal')
const $buttonConfirm = $('<button type="button" class="btn"></button>')
.addClass('btn-' + variant)
.text(this.options.confirmText)
.attr('data-bs-dismiss', (this.options.closeManually || this.options.closeOnSuccess) ? '' : 'modal')
$buttonConfirm.click(this.getConfirmationHandlerFunction($buttonConfirm))
return [$buttonCancel, $buttonConfirm]
}
/** Return a close button */
static getCloseButton() {
return $(ModalFactory.closeButtonHtml)
}
/** Generate the function that will be called when the user confirm the modal */
getConfirmationHandlerFunction($buttonConfirm, buttonIndex) {
if (this.options.APIConfirms) {
if (Array.isArray(this.ajaxApi)) {
const tmpApi = new AJAXApi({
statusNode: $buttonConfirm[0]
})
this.ajaxApi.push(tmpApi)
} else {
this.ajaxApi.options.statusNode = $buttonConfirm[0]
this.ajaxApi = [this.ajaxApi];
}
} else {
this.ajaxApi.options.statusNode = $buttonConfirm[0]
}
return (evt) => {
let confirmFunction = this.options.confirm
if (this.options.APIConfirms) {
if (buttonIndex !== undefined && this.options.APIConfirms[buttonIndex] !== undefined) {
confirmFunction = () => { return this.options.APIConfirms[buttonIndex](this.ajaxApi[buttonIndex]) }
}
} else if (this.options.APIConfirm) {
confirmFunction = () => { return this.options.APIConfirm(this.ajaxApi) }
}
let confirmResult = confirmFunction(() => { this.hide() }, this, evt)
if (confirmResult === undefined) {
this.hide()
} else {
confirmResult.then((data) => {
if (this.options.closeOnSuccess) {
this.hide()
}
})
.catch((err) => {
this.options.APIError(() => { this.hide() }, this, evt)
})
}
}
}
/** Attach the submission click listener for modals that have been generated by raw HTML */
findSubmitButtonAndAddListener() {
let $modalFooter = this.$modal.find('.modal-footer')
if ($modalFooter.data('custom-footer')) { // setup basic listener as callback are defined in the template
let $submitButtons = this.$modal.find('.modal-footer .modal-confirm-button')
var selfModal = this;
selfModal.options.APIConfirms = [];
$submitButtons.each(function(i) {
const $submitButton = $(this)
if ($submitButton.data('clickfunction') !== undefined && $submitButton.data('clickfunction') !== '') {
const clickHandler = window[$submitButton.data('clickfunction')]
selfModal.options.APIConfirms[i] = (tmpApi) => {
let clickResult = clickHandler(selfModal, tmpApi)
if (clickResult !== undefined) {
return clickResult
.then((data) => {
if (data.success) {
selfModal.options.POSTSuccessCallback([data, this])
} else { // Validation error
selfModal.injectFormValidationFeedback(form, data.errors)
return Promise.reject('Validation error');
}
})
.catch((errorMessage) => {
selfModal.options.POSTFailCallback(errorMessage)
return Promise.reject(errorMessage);
})
}
}
}
$submitButton.click(selfModal.getConfirmationHandlerFunction($submitButton, i))
})
} else {
let $submitButton = this.$modal.find('.modal-footer #submitButton')
if (!$submitButton[0]) {
$submitButton = this.$modal.find('.modal-footer .modal-confirm-button')
}
if ($submitButton[0]) {
const formID = $submitButton.data('form-id')
let $form
if (formID) {
$form = $(formID)
} else {
$form = this.$modal.find('form')
}
if ($submitButton.data('confirmfunction') !== undefined && $submitButton.data('confirmfunction') !== '') {
$submitButton[0].removeAttribute('onclick')
const clickHandler = window[$submitButton.data('confirmfunction')]
if (clickHandler === undefined) {
console.error(`Function \`${$submitButton.data('confirmfunction')}\` is not defined`)
}
this.options.APIConfirm = (tmpApi) => {
let clickResult = clickHandler(this, tmpApi)
if (clickResult !== undefined) {
return clickResult
.then((data) => {
if (!data) {
this.options.POSTSuccessCallback([data, this])
} else {
if (data.success == undefined || data.success) {
this.options.POSTSuccessCallback([data, this])
} else { // Validation error
this.injectFormValidationFeedback(form, data.errors)
return Promise.reject('Validation error');
}
}
})
.catch((errorMessage) => {
this.options.POSTFailCallback(errorMessage)
return Promise.reject(errorMessage);
})
}
}
} else {
if ($form[0]) {
// Submit the form via the AJAXApi
$submitButton[0].removeAttribute('onclick')
this.options.APIConfirm = (tmpApi) => {
return tmpApi.postForm($form[0])
.then((data) => {
if (!data) {
this.options.POSTSuccessCallback([data, this])
} else {
if (data.success == undefined || data.success) {
this.options.POSTSuccessCallback([data, this])
} else { // Validation error
this.injectFormValidationFeedback(form, data.errors)
return Promise.reject('Validation error');
}
}
})
.catch((errorMessage) => {
this.options.POSTFailCallback([errorMessage, this])
return Promise.reject(errorMessage);
})
}
}
}
$submitButton.click(this.getConfirmationHandlerFunction($submitButton))
}
}
}
}
/** Class representing an loading overlay */
class OverlayFactory {
/**
* Create a loading overlay
* @param {(jQuery|string|HTMLButtonElement)} node - The node on which the overlay should be placed
* @param {Object} options - The options supported by OverlayFactory#defaultOptions
*/
constructor(node, options={}) {
this.node = node
this.$node = $(this.node)
this.options = Object.assign({}, OverlayFactory.defaultOptions, options)
this.options.auto = options.auto ? this.options.auto : !(options.variant || options.spinnerVariant)
if (this.options.auto) {
this.adjustOptionsBasedOnNode()
}
}
/**
* @namespace
* @property {string} text - A small text indicating the reason of the overlay
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the overlay
* @property {number|string} opacity - The opacity of the overlay
* @property {boolean} rounded - If the overlay should be rounded
* @property {boolean} auto - Whether overlay and spinner options should be adapted automatically based on the node
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} spinnerVariant - The variant of the spinner
* @property {boolean} spinnerSmall - If the spinner inside the overlay should be small
* @property {string=('border'|'grow')} spinnerSmall - If the spinner inside the overlay should be small
*/
static defaultOptions = {
text: '',
variant: '',
opacity: '',
blur: '2px',
rounded: false,
auto: true,
spinnerVariant: '',
spinnerSmall: false,
spinnerType: 'border',
fallbackBootstrapVariant: '',
wrapperCSSDisplay: '',
}
static overlayWrapper = '<div aria-busy="true" class="position-relative"/>'
static overlayContainer = '<div class="position-absolute text-nowrap loading-overlay-container" style="inset: 0px; z-index: 1100;"/>'
static overlayBg = '<div class="position-absolute loading-overlay" style="inset: 0px;"/>'
static overlaySpinner = '<div class="position-absolute" style="top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);"><span aria-hidden="true" class=""><!----></span></div></div>'
static overlayText = '<span class="ml-1 align-text-top"></span>'
shown = false
originalNodeIndex = 0
/** Create the HTML of the overlay */
buildOverlay() {
this.$overlayWrapper = $(OverlayFactory.overlayWrapper)
if (this.options.wrapperCSSDisplay) {
this.$overlayWrapper.css('display', this.options.wrapperCSSDisplay)
}
if (this.$node[0]) {
const boundingRect = this.$node[0].getBoundingClientRect()
this.$overlayWrapper.css('min-height', Math.max(boundingRect.height, 20))
this.$overlayWrapper.css('min-width', Math.max(boundingRect.width, 20))
if (this.$node.hasClass('row')) {
this.$overlayWrapper.addClass('row')
}
}
this.$overlayContainer = $(OverlayFactory.overlayContainer)
this.$overlayBg = $(OverlayFactory.overlayBg)
.addClass([`bg-${this.options.variant}`, (this.options.rounded ? 'rounded' : '')])
if (this.options.opacity !== '') {
this.$overlayBg.css('opacity', this.options.opacity)
}
this.$overlaySpinner = $(OverlayFactory.overlaySpinner)
this.$overlaySpinner.children().addClass(`spinner-${this.options.spinnerType}`)
if (this.options.spinnerSmall) {
this.$overlaySpinner.children().addClass(`spinner-${this.options.spinnerType}-sm`)
}
if (this.options.spinnerVariant.length > 0) {
this.$overlaySpinner.children().addClass(`text-${this.options.spinnerVariant}`)
}
if (this.options.text.length > 0) {
this.$overlayText = $(OverlayFactory.overlayText);
this.$overlayText.addClass(`text-${this.options.spinnerVariant}`)
.text(this.options.text)
this.$overlaySpinner.append(this.$overlayText)
}
}
/** Create the overlay, attach it to the DOM and display it */
show() {
this.buildOverlay()
this.mountOverlay()
this.shown = true
}
/** Hide the overlay and remove it from the DOM */
hide() {
if (this.shown) {
this.unmountOverlay()
}
this.shown = false
}
/** Attach the overlay to the DOM */
mountOverlay() {
this.originalNodeIndex = this.$node.index()
this.$overlayBg.appendTo(this.$overlayContainer)
this.$overlaySpinner.appendTo(this.$overlayContainer)
this.appendToIndex(this.$overlayWrapper, this.$node.parent(), this.originalNodeIndex)
this.$overlayContainer.appendTo(this.$overlayWrapper)
this.$node.prependTo(this.$overlayWrapper)
}
/** Remove the overlay from the DOM */
unmountOverlay() {
this.appendToIndex(this.$node, this.$overlayWrapper.parent(), this.originalNodeIndex)
this.$overlayWrapper.remove()
this.originalNodeIndex = 0
}
/** Append a node to the provided DOM index */
appendToIndex($node, $targetContainer, index) {
const $target = $targetContainer.children().eq(index);
$node.insertBefore($target);
}
/** Adjust instance's options based on the provided node */
adjustOptionsBasedOnNode() {
if (this.$node.width() < 50 || this.$node.height() < 50) {
this.options.spinnerSmall = true
}
if (this.$node.is('input[type="checkbox"]') || this.$node.css('border-radius') !== '0px') {
this.options.rounded = true
}
this.options.wrapperCSSDisplay = this.$node.css('display')
let classes = this.$node.attr('class')
if (classes !== undefined) {
classes = classes.split(' ')
const detectedVariant = OverlayFactory.detectedBootstrapVariant(classes, this.options.fallbackBootstrapVariant)
this.options.spinnerVariant = detectedVariant
}
}
/**
* Detect the bootstrap variant from a list of classes
* @param {Array} classes - A list of classes containg a bootstrap variant
*/
static detectedBootstrapVariant(classes, fallback = OverlayFactory.defaultOptions.fallbackBootstrapVariant) {
const re = /^[a-zA-Z]+-(?<variant>primary|success|danger|warning|info|light|dark|white|transparent)$/;
let result
for (let i=0; i<classes.length; i++) {
let theClass = classes[i]
if ((result = re.exec(theClass)) !== null) {
if (result.groups !== undefined && result.groups.variant !== undefined) {
return result.groups.variant
}
}
}
return fallback
}
}
/** Class representing a FormValidationHelper */
class FormValidationHelper {
/**
* Create a FormValidationHelper.
* @param {Object} options - The options supported by FormValidationHelper#defaultOptions
*/
constructor(form, options={}) {
this.form = form
this.options = Object.assign({}, Toaster.defaultOptions, options)
}
/**
* @namespace
*/
static defaultOptions = {
}
/**
* Create node containing validation information from validationError. If no field can be associated to the error, it will be placed on top
* @param {Object} validationErrors - The validation errors to be displayed. Keys are the fieldName that had errors, values are the error text
*/
injectValidationErrors(validationErrors) {
this.cleanValidationErrors()
for (const [fieldName, errors] of Object.entries(validationErrors)) {
this.injectValidationErrorInForm(fieldName, errors)
}
}
injectValidationErrorInForm(fieldName, errors) {
const inputField = Array.from(this.form).find(node => { return node.name == fieldName })
if (inputField !== undefined) {
const $messageNode = this.buildValidationMessageNode(fieldName, errors)
const $inputField = $(inputField)
$inputField.addClass('is-invalid')
$messageNode.insertAfter($inputField)
} else {
const $messageNode = this.buildValidationMessageNode(fieldName, errors, true)
const $flashContainer = $(this.form).parent().find('#flashContainer')
$messageNode.insertAfter($flashContainer)
}
}
buildValidationMessageNode(fieldName, errors, isAlert=false) {
const $messageNode = $('<div></div>')
if (isAlert) {
$messageNode.addClass('alert alert-danger').attr('role', 'alert')
$messageNode.append($('<strong></strong>').text(`${fieldName}: `))
} else {
$messageNode.addClass('invalid-feedback')
}
if (typeof errors === 'object') {
const hasMultipleErrors = Object.keys(errors).length > 1
for (const [ruleName, error] of Object.entries(errors)) {
if (hasMultipleErrors) {
$messageNode.append($('<li></li>').text(error))
} else {
$messageNode.append($('<span></span>').text(error))
}
}
} else {
$messageNode.text(errors)
}
return $messageNode
}
cleanValidationErrors() {
$(this.form).find('textarea, input, select').removeClass('is-invalid')
$(this.form).find('.invalid-feedback').remove()
$(this.form).parent().find('.alert').remove()
}
}
/** Class representing a Popover */
class PopoverFactory {
/**
* Create a Popover.
* @param {Object} element - The target element on which to attach the popover
* @param {Object} options - The options supported by PopoverFactory#defaultOptions
*/
constructor(element, options) {
this.element = $(element)[0]
this.options = Object.assign({}, PopoverFactory.defaultOptions, options)
}
/**
* @namespace
* @property {string} title - The title's content of the popover
* @property {string} titleHtml - The raw HTML title's content of the popover
* @property {string} body - The body's content of the popover
* @property {string} bodyHtml - The raw HTML body's content of the popover
* @property {string} content - Forward the popover's content to the bootstrap popover constructor
* @property {string} html - Manually allow HTML in both the title and body
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the popover
* @property {string} popoverClass - Classes to be added to the popover's container
* @property {string} container - Appends the popover to a specific element
* @property {string=('auto'|'top'|'bottom'|'left'|'right')} placement - How to position the popover
*/
static defaultOptions = {
title: '',
titleHtml: false,
body: false,
bodyHtml: false,
content: null,
html: false,
popoverClass: '',
container: false,
placement: 'right',
}
/** Create the HTML of the modal and inject it into the DOM */
makePopover() {
if (this.isValid()) {
if (this.options.titleHtml || this.options.bodyHtml) {
this.options.html = true
}
this.options.title = this.options.titleHtml ? this.options.titleHtml : sanitize(this.options.title)
if (this.options.content === null) {
this.options.content = this.options.bodyHtml ? this.options.bodyHtml : sanitize(this.options.body)
}
this.popoverInstance = new bootstrap.Popover(this.element, this.options)
} else {
console.error('Popover not valid')
}
}
/** Display the popover */
show() {
this.popoverInstance.show()
}
/** Hide the popover */
hide() {
this.popoverInstance.hide()
}
/** Updates the position of an elements popover. */
updatePosition() {
this.popoverInstance.update()
}
/** Hides and destroys an elements popover */
dispose() {
this.popoverInstance.dispose();
}
/**
* Check wheter a popover is valid
* @return {boolean} Return true if the popover contains at least data to be rendered
*/
isValid() {
return this.options.title !== false || this.options.titleHtml !== false ||
this.options.body !== false || this.options.bodyHtml !== false ||
this.options.rawHtml !== false
}
}
class HtmlHelper {
static table(head=[], body=[], options={}) {
const $table = $('<table/>')
const $thead = $('<thead/>')
const $tbody = $('<tbody/>')
$table.addClass('table')
if (options.striped) {
$table.addClass('table-striped')
}
if (options.bordered) {
$table.addClass('table-bordered')
}
if (options.borderless) {
$table.addClass('table-borderless')
}
if (options.hoverable) {
$table.addClass('table-hover')
}
if (options.small) {
$table.addClass('table-sm')
}
if (options.variant) {
$table.addClass(`table-${options.variant}`)
}
if (options.fixed_layout) {
$table.css('table-layout', 'fixed')
}
if (options.tableClass) {
$table.addClass(options.tableClass)
}
let $caption = null
if (options.caption) {
$caption = $('<caption/>')
if (options.caption instanceof jQuery) {
$caption = options.caption
} else {
$caption.text(options.caption)
}
}
let $theadRow = null
if (head) {
$theadRow = $('<tr/>')
head.forEach(head => {
if (head instanceof jQuery) {
$theadRow.append($('<td/>').append(head))
} else {
$theadRow.append($('<th/>').text(head))
}
})
$thead.append($theadRow)
}
body.forEach(row => {
const $bodyRow = $('<tr/>')
row.forEach(item => {
if (item instanceof jQuery) {
if (item.is('td')) {
$bodyRow.append(item)
} else {
$bodyRow.append($('<td/>').append(item))
}
} else {
$bodyRow.append($('<td/>').text(item))
}
})
$tbody.append($bodyRow)
})
$table.append($caption, $thead, $tbody)
if (options.responsive) {
options.responsiveBreakpoint = options.responsiveBreakpoint !== undefined ? options.responsiveBreakpoint : ''
$table = $('<div/>').addClass(options.responsiveBreakpoint !== undefined ? `table-responsive-${options.responsiveBreakpoint}` : 'table-responsive').append($table)
}
return $table
}
}