PageSourceSearch

https://tickets.informaticawe.com/js/modules/Forms/RendererController.js?v=7ac37cc1

js informaticawe.com collected 2026-10-10 02:24:45 UTC 22,318 bytes, 644 lines download raw bytes

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()}&section_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.