1// Makeshift interface of sorts for javascript, these functions need to be defined in luna.js and public.js. 2// This is suboptimal and only done like this to ensure backwards compatibility. A better solution would be to 3// use events, which are then listened to in the public and luna files separately. 4const mustBeDefinedFunctions = ['initialize_all_form_fields', 'maskElement', 'unmaskElement', 'reset_captcha', 'unmaskForm']; 5mustBeDefinedFunctions.forEach((functionName) => { 6 if (typeof functionName === 'undefined') { 7 throw new Error(`The function ${functionName} must be defined`); 8 } 9}); 10 11// This is for JS which is shared by the public and admin layouts. 12function init_richtext() { 13 $('.l-richtext-editor').each(function (i, el) { 14 let toolbar = [ 15 ['style', ['bold', 'italic', 'underline',]], 16 ['para', ['ul', 'ol',]], 17 ]; 18 19 let settings = { 20 toolbar: toolbar, 21 // We add all classes so the styles work for public (ui-kit) and admin (bootstrap) 22 tableClassName: 'table table-striped uk-table uk-table-striped', 23 lang: locale, 24 callbacks: { 25 // onPaste-callback only gets called when not in codeview 26 onPaste: function (event) { 27 // Firefox doesn't allow access to clipboard in JS so the purifier is not used here but only after submit 28 if (navigator.userAgent.indexOf("Firefox") !== -1) { 29 return; 30 } 31 let targetElement = $(event.currentTarget); 32 // If the user selects text and then pastes, the selection is replaced with the pasted text. 33 // This line is responsible for deleting the selection before pasting. 34 $(el).summernote('createRange').deleteContents(); 35 36 // using a promise to wait for the paste event to finish (avoiding race-condition) 37 Promise.resolve().then(function () { 38 let clipboardData = event.originalEvent.clipboardData; 39 40 $.ajax({ 41 method: 'POST', 42 url: PATH_TO_PURIFY_RTE, 43 data: { 44 rte_html: clipboardData.getData('text/html') !== '' ? clipboardData.getData('text/html') : clipboardData.getData('text/plain') 45 }, 46 success: function (formattedPastedText) { 47 // The reason we're using the summernote pasteHTML function is because it takes the current 48 // cursor position into account. There is a side effect where it will remove quite a lot of the 49 // html, which we generally find pretty cool, since it lessens the work on configuring the html purifier. 50 // It's important to be aware of this though, since it can lead to unexpected results. 51 $(el).summernote('pasteHTML', formattedPastedText); 52 }, 53 }); 54 }); 55 56 // we need to prevent the event so it doesn't paste twice. It's also after the promise so the promise runs. 57 event.preventDefault(); 58 }, 59 onDialogShown: function () { 60 let linkDialog = $('.link-dialog.show.modal'), 61 textarea = $(this); 62 63 // By default the close button of the link dialog is just an x 64 // we replace it with the icon and make sure it closes only this dialog 65 // and not other modals below it 66 if (linkDialog.length && $('.close', linkDialog).length) { 67 let button = $('> .modal-dialog .modal-header button', linkDialog); 68 button.html('<i aria-hidden="true" class="ki ki-close"></i>'); 69 button.removeAttr('data-dismiss'); 70 71 button.click(function () { 72 $(this).closest('.modal').modal('hide'); 73 }); 74 } 75 76 if (linkDialog.length && !$(linkDialog).hasClass('ct-page-and-media-buttons') && (textarea.data('media-text') || textarea.data('page-text'))) { 77 $(linkDialog).addClass('ct-page-and-media-buttons'); 78 79 $('#ct-form-modal-container > .modal').on('hide.bs.modal', function () { 80 $('.modal-backdrop').remove(); 81 }); 82 83 $('.note-link-url', linkDialog).parent().append('<a href="javascript:" class="btn btn-light ct-summernote-media-button">' + textarea.data('media-text') + '</a>'); 84 $('.note-link-url', linkDialog).parent().append('<a href="javascript:" class="btn btn-light ct-summernote-page-button">' + textarea.data('page-text') + '</a>'); 85 86 $('> .modal .close', textarea.parent()).click(function () { 87 $('> .modal', textarea.parent()).modal('hide'); 88 }); 89 90 $('.ct-summernote-media-button').click(function () { 91 $(' > .modal', textarea.parent()).modal('show', {keyboard: false}); 92 // Loading the standard media selector overlay to select media 93 loadMediaSelectorUrl(textarea.data('media-uri'), textarea.parent(), false, function (parentContainer, modalBody) { 94 $('.cloudtec-media-grid .datatable tbody tr', modalBody).click(function () { 95 let downloadUri = $('td[data-field="name"] span', this).data('uri'); 96 97 // If it's inside a translation widget, we need to adjust the link locale 98 if (textarea.attr('name').indexOf('[translations]') > -1) { 99 let locale = textarea.parents('.ct-translation-pane').data('language'); 100 101 if (!locale) { 102 locale = textarea.attr('name').match(/\[translations\]\[([a-z]{2})]/)[1]; 103 } 104 105 downloadUri = '/' + locale + '/' + downloadUri.slice(4); 106 } 107 108 $('.note-link-url', linkDialog).val(downloadUri); 109 110 if ($('.note-link-text', linkDialog).val()) { 111 $('.note-link-btn', linkDialog).prop('disabled', false); 112 $('.note-link-btn', linkDialog).removeClass('disabled'); 113 } 114 115 $('> .modal', parentContainer).modal('hide'); 116 }); 117 }) 118 }); 119 120 $('.ct-summernote-page-button').click(function () { 121 $(' > .modal', textarea.parent()).modal('show', {keyboard: false}); 122 // Loading the standard page selector overlay to select page 123 loadPageSelectorUrl(textarea.data('page-uri'), textarea.parent(), function (parentContainer, modalBody) { 124 $('.cloudtec-page-grid .datatable tbody tr', modalBody).click(function () { 125 let pageUri = $('td[data-field="title"] span', this).data('uri'); 126 // If it's inside a translation widget, we need to adjust the link locale 127 if (textarea.attr('name').indexOf('[translations]') > -1) { 128 let locale = textarea.parents('.ct-translation-pane').data('language'); 129 130 if (!locale) { 131 locale = textarea.attr('name').match(/\[translations\]\[([a-z]{2})]/)[1]; 132 } 133 134 pageUri = '/' + locale + '/' + pageUri.slice(4); 135 } 136 137 $('.note-link-url', linkDialog).val(pageUri); 138 139 if ($('.note-link-text', linkDialog).val()) { 140 $('.note-link-btn', linkDialog).prop('disabled', false); 141 $('.note-link-btn', linkDialog).removeClass('disabled'); 142 } 143 144 $('> .modal', parentContainer).modal('hide'); 145 }); 146 }) 147 }); 148 } 149 }, 150 onImageUpload: function (data) { 151 },
152 onChange: function(changedText, $textarea) { 153 $(".l-textarea-counter .l-character-count", $textarea.parents(".note-editor").first().parent()).text($textarea.text().length); 154 } 155 } 156 }; 157 158 if ($(el).data('enable-images')) { 159 delete settings.callbacks.onImageUpload; 160 } 161 162 if ($(el).data('enable-hr')) { 163 toolbar.push(['para', ['hr']]); 164 } 165 166 if ($(el).data('enable-links')) { 167 toolbar.push(['para', ['link']]); 168 } 169 170 if ($(el).data('enable-fullscreen')) { 171 toolbar.push(['style', ['fullscreen']]); 172 } 173 174 if ($(el).data('enable-tables')) { 175 toolbar.push(['table', ['table']]); 176 } 177 178 if ($(el).data('enable-codeview')) { 179 toolbar.push(['style', ['codeview']]); 180 } 181 182 if ($(el).data('enable-styleset')) { 183 toolbar.push(['style', ['style']]); 184 if ($(el).data('restricted-styleset')) { 185 settings.styleTags = [ 186 {title: 'Standard', tag: 'p', className: '', value: 'p'}, 187 {title: 'Blockquote', tag: 'blockquote', className: 'blockquote', value: 'blockquote'}, 188 'pre', 'h3', 'h4', 'h5' 189 ]; 190 } 191 } 192 193 $(el).summernote(settings); 194 195 if (el.disabled) { 196 $(el).summernote('disable'); 197 } 198 }); 199} 200 201function init_dropzones() { 202 let dropzones = $('div.dropzone:not(.l-no-init)'); 203 204 dropzones.each(function (index, zone) { 205 if (typeof zone.dropzone === 'undefined') { 206 let myDropzone = initDropzone(zone, $(zone).parents('[data-upload-url]').data('upload-url')); 207 } 208 }); 209} 210 211// display flash messages stored in cloudtec_flashes 212function init_flashes() { 213 let flashTypes = ['success', 'error', 'info', 'warning']; 214 for (let type of flashTypes) { 215 if (type in cloudtec_flashes) { 216 while (cloudtec_flashes[type].length) { 217 let flash = cloudtec_flashes[type].shift(); // remove element from array to prevent double display 218 let metadata = $('span.flash-metadata', '<div>' + flash + '</div>'); 219 let id = metadata.data('id'); 220 if (id) { 221 $('.l-index-card table tr[data-id="' + id + '"]').addClass('flash-focus'); 222 } 223 224 toastr[type](flash, null, { 225 'onclick': function () { 226 if (metadata.data('url')) { 227 window.location.href = metadata.data('url'); 228 } 229 } 230 }); 231 } 232 } 233 } 234} 235 236/** 237 * Does an ajax form request to update specific form fields. 238 * This won't save the object on the server since we don't send the token. 239 * ! Supports concurrent requests but won't check for race conditions. 240 * ! Empty multi select dropdowns will not generate any post data. This can result in a 500 error due to the POST request. 241 * ! Checkboxes will only be submitted if they are checked. Otherwise, no data is sent for its form field 242 * @param sources List of jquery input fields. They will be sent to the server to update the target fields. 243 * @param targets List of jquery input fields. Will be updated depending on the source field data. 244 */ 245function updateFormFields(sources, targets) { 246 let form = $(sources.get(0).closest('form')), fields = $([].concat(sources.toArray(), targets.toArray())), 247 data = {}, method = form.find('input[name="_method"]').val(); 248 249 if (!method) { 250 method = form.attr('method'); 251 } 252 253 fields.each(function () { 254 if ($(this).is(':checkbox') && $(this).is(':checked')) { 255 data[$(this).attr('name')] = "1"; 256 } else if (!$(this).is(':checkbox')) { 257 data[$(this).attr('name')] = $(this).val(); 258 } 259 }); 260 261 // lock fields during request 262 targets.each(function () { 263 maskElement($(this).parent()); 264 }); 265 266 $.ajax({ 267 method: method, url: form.attr('action'), type: form.attr('method'), data: data, complete: function (xhr) { 268 targets.each(function () { 269 unmaskElement($(this).parent()); 270 }); 271 272 if (xhr.status === 200 || xhr.status === 422) { 273 let html = xhr.responseText; 274 275 targets.each(function () { 276 const rowClassSelect = isBackend ? '.form-group, .form-compound' : '.uk-form-control
276s'; 277 let fieldHtml = $(html).find('#' + $(this).attr('id')).closest(rowClassSelect); 278 const errorClass = isBackend ? 'is-invalid' : 'invalid'; 279 //dont show field errors 280 fieldHtml.find('.' + errorClass).removeClass('is-invalid'); 281 fieldHtml.find('.form-error').remove(); 282 283 $('#' + $(this).attr('id')).closest(rowClassSelect).replaceWith(fieldHtml); 284 }); 285 286 initialize_all_form_fields(); 287 288 // trigger ajax update event on new element 289 // this has to be triggered after the dropdowns are initiated or the mask will break because the dropdown init changes the markup. 290 targets.each(function () { 291 $('#' + $(this).attr('id')).trigger('ajax-update', [html]); 292 }); 293 form.trigger('ajax-update', [html]); 294 } 295 } 296 }); 297} 298 299/** 300 * Initiates ajax requests based on ajax-source and ajax-target data attributes. 301 */ 302function init_ajax_field_loader() { 303 const initiateAjaxUpdate = function (select) { 304 let source = $(select).data('ajax-source'); 305 let form = $(select).closest('form'); 306 //add all sources to request 307 let sources = form.find(`[data-ajax-source="${source}"]`); 308 309 //find all targets with source 310 let targets = form.find(`[data-ajax-target="${source}"]`); 311 312 updateFormFields(sources, targets); 313 }; 314 315 let debInputChange = debounce(initiateAjaxUpdate, 500); 316 // single selects in backend should prock the ajax update instantly. If someone sets multiple to false, this event will not be triggered. 317 $(document).on('changed.bs.select', '.kt_selectpicker[data-ajax-source]:not([multiple])', (event) => { 318 initiateAjaxUpdate(event.target); 319 }); 320 $(document).on('change', '[data-ajax-source]:not(.kt_selectpicker:not([multiple]))', (event) => { 321 debInputChange(event.target); 322 }); 323 // Update depending fields when a field has both ajax-source and ajax-target. 324 $(document).on('ajax-update', '[data-ajax-source]', (event) => { 325 initiateAjaxUpdate(event.target); 326 }); 327} 328 329// Returns a function, that, as long as it continues to be invoked, will not 330// be triggered. The function will be called after it stops being called for 331// N milliseconds. If `immediate` is passed, trigger the function on the 332// leading edge, instead of the trailing. 333function debounce(func, wait, immediate) { 334 let timeout; 335 return function () { 336 let context = this, args = arguments; 337 let later = function () { 338 timeout = null; 339 340 if (!immediate) func.apply(context, args); 341 }; 342 let callNow = immediate && !timeout; 343 clearTimeout(timeout); 344 timeout = setTimeout(later, wait); 345 346 if (callNow) func.apply(context, args); 347 }; 348} 349 350function init_autocompletes() { 351 const validateSelection = function (el) { 352 let allValues = [], 353 validValues = [], 354 strings = []; 355 356 strings = [$(el).val()]; 357 358 // We need this validate to determine, if the given values are still in order with the saved selection. 359 // e.g. if someone has selected "Germany" before, which is valid and then removes the "y" and it becomes "German" 360 // it would be invalid and therefore we have to remove it from the selected values. 361 strings.forEach(function (label) { 362 label = label.trim() 363 if (label) { 364 let existing = el._previousSelections.filter(selection => selection[$(el).data('label-name')] === label); 365 if (existing.length && validValues.indexOf(existing[0].id) === -1) { 366 validValues.push(existing[0]); 367 } 368 369 allValues.push(label); 370 } 371 }); 372 373 if (validValues.length === allValues.length && validValues.length) { 374 $('#' + $(el._autocomplete.input).data('hidden-id')).val(validValues.map(a => a.id).join(',')); 375 $(el).addClass('ct-autocomplete-has-valid-selection'); 376 // DataHolder is a hidden field which stores the actual value and all `change` event-listeners are bound to (ajax-source-target) 377 let dataHolderId = el.id.replace('_input', ''); 378 $(`#${dataHolderId}`).trigger('change') 379 } else { 380 $('#' + $(el._autocomplete.input).data('hidden-id')).val(''); 381 $(el).removeClass('ct-autocomplete-has-valid-selection'); 382 // We dispatch an event so it is possible in further implementation to e.g. reset a selection view e.g. medic in opancare 383 el.dispatchEvent(new Event("invalid-selection")); 384 } 385 }; 386 387 const syncMultiSelection = function (el) { 388 let hiddenField = $('#' + $(el._autocomplete.input).data('hidden-id')); 389 let hiddenFieldValues = []; 390 391 let selectionList = $('<ul class="ct-autocomplete-selection"></ul>'); 392 el._previousSelections.forEach(function (selection) { 393 // they should have a delete link 394 let selectionItem = $('<li></li>'); 395 selectionItem.text(selection[$(el).data('label-name')]); 396 selectionItem.append('<a href="javascript:" class="ct-autocomplete-selection-delete"><i class="fa fa-circle-xmark"></i></a>'); 397 selectionItem.find('a').on('click', function () { 398 el._previousSelections = el._previousSelections.filter(item => item.id !== selection.id); 399 syncMultiSelection(el); 400 }); 401 selectionList.append(selectionItem); 402 hiddenFieldValues.push(selection.id); 403 }); 404 hiddenField.val(hiddenFieldValues.join(',')); 405 406 let listContainer = $(el); 407 408 if ($(el).parent().hasClass('uk-inline')) { 409 listContainer = $(el).parent(); 410 } 411 412 listContainer.next('.ct-autocomplete-selection').remove(); 413 listContainer.after(selectionList); 414 }; 415 416 const elements = document.querySelectorAll('.ct-autocomplete');
417 elements.forEach(function (el) { 418 //set a data attribute on each autocomplete element to prevent multiple initializations of event listeners 419 if ($(el).data('initialized')) { 420 return; 421 } 422 $(el).attr('data-initialized', true); 423 424 // We store every selection in an array to later validate if a string value in the field is a valid selection or just a string 425 el._previousSelections = $(el).data('pre-selection') && $(el).data('pre-selection').length ? $(el).data('pre-selection') : []; 426 427 // todo: This is maybe not the fasted way to handle this 428 let prototype = Object.getPrototypeOf(el); 429 prototype.resetSelection = function () { 430 $(this).val(null); 431 $(this)._previousSelections = []; 432 $(this).removeClass('ct-autocomplete-has-valid-selection'); 433 $('#' + $(this._autocomplete.input).data('hidden-id')).val(''); 434 435 if ($(this).attr('multiple')) { 436 syncMultiSelection(this); 437 } 438 }; 439 prototype.setValue = function (value, text) { 440 if (!$(this).attr('multiple')) { 441 $(this).val(text); 442 } 443 444 $('#' + $(this._autocomplete.input).data('hidden-id')).val(value); 445 let selection = {id: value}; 446 selection[$(el).data('label-name')] = text; 447 this._previousSelections = [selection]; 448 449 if ($(this).attr('multiple')) { 450 syncMultiSelection(this); 451 } else { 452 validateSelection(this, false); 453 } 454 }; 455 Object.setPrototypeOf(el, prototype); 456 457 let config = { 458 selector: '#' + $(el).attr('id'), 459 debounce: 300, 460 threshold: $(el).data('min-chars'), 461 placeholder: $(el).data('placeholder'), 462 wrapper: false, 463 // Make sure all items from api are returned and they are not filtered by the library again 464 searchEngine: function searchEngine(query, record) { 465 return record; 466 }, 467 resultsList: { 468 element: (list, data) => { 469 const info = document.createElement("p"); 470 info.setAttribute("class", "ct-autocomplete-info"); 471 if (data.results.length === 0) { 472 info.innerHTML = $(el).data('no-results-text'); 473 list.append(info); 474 } else { 475 $('.ct-autocomplete-info', list).remove(); 476 } 477 }, 478 class: 'ct-autocomplete-list', 479 noResults: true, 480 maxResults: $(el).data('max-results'), 481 tabSelect: true 482 }, 483 resultItem: { 484 // We need to add an own highlighting algorithm as we do not use the original search engine 485 element: (item, data) => { 486 let match = data.match, 487 value = el.value; 488 489 let escapedValue = value 490 .replaceAll('&', '&') 491 .replaceAll('<', '<') 492 .replaceAll('>', '>') 493 .replaceAll('"', '"') 494 .replaceAll("'", '''); 495 496 match = match.replace(value, '<mark>' + escapedValue + '</mark>'); 497 // Then we do the same with the uppercase version of the value 498 value = value.charAt(0).toUpperCase() + value.slice(1); 499 match = match.replace(value, '<mark>' + escapedValue + '</mark>'); 500 501 item.innerHTML = `${match}`; 502 }, 503 }, 504 data: { 505 src: async (query) => { 506 try { 507 let url = $(el).data('url'), 508 searchEvent = new Event("before-search"); 509 510 searchEvent.url = url; 511 el.dispatchEvent(searchEvent); 512 // Url could be changed in the event 513 url = searchEvent.url; 514 515 let token = url.indexOf('?') > -1 ? '&' : '?'; 516 517 const encodedQuery = encodeURIComponent(query); 518 const source = await fetch(`${url}${token}query=${encodedQuery}`); 519 520 return await source.json(); 521 } catch (error) { 522 return error; 523 } 524 }, 525 // Data source 'Object' key to be searched 526 keys: [$(el).data('label-name')] 527 }, 528 events: { 529 input: { 530 focus() { 531 if (el._autocomplete.input.value.length) { 532 el._autocomplete.start(); 533 } 534 }, 535 selection(event) { 536 const feedback = event.detail; 537 el._autocomplete.input.blur(); 538 el._autocomplete.input.value = feedback.selection.value[feedback.selection.key]; 539 $('#' + $(el._autocomplete.input).data('hidden-id')).val(feedback.selection.value.id); 540 541 el._previousSelections.push(feedback.selection.value); 542 543 validateSelection(el); 544 }, 545 // When hitting enter, first result is selected automatically 546 results(event) { 547 $(this).one('keydown', function (e) { 548 if (e.key === 'Enter') { 549 e.preventDefault(); 550 e.stopPropagation(); 551 let list = $(this).next('.ct-autocomplete-list'); 552 if (list.length && list.children().length) { 553 let firstItem = list.children().first(); 554 firstItem.trigger('click'); 555 } 556 } 557 }); 558 } 559 }, 560 } 561 }; 562 563 if (el.getAttribute('multiple')) { 564 config.events = { 565 input: { 566 focus() { 567 if (el._autocomplete.input.value.length) { 568 el._autocomplete.start(); 569 } 570 }, 571 selection(event) { 572 const feedback = event.detail; 573 // Clear the input field 574 el._autocomplete.input.value = ''; 575 576 // do not add it if it's already in the list 577 if (el._previousSelections.filter(item => item.id === feedback.selection.value.id).length === 0) { 578 el._previousSelections.push(feedback.selection.value); 579 } 580 581 syncMultiSelection(el); 582 } 583 } 584 } 585 } 586 587 el._autocomplete = new autoComplete(config); 588 589 $(el._autocomplete.input).on('keyup', function () { 590 if (!$(el).attr('multiple')) { 591 validateSelection(this); 592 } 593 }); 594 595 if ($(el).attr('multiple')) { 596 syncMultiSelection(el); 597 } 598 }); 599} 600 601 602/** 603 * This should hopefully replace the existing ajax-source/ajax-target handling. You can use it by adding an ajax_source_target 604 * definition to your form. Check the manual in the admin-bundle about how
604it works. 605 */ 606class AjaxFieldUpdater { 607 #definitions; 608 #form; 609 #triggerFields = {}; 610 #requests; 611 612 constructor(form) { 613 this.#form = form; 614 this.#triggerFields = {}; 615 this.#requests = new Set(); 616 617 this.#definitions = this.#form.data('ajax-source-target'); 618 619 if (this.#definitions) { 620 this.setupFieldTriggers(); 621 } 622 } 623 624 setupFieldTriggers() { 625 for (const [key] of Object.entries(this.#definitions)) { 626 // We try to locate the trigger field in the html and convert its property path to field notation 627 let fieldName = this.#form.attr('name') + this.convertPropertyPathToFieldName(key), 628 field = $('[name="' + fieldName + '"]', this.#form); 629 630 // If no field was found it's possible that it's a multiselect field so we try again with [] 631 if (!field.length) { 632 field = $('[name="' + fieldName + '[]"]', this.#form); 633 } 634 635 if (field.length) { 636 this.#triggerFields[key] = field; 637 } 638 } 639 640 for (const [key, field] of Object.entries(this.#triggerFields)) { 641 if (field.hasClass('kt_selectpicker') && !field.attr('multiple')) { 642 field.one('changed.bs.select', () => this.initAjaxFieldUpdateRequest(key)); 643 // datetimepicker has its own event that gets triggered when a date is selected over the popup-picker 644 } else if (field.parent().hasClass('l-datetimepicker')) { 645 field.parent().one('change.datetimepicker', () => this.initAjaxFieldUpdateRequest(key)); 646 } else { 647 field.one('change', debounce(() => this.initAjaxFieldUpdateRequest(key), 500)); 648 } 649 } 650 } 651 652 initAjaxFieldUpdateRequest(triggerKey) { 653 if (this.#requests.has(triggerKey)) { 654 return; 655 } 656 657 this.#requests.add(triggerKey); 658 659 let method = $('input[name="_method"]', this.#form).val() || this.#form.attr('method'), 660 data = {}, 661 targetFields = [], 662 maskableFields = []; 663 664 for (const [fieldName, triggerField] of Object.entries(this.#triggerFields)) { 665 if (!triggerField.is(':checkbox') || triggerField.is(':checked')) { 666 data[triggerField.attr('name')] = triggerField.val(); 667 } 668 669 // radio-elements (ChoiceType with `'expanded' => true`) have multiple input-elements, so we get the one 670 // that is checked (since `triggerField.val()` just gets the first one) 671 if (triggerField.toArray().every((el) => $(el).attr('type') === 'radio')) { 672 data[triggerField.attr('name')] = triggerField.filter(':checked').val(); 673 } 674 675 for (const targetFieldName of this.#definitions[fieldName]) { 676 let foundTargetFields; 677 678 if ($('[data-ajax-field="' + targetFieldName + '"]', this.#form).length) { 679 foundTargetFields = $('[data-ajax-field="' + targetFieldName + '"]', this.#form); 680 } else { 681 foundTargetFields = $('[name^="' + $(this.#form).attr('name') + this.convertPropertyPathToFieldName(targetFieldName) + '"]', this.#form); 682 } 683 684 foundTargetFields.each(function () { 685 let targetField = $(this); 686 687 targetFields.push(targetField); 688 689 if (fieldName === triggerKey) { 690 maskableFields.push(targetField); 691 } 692 693 if (!targetField.data('ajax-field') && (!targetField.is(':checkbox') || targetField.is(':checked'))) { 694 data[targetField.attr('name')] = targetField.val(); 695 } 696 }); 697 } 698 } 699 700 // We also want to mask the trigger field 701 maskableFields.push(this.#triggerFields[triggerKey]); 702 703 this.maskFields(maskableFields); 704 705 $.ajax({ 706 method: method, 707 url: this.#form.attr('action'), 708 type: this.#form.attr('method'), 709 data: data, 710 complete: (xhr) => { 711 this.unmaskFields(maskableFields); 712 this.handleAjaxResponse(xhr, targetFields); 713 this.#requests.delete(triggerKey); 714 } 715 }); 716 } 717 718 handleAjaxResponse(xhr, fields) { 719 if (xhr.status === 200 || xhr.status === 422) { 720 this.updateFieldsFromResponse(xhr.responseText, fields); 721 initialize_all_form_fields(); 722 init_autocompletes(); 723
724 this.#form.trigger('ajax-field-update', [xhr.responseText]); 725 726 fields.forEach(field => { 727 let eventField; 728 if (field.data('ajax-field')) { 729 eventField = $('[data-ajax-field="' + field.data('ajax-field') + '"]', this.#form); 730 } else { 731 eventField = $('#' + field.attr('id')); 732 } 733 734 eventField.trigger('ajax-field-update', [xhr.responseText]); 735 }); 736 } 737 738 this.update(); 739 } 740 741 updateFieldsFromResponse(responseText, fieldsToUpdate) { 742 fieldsToUpdate.forEach(field => { 743 if (field.data('ajax-field')) { 744 $(field).html($('[data-ajax-field="' + field.data('ajax-field') + '"]', responseText).html()); 745 } else { 746 const rowClassSelect = isBackend ? '.form-group, .form-compound' : '.uk-form-controls'; 747 let fieldHtml = $(responseText).find('#' + field.attr('id')).closest(rowClassSelect); 748 fieldHtml.find('.is-invalid, .invalid').removeClass('is-invalid'); 749 fieldHtml.find('.form-error').remove(); 750 $('#' + field.attr('id')).closest(rowClassSelect).replaceWith(fieldHtml); 751 } 752 }); 753 } 754 755 update() { 756 this.setupFieldTriggers(); 757 } 758 759 maskFields(fields) { 760 fields.forEach(field => maskElement(field.parent())); 761 } 762 763 unmaskFields(fields) { 764 fields.forEach(field => unmaskElement(field.parent())); 765 } 766 767 convertPropertyPathToFieldName(dotNotation) { 768 return dotNotation.split('.').reduce((acc, part, index) => { 769 if (index === 0) { 770 return `[${part}]`; 771 } else { 772 return `${acc}[${part}]`; 773 } 774 }, ''); 775 } 776} 777 778 779function init_character_counter() { 780 $(document).on('keyup', 'input,textarea', function () { 781 let widget = $(this).closest('.form-group,.uk-form-controls'); 782 let counter = $('.l-character-count', widget); 783 if (counter.length === 1) { 784 counter.text($(this).val().length); 785 } 786 }); 787} 788 789function init_custom_forms() { 790 $(document).ready(function () { 791 $('form[name="cloudtec_form_request"][data-submit="page"]').on('submit', function (e) { 792 e.preventDefault(); 793 794 let form = $(this); 795 let data = form.serializeArray(); 796 let container = form.find('.ct-custom-form-content'); 797 798 form.find('button[type="submit"]').prop('disabled', true); 799 800 if (form.find('button[type="submit"]').hasClass('l-submitted') === false) { 801 form.find('button[type="submit"]').addClass('l-submitted') 802 } 803 804 maskForm(form); 805 806 $.ajax({ 807 type: form.attr('method'), 808 url: form.attr('action'), 809 data: data, 810 complete: function (xhr) { 811 let status = xhr.status; 812 let location = xhr.getResponseHeader('Location'); 813 814 if (location) { 815 window.location.href = location; 816 return; 817 } 818 819 if ((status >= 200 && status < 300) || status === 422) { 820 let result = $('<div>').html(xhr.responseText) 821 822 823 let scriptTag = result.find('script').filter(function () { 824 return this.innerHTML.includes('let cloudtec_flashes'); 825 }).first(); 826 827 let flashes = false; 828 829 if (scriptTag.length > 0) { 830 flashes = scriptTag.html().match(/let cloudtec_flashes = (\{.*?\});/); 831 } 832 833 if (flashes) { 834 cloudtec_flashes = JSON.parse(flashes[1]); 835 } 836 837 if (form.find('#ct-request-success').length === 0 && flashes) { 838 init_flashes(); 839 } else if (flashes) { 840 let messageTemplate = form.find('#ct-request-success').html(); 841 form.html(''); 842 let flashTypes = ['success', 'error', 'info', 'warning']; 843 for (let type of flashTypes) { 844 if (type in cloudtec_flashes) { 845 while (cloudtec_flashes[type].length) { 846 let message = $('<div>').html(messageTemplate); 847 message.find('.uk-alert').addClass(`uk-alert-${type}`); 848 message.find('p').text(cloudtec_flashes[type].shift()); 849 form.append(message); 850 } 851 } 852 } 853 if (form.find('.uk-alert').length) { 854 form.find('.uk-alert').first().get(0).scrollIntoView({left: 0, block: 'start', behavior: 'smooth'}); 855 } 856 return; 857 } 858 859 container.html(result.find(".ct-custom-form-content").html()); 860 861 initialize_all_form_fields(); 862 reset_captcha(form); 863 864 form.find('button[type="submit"]').prop('disabled', false); 865 866 if (form.find('button[type="submit"]').hasClass('l-submitted')) { 867 form.find('button[type="submit"]').removeClass('l-submitted') 868 } 869 870 unmaskForm(form); 871 } else { 872 form.html(form.find('#ct-request-failed').html()); 873 } 874 } 875 }); 876 }); 877 }); 878} 879
880const delay = ms => new Promise(res => setTimeout(res, ms)); 881 882/** 883 * This class handles all server communication for mutex information. It is a singleton and prevents double initialization. 884 * Events: 885 * - mutex-initial: the first time there is mutex data available 886 * - mutex-update: whenever the mutex data changes. dispatched after the other events 887 * - mutex-other-started: when someone else started editing 888 * - mutex-other-stopped: when someone else stopped editing 889 * - mutex-failed: when the mutex check failed. The provided message is not user friendly. 890 */ 891class Mutex { 892 timeout = 60; 893 active = false; // if the mutex check is active 894 activityData = null; // stores the last activity data 895 someoneElseStoppedEditing = false; 896 897 constructor() { 898 let instance = $(document).data('mutex'); 899 if (instance) { 900 return instance; 901 } 902 903 $(document).data('mutex', this); 904 905 this.start(); 906 } 907 908 async check() { 909 if (this.active) { 910 return; 911 } 912 this.active = true; 913 while (this.active) { 914 let response, previousData; 915 try { 916 response = await fetch('?mutex'); 917 if (!response.ok) { 918 this.stop(); 919 let message = `${response.status}: ${response.statusText}`; 920 console.error(message); 921 document.dispatchEvent(new CustomEvent('mutex-failed', {detail: message})); 922 return; 923 } 924 925 previousData = this.activityData; 926 this.activityData = await response.json(); 927 928 } catch (e) { 929 this.stop(); 930 console.error(e.message); 931 document.dispatchEvent(new CustomEvent('mutex-failed', {detail: e.message})); 932 return; 933 } 934 935 if (this.activityData !== previousData) { 936 // initial event 937 if (previousData === null) { 938 document.dispatchEvent(new CustomEvent('mutex-initial', {detail: this})); 939 } 940 941 // someone else started editing 942 if (this.activityData.length > 0 && !previousData?.length) { 943 document.dispatchEvent(new CustomEvent('mutex-other-started', {detail: this})); 944 } 945 946 // someone else stopped editing 947 if (this.activityData.length === 0 && previousData?.length > 0) { 948 this.someoneElseStoppedEditing = true; 949 document.dispatchEvent(new CustomEvent('mutex-other-stopped', {detail: this})); 950 } 951 952 // generic update 953 document.dispatchEvent(new CustomEvent('mutex-update', {detail: this})); 954 } 955 956 await delay(this.timeout / 2 * 1000); 957 } 958 } 959 960 start() { 961 this.check(); 962 } 963 964 stop() { 965 this.activityData = null; 966 this.active = false; 967 } 968} 969 970function initializePhoneField(inputGroup) { 971 let numberInput = $('.ct-phone-number', inputGroup); 972 if (numberInput.length === 0) { 973 return; 974 } 975 let cleave = null; 976 let currentTrunkPrefix = null; 977 978 let phoneSelect = isBackend ? '.ct-phone-flag-dropdown select' : '.ct-phone-flag-dropdown'; 979 $(phoneSelect, inputGroup).change(function () { 980 if (cleave) { 981 // cleave needs to be destroyed/reinstated because i can't change the prefix on the fly. 982 cleave.destroy(); 983 } 984 985 let selectedCountry = $(phoneSelect + ' :selected', inputGroup); 986 let countryCode = selectedCountry.data('code'); 987 let trunkPrefix = String(selectedCountry.data('trunk-prefix')); 988 let countryPrefix = String(selectedCountry.data('prefix')); 989 if (!countryPrefix) { 990 countryCode = ""; 991 } 992 cleave = new Cleave(numberInput.get(0), { 993 phone: true, 994 phoneRegionCode: countryCode, 995 prefix: trunkPrefix 996 }); 997 998 // update prefix in the number field if only the prefix was filled in 999 if (currentTrunkPrefix === numberInput.val()) { 1000 numberInput.val(trunkPrefix); 1001 } 1002 currentTrunkPrefix = trunkPrefix; 1003 }).trigger('change'); 1004} 1005 1006function init_newsletter_subscribe_form() { 1007 $(document).ready(function () { 1008 $('form[name="cloudtec_newsletter_recipient"][data-submit="page"]').on('submit', function (e) { 1009 e.preventDefault(); 1010 1011 let form = $(this); 1012 let data = form.serializeArray(); 1013 let formContent = form.find('.ct-newsletter-form-content'); 1014 let submitButton = form.find('button[type="submit"]'); 1015 1016 // disable button 1017 submitButton.prop('disabled', true); 1018 if (!submitButton.hasClass('l-submitted')) { 1019 submitButton.addClass('l-submitted'); 1020 } 1021 1022 maskForm(form); 1023 1024 $.ajax({ 1025 type: form.attr('method'), 1026 url: form.attr('action'), 1027 data: data, 1028 complete: function (xhr) { 1029 let html = xhr.responseText; 1030 let newContent = $('.ct-newsletter-form-content', html); 1031 1032 if (xhr.status === 422) { 1033 // render form with returned form content if validation fails 1034 formContent.replaceWith(newContent); 1035 } else if (xhr.status === 200) { 1036 let successTemplate = form.find('#ct-request-success').html(); 1037 1038 form.prepend(successTemplate); 1039 1040 formContent.replaceWith(newContent); 1041 } else { 1042 let failureTemplate = form.find('#ct-request-failed').html(); 1043 form.replaceWith(failureTemplate); 1044 } 1045 1046 initialize_all_form_fields(); 1047 reset_captcha(form); 1048 unmaskForm(form); 1049 1050 if (submitButton.hasClass('l-submitted')) { 1051 submitButton.removeClass('l-submitted'); 1052 } 1053 submitButton.prop('disabled', false); 1054 } 1055 }); 1056 }); 1057 }); 1058} 1059 1060/** 1061 * resize iframes with data-resize="auto" to fit their content height 1062 * Note: This only works for same-origin iframes due to browser security policies. 1063 */ 1064function init_iframe_auto_resize() { 1065 $(document).ready(function () { 1066 $('iframe[data-resize="auto"]').on('load', function () { 1067 const iframe = this; 1068 const iwin = iframe.contentWindow; 1069 const doc = iframe.contentDocument; 1070 1071 // properties are protected due to cors policy when source is external / non same origin 1072 if (!iwin || !doc) return; 1073 1074 const measureAndSet = function () { 1075 iframe.style.height = '0px'; 1076 const b = doc.body; 1077 const e = doc.documentElement; 1078 const height = Math.max( 1079 b.scrollHeight, e.scrollHeight, 1080 b.offsetHeight, e.offsetHeight, 1081 e.clientHeight 1082 ); 1083 iframe.style.height = height + 'px'; 1084 }; 1085 1086 measureAndSet(); 1087 }); 1088 }); 1089} 1090 1091function init_form_submit_disabled_on_submit() { 1092 // disable submit buttons on submit 1093 $('.l-submit[type="submit"]').closest('form').submit(function (event) { 1094 $(this).addClass('l-submitted'); 1095 // show spinner on clicked submit button 1096 if ('originalEvent' in event && 'submitter' in event.originalEvent) { 1097 $(event.originalEvent.submitter).addClass('l-submitted'); 1098 } else { 1099 // fallback when no button was used (e.g. control + enter) 1100 $('#cloudtec-save').addClass('l-submitted'); 1101 } 1102 1103 // If the form has a modal that can be used to submit, do not disable submit buttons 1104 if ($(this).find('.modal').find('button[type="submit"]').length > 0) { 1105 return; 1106 } 1107 1108 // disable all submit buttons (after 10ms so the pressed button is still sent in the request) 1109 setTimeout(() => $("[type=submit]", this).prop('disabled', true), 10); 1110 }); 1111}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.