1/** 2 * --------------------------------------------------------------------- 3 * 4 * GLPI - Gestionnaire Libre de Parc Informatique 5 * 6 * http://glpi-project.org 7 * 8 * @copyright 2015-2026 Teclib' and contributors. 9 * @copyright 2003-2014 by the INDEPNET Development Team. 10 * @licence https://www.gnu.org/licenses/gpl-3.0.html 11 * 12 * --------------------------------------------------------------------- 13 * 14 * LICENSE 15 * 16 * This file is part of GLPI. 17 * 18 * This program is free software: you can redistribute it and/or modify 19 * it under the terms of the GNU General Public License as published by 20 * the Free Software Foundation, either version 3 of the License, or 21 * (at your option) any later version. 22 * 23 * This program is distributed in the hope that it will be useful, 24 * but WITHOUT ANY WARRANTY; without even the implied warranty of 25 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 26 * GNU General Public License for more details. 27 * 28 * You should have received a copy of the GNU General Public License 29 * along with this program. If not, see <https://www.gnu.org/licenses/>. 30 * 31 * --------------------------------------------------------------------- 32 */ 33 34/* global glpi_toast_info, tinymce, glpi_toast_error, _ */ 35 36import { GlpiFormConditionEngine } from '/js/modules/Forms/Condition/Engine.js'; 37 38/** 39 * Client code to handle users actions on the form_renderer template 40 */ 41export class GlpiFormRendererController 42{ 43 /** 44 * Target form 45 * @type {HTMLFormElement} 46 */ 47 #target; 48 49 /** 50 * Active section index 51 * @type {number} 52 */ 53 #section_index; 54 55 /** 56 * @type {GlpiFormConditionEngine} 57 */ 58 #condition_engine; 59 60 /** 61 * Form render layout mode 62 * @type {string} 63 */ 64 #render_layout; 65 66 /** 67 * Create a new GlpiFormRendererController instance for the given target. 68 * The target must be a valid form. 69 * 70 * @param {string} target 71 * @param {number} form_id 72 */ 73 constructor(target, form_id) { 74 // Target must be a valid form 75 this.#target = document.querySelector(target); 76 if ($(this.#target).prop("tagName") != "FORM") { 77 throw new Error("Target must be a valid form"); 78 } 79 80 // Get render layout from data attribute 81 this.#render_layout = this.#target.dataset.glpiFormRenderLayout || 'step_by_step'; 82 83 // Init section data 84 this.#section_index = 0; 85 86 // Init event handlers 87 this.#initEventHandlers(); 88 89 // Make "Send form" button clickable 90 $(this.#target) 91 .find("[data-glpi-form-renderer-action=submit]") 92 .removeAttr("disabled"); 93 94 // Load condition engine 95 this.#condition_engine = new GlpiFormConditionEngine(form_id); 96 this.#enableActions(); 97 this.#updateActionsVisiblity(); 98 } 99 100 /** 101 * Init event handlers for each possible actions, identified by the data 102 * attribute "data-glpi-form-renderer-action". 103 */ 104 #initEventHandlers() { 105 const action_attribute = "data-glpi-form-renderer-action"; 106 107 // Submit form action 108 $(this.#target) 109 .find(`[${action_attribute}=submit]`) 110 .on("click", () => this.#submitForm()); 111 112 // Next section form action 113 $(this.#target) 114 .find(`[${action_attribute}=next-section]`) 115 .on("click", () => this.#goToNextSection()); 116 117 // Previous section form action 118 $(this.#target) 119 .find(`[${action_attribute}=previous-section]`) 120 .on("click", () => this.#goToPreviousSection()); 121 122 // Watch for any change on answers 123 const debouncedComputeItemsVisibilities = _.debounce( 124 () => this.#computeItemsVisibilities(), 125 400, 126 ); 127 $(document).on('input tinyMCEInput glpi_fileupload_remove', this.#target, () => { 128 // Disable actions immediately to avoid someone clicking on the actions 129 // while the conditions have not been computed yet. 130 this.#disableActions(); 131 132 debouncedComputeItemsVisibilities(); 133 }); 134 135 // Handle delegation form update 136 $(this.#target).on( 137 'change', 138 '[data-glpi-form-renderer-delegation-container] select[name="delegation_users_id"]', 139 (e) => this.#renderDelegation(e) 140 ); 141 142 // Enable actions 143 $(this.#target).removeClass('pointer-events-none'); 144 } 145 146 /** 147 * Save all TinyMCE editors content to their respective form fields 148 */ 149 #saveTinymceEditors() { 150 if (window.tinymce !== undefined) {
151 tinymce.get().forEach(editor => editor.save()); 152 } 153 } 154 155 async #checkCurrentSectionValidity() { 156 // Update tinymce values 157 this.#saveTinymceEditors(); 158 159 // In single page mode, validate all sections 160 if (this.#render_layout === 'single_page') { 161 return await this.#checkAllSectionsValidity(); 162 } 163 164 // Get the UUID of the current section 165 const currentSectionElement = $(this.#target).find(`[data-glpi-form-renderer-section="${CSS.escape(this.#section_index)}"]`); 166 const currentSectionUuid = currentSectionElement.data('glpi-form-renderer-uuid'); 167 168 const response = await $.ajax({ 169 url: `${CFG_GLPI.root_doc}/Form/ValidateAnswers`, 170 type: 'POST', 171 data: `${$(this.#target).serialize()}§ion_uuid=${currentSectionUuid}`, 172 dataType: 'json', 173 }); 174 175 return this.#processValidationResponse(response); 176 } 177 178 async #checkAllSectionsValidity() { 179 const response = await $.ajax({ 180 url: `${CFG_GLPI.root_doc}/Form/ValidateAnswers`, 181 type: 'POST', 182 data: $(this.#target).serialize(), 183 dataType: 'json', 184 }); 185 186 return this.#processValidationResponse(response); 187 } 188 189 #processValidationResponse(response) { 190 // Remove previous error messages and aria attributes 191 $(this.#target) 192 .find(".invalid-tooltip") 193 .remove(); 194 $(this.#target) 195 .find(".is-invalid") 196 .removeClass("is-invalid") 197 .removeAttr("aria-invalid") 198 .removeAttr("aria-errormessage"); 199 200 if (response.success === false) { 201 let is_first_error = true; 202 Object.values(response.errors).forEach(error => { 203 // Highlight the field with error 204 const question = $(`[data-glpi-form-renderer-id="${CSS.escape(error.question_id)}"][data-glpi-form-renderer-question]`); 205 if (!question.length) { 206 return; 207 } 208 209 // Find the input field within the question 210 let has_input_field = true; 211 const inputFields = question.find('input:not([type=hidden]):not([data-uploader-name]):not(.select2-search__field), select, textarea'); 212 if (!inputFields.length) { 213 has_input_field = false; 214 } 215 216 // Generate a unique ID for the error message 217 const errorId = `error-${error.question_id}`; 218 219 // Add validation classes and accessibility attributes to all inputs 220 if (has_input_field) { 221 inputFields 222 .addClass('is-invalid') 223 .attr('aria-invalid', 'true') 224 .attr('aria-errormessage', errorId); 225 } 226 227 // Add a tooltip with the error message 228 let targetElement; 229 let position = "append"; 230 let extra_class = ""; 231 if (!has_input_field) { 232 // Specific instructions for file upload 233 targetElement = question; 234 extra_class = "d-block"; 235 } else if (inputFields.filter('.is-flatpicker').length > 0) { 236 // Specifics instructions for flatpicker. 237 // This is needed because it has an extra div that wrap the 238 // input, we want the tooltip to be right after that wrappper 239 // div. 240 targetElement = inputFields.parent(); 241 position = "after"; 242 extra_class = "d-block"; 243 } else if (inputFields.filter('[type="radio"], [type="checkbox"]').length > 0) { 244 // For radio/checkbox, find the first common parent 245 targetElement = inputFields.first().closest('.form-check').parent(); 246 if (targetElement.length === 0) { 247 targetElement = inputFields.first().parent(); 248 } 249 } else { 250 targetElement = inputFields.parent(); 251 } 252 253 const content = `<span id="${_.escape(errorId)}" class="invalid-tooltip ${extra_class}">${_.escape(error.message)}</span>`;
254 if (position === "append") { 255 targetElement.append(content); 256 } else { 257 targetElement.after(content); 258 } 259 260 // Make sure the first error is inside the viewport so the user 261 // can see it properly. 262 if (is_first_error) { 263 targetElement 264 .closest('[data-glpi-form-renderer-question]')[0] 265 .scrollIntoView() 266 ; 267 is_first_error = false; 268 } 269 }); 270 271 return false; 272 } 273 274 return true; 275 } 276 277 /** 278 * Submit the target form using an AJAX request. 279 */ 280 async #submitForm() { 281 const submit = $(this.#target).find('button[data-glpi-form-renderer-action=submit]'); 282 283 // Form will be sumitted using an AJAX request instead 284 try { 285 // Disable actions immediately to avoid someone clicking on the actions 286 // while the form is being submitted. 287 this.#disableActions(); 288 submit.addClass('btn-loading'); 289 290 // Update tinymce values 291 this.#saveTinymceEditors(); 292 293 if (!await this.#checkCurrentSectionValidity()) { 294 this.#enableActions(); 295 return; 296 } 297 298 // Submit form using AJAX 299 const response = await $.post({ 300 url: $(this.#target).prop("action"), 301 data: $(this.#target).serialize(), 302 }); 303 304 // Show toast with link to answers set 305 glpi_toast_info( 306 __("Item successfully created: %s").replace( 307 "%s", 308 response.links_to_created_items.join(", ") 309 ) 310 ); 311 312 // Show final confirmation step 313 $(this.#target) 314 .find("[data-glpi-form-renderer-success]") 315 .removeClass("d-none"); 316 317 // Hide everything else 318 let hideSelector = ` 319 [data-glpi-form-renderer-form-header], 320 [data-glpi-form-renderer-delegation-container], 321 [data-glpi-form-renderer-actions] 322 `; 323 324 if (this.#render_layout === 'single_page') { 325 // In single page mode, hide all sections 326 hideSelector += `, [data-glpi-form-renderer-section]`; 327 } else { 328 // In step-by-step mode, hide only current section 329 hideSelector += `, 330 [data-glpi-form-renderer-section="${CSS.escape(this.#section_index)}"], 331 [data-glpi-form-renderer-parent-section="${CSS.escape(this.#section_index)}"] 332 `; 333 } 334 335 $(this.#target) 336 .find(hideSelector) 337 .addClass("d-none"); 338 339 } catch (e) { 340 console.error(e); 341 342 // Always display the default error message 343 let errorMessage = __("Failed to submit form, please contact your administrator."); 344 345 // Try to extract and concatenate specific error messages from the response 346 if (e.responseJSON && e.responseJSON.errors && e.responseJSON.errors.length > 0) { 347 const specificErrors = e.responseJSON.errors 348 .map(msg => _.escape(msg)) 349 .join("<br>"); 350 errorMessage = `${errorMessage}<br><br><strong>${__("Details:")}</strong><br>${specificErrors}`; 351 } 352 353 glpi_toast_error(errorMessage); 354 } finally { 355 this.#enableActions(); 356 submit.removeClass('btn-loading'); 357 } 358 } 359 360 /** 361 * Go to the next section of the form. 362 */ 363 async #goToNextSection() { 364 // Don't navigate in single page mode 365 if (this.#render_layout === 'single_page') { 366 return; 367 } 368 369 if (!await this.#checkCurrentSectionValidity()) { 370 return; 371 } 372 373 // Hide current section and its questions 374 $(this.#target) 375 .find(` 376 [data-glpi-form-renderer-section="${CSS.escape(this.#section_index)}"], 377 [data-glpi-form-renderer-parent-section="${CSS.escape(this.#section_index)}"] 378 `) 379 .addClass("d-none"); 380 381 // Show next visible section and its questions 382 const next_section_index = this.#getNextVisibleSectionIndex(); 383 if (next_section_index === null) { 384 throw new Error('Impossible to load the next section'); 385 } 386 387 this.#section_index = next_section_index; 388 $(this.#target) 389 .find(` 390 [data-glpi-form-renderer-section="${CSS.escape(this.#section_index)}"], 391 [data-glpi-form-renderer-parent-section="${CSS.escape(this.#section_index)}"] 392 `) 393 .removeClass("d-none"); 394 395 // Update actions visibility 396 this.#updateActionsVisiblity(); 397 398 // Scroll to the top of the section 399 $('[data-glpi-form-renderer-section]:visible')[0].scrollIntoView(); 400 } 401 402 /** 403 * Go to the previous section of the form. 404 */ 405 #goToPreviousSection() { 406 // Don't navigate in single page mode 407 if (this.#render_layout === 'single_page') { 408 return; 409 } 410 411 // Hide current section and its questions 412 $(this.#target) 413 .find(` 414 [data-glpi-form-renderer-section="${CSS.escape(this.#section_index)}"], 415 [data-glpi-form-renderer-parent-section="${CSS.escape(this.#section_index)}"] 416 `) 417 .addClass("d-none"); 418 419 // Show previous visible section and its questions 420 const previous_section_index = this.#getPreviousVisibleSectionIndex(); 421 if (previous_section_index === null) { 422 throw new Error('Impossible to load the previous section'); 423 } 424 425 this.#section_index = previous_section_index; 426 $(this.#target) 427 .find(` 428 [data-glpi-form-renderer-section="${CSS.escape(this.#section_index)}"], 429 [data-glpi-form-renderer-parent-section="${CSS.escape(this.#section_index)}"] 430 `) 431 .removeClass("d-none"); 432 433 // Update actions visibility 434 this.#updateActionsVisiblity(); 435 436 // Scroll to the top of the section 437 $('[data-glpi-form-renderer-section]:visible')[0].scrollIntoView(); 438 } 439 440 /** 441 * Update the visibility of the actions buttons depending on the active 442 * section of the form. 443 */ 444 #updateActionsVisiblity() { 445 // In single page mode, no navigation logic needed (only submit button exists) 446 if (this.#render_layout === 'single_page') { 447 return; 448 } 449 450 // Step-by-step mode logic - manage navigation between sections 451 if (this.#hasOneVisibleSectionAfterCurrentIndex()) { 452 // Show "next" button if at least one other following section is visible 453 $(this.#target)
454 .find('[data-glpi-form-renderer-action="submit"]') 455 .addClass("d-none"); 456 $(this.#target) 457 .find('[data-glpi-form-renderer-action="next-section"]') 458 .removeClass("d-none"); 459 } else { 460 // Show "submit" button instead 461 $(this.#target) 462 .find('[data-glpi-form-renderer-action="submit"]') 463 .removeClass("d-none"); 464 $(this.#target) 465 .find('[data-glpi-form-renderer-action="next-section"]') 466 .addClass("d-none"); 467 } 468 469 if (this.#hasOneVisibleSectionBeforeCurrentIndex()) { 470 // Show "back" button if at least one previous section is visible 471 $(this.#target) 472 .find('[data-glpi-form-renderer-action="previous-section"]') 473 .removeClass("d-none"); 474 } else { 475 $(this.#target) 476 .find('[data-glpi-form-renderer-action="previous-section"]') 477 .addClass("d-none"); 478 } 479 } 480 481 async #computeItemsVisibilities() { 482 // Update tinymce values 483 this.#saveTinymceEditors(); 484 485 const results = await this.#condition_engine.computeVisiblity(this.#target); 486 this.#applyVisibilityResults(results); 487 this.#enableActions(); 488 } 489 490 #applyVisibilityResults(results) 491 { 492 const container = this.#target; 493 494 // Apply submit button visibility 495 const submit_button = container.querySelector( 496 '[data-glpi-form-renderer-action="submit"]' 497 ); 498 if (submit_button !== null) { 499 this.#applyVisibilityToItem(submit_button, results.form_visibility); 500 } 501 502 // Apply sections visibility 503 for (const [id, must_be_visible] of Object.entries( 504 results.sections_visibility 505 )) { 506 const section = container.querySelector( 507 `[data-glpi-form-renderer-section][data-glpi-form-renderer-id="${CSS.escape(id)}"]` 508 ); 509 if (section === null) { 510 continue; 511 } 512 513 // Can't change the visibility of the current section 514 if ($(section).data('glpi-form-renderer-section') == this.#section_index) { 515 continue; 516 } 517 518 this.#applyVisibilityToItem(section, must_be_visible); 519 }; 520 521 // Apply questions visibility 522 for (const [id, must_be_visible] of Object.entries( 523 results.questions_visibility 524 )) { 525 const question = container.querySelector( 526 `[data-glpi-form-renderer-question][data-glpi-form-renderer-id="${CSS.escape(id)}"]` 527 ); 528 if (question === null) { 529 continue; 530 } 531 this.#applyVisibilityToItem(question, must_be_visible); 532 }; 533 534 // Apply comments visibility 535 for (const [id, must_be_visible] of Object.entries( 536 results.comments_visibility 537 )) { 538 const comment = container.querySelector( 539 `[data-glpi-form-renderer-comment][data-glpi-form-renderer-id="${CSS.escape(id)}"]` 540 ); 541 if (comment === null) { 542 continue; 543 } 544 this.#applyVisibilityToItem(comment, must_be_visible); 545 }; 546 547 this.#updateActionsVisiblity(); 548 } 549 550 #applyVisibilityToItem(item, must_be_visible) 551 { 552 if (must_be_visible) { 553 item.removeAttribute("data-glpi-form-renderer-hidden-by-condition"); 554 } else { 555 item.setAttribute("data-glpi-form-renderer-hidden-by-condition", ""); 556 } 557 } 558 559 #getNextVisibleSectionIndex() 560 { 561 let index = null; 562 563 const sections = $(this.#target).find('[data-glpi-form-renderer-section]'); 564 sections.each((_i, section) => { 565 // Ignore previous and current section 566 if (parseInt(section.dataset.glpiFormRendererSection) <= this.#section_index) { 567 return; 568 } 569 570 // A visible section won't have the following data property 571 if (section.dataset.glpiFormRendererHiddenByCondition === undefined) { 572 index = parseInt(section.dataset.glpiFormRendererSection); 573 return false; // Break 574 } 575 }); 576 577 return index; 578 } 579 580 #getPreviousVisibleSectionIndex() 581 { 582 let index = null; 583 584 const sections = $(this.#target).find('[data-glpi-form-renderer-section]'); 585 sections.each((_i, section) => { 586 // Ignore next and current section 587 if (section.dataset.glpiFormRendererSection >= this.#section_index) { 588 return false; // Break 589 } 590 591 // A visible section won't have the following data property 592 if (section.dataset.glpiFormRendererHiddenByCondition === undefined) { 593 index = section.dataset.glpiFormRendererSection; 594 } 595 }); 596 597 return index; 598 } 599 600 #hasOneVisibleSectionAfterCurrentIndex() 601 { 602 return this.#getNextVisibleSectionIndex() !== null; 603 } 604 605 #hasOneVisibleSectionBeforeCurrentIndex() 606 { 607 return this.#getPreviousVisibleSectionIndex() !== null; 608 } 609 610 #disableActions() 611 {
612 // Do not use "disable" prop to avoid the button "flashing" back and 613 // forth. 614 $(this.#target) 615 .find("button[data-glpi-form-renderer-action]") 616 .addClass("pointer-events-none") 617 ; 618 } 619 620 #enableActions() 621 { 622 $(this.#target) 623 .find("button[data-glpi-form-renderer-action]") 624 .removeClass("pointer-events-none") 625 ; 626 } 627 628 async #renderDelegation() 629 { 630 const selected_user_id = $(this.#target) 631 .find('[data-glpi-form-renderer-delegation-container]') 632 .find('select[name="delegation_users_id"]') 633 .val(); 634 635 const response = await $.get('/Form/Delegation', { 636 'selected_user_id': selected_user_id, 637 }); 638 639 // Replace only the inner content of the delegation container 640 $(this.#target) 641 .find('[data-glpi-form-renderer-delegation-container]') 642 .html(response); 643 } 644}
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.