Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * Display a form in a modal dialogue
18
 *
19
 * Example:
20
 *    import ModalForm from 'core_form/modalform';
21
 *
22
 *    const modalForm = new ModalForm({
23
 *        formClass: 'pluginname\\form\\formname',
24
 *        modalConfig: {title: 'Here comes the title'},
25
 *        args: {categoryid: 123},
26
 *        returnFocus: e.target,
27
 *    });
28
 *    modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (c) => window.console.log(c.detail));
29
 *    modalForm.show();
30
 *
31
 * See also https://docs.moodle.org/dev/Modal_and_AJAX_forms
32
 *
33
 * @module     core_form/modalform
34
 * @copyright  2018 Mitxel Moriana <mitxel@tresipunt.>
35
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36
 */
37
 
38
import Ajax from 'core/ajax';
39
import * as FormChangeChecker from 'core_form/changechecker';
40
import * as FormEvents from 'core_form/events';
41
import Fragment from 'core/fragment';
42
import ModalEvents from 'core/modal_events';
43
import Notification from 'core/notification';
44
import Pending from 'core/pending';
45
import {serialize} from './util';
46
 
47
export default class ModalForm {
48
 
49
    /**
50
     * Various events that can be observed.
51
     *
52
     * @type {Object}
53
     */
54
    events = {
55
        // Form was successfully submitted - the response is passed to the event listener.
56
        // Cancellable (but it's hardly ever needed to cancel this event).
57
        FORM_SUBMITTED: 'core_form_modalform_formsubmitted',
58
        // Cancel button was pressed.
59
        // Cancellable (but it's hardly ever needed to cancel this event).
60
        FORM_CANCELLED: 'core_form_modalform_formcancelled',
61
        // User attempted to submit the form but there was client-side validation error.
62
        CLIENT_VALIDATION_ERROR: 'core_form_modalform_clientvalidationerror',
63
        // User attempted to submit the form but server returned validation error.
64
        SERVER_VALIDATION_ERROR: 'core_form_modalform_validationerror',
65
        // Error occurred while performing request to the server.
66
        // Cancellable (by default calls Notification.exception).
67
        ERROR: 'core_form_modalform_error',
68
        // Right after user pressed no-submit button,
69
        // listen to this event if you want to add JS validation or processing for no-submit button.
70
        // Cancellable.
71
        NOSUBMIT_BUTTON_PRESSED: 'core_form_modalform_nosubmitbutton',
72
        // Right after user pressed submit button,
73
        // listen to this event if you want to add additional JS validation or confirmation dialog.
74
        // Cancellable.
75
        SUBMIT_BUTTON_PRESSED: 'core_form_modalform_submitbutton',
76
        // Right after user pressed cancel button,
77
        // listen to this event if you want to add confirmation dialog.
78
        // Cancellable.
79
        CANCEL_BUTTON_PRESSED: 'core_form_modalform_cancelbutton',
80
        // Modal was loaded and this.modal is available (but the form content may not be loaded yet).
81
        LOADED: 'core_form_modalform_loaded',
82
    };
83
 
84
    /**
85
     * Constructor
86
     *
87
     * Shows the required form inside a modal dialogue
88
     *
89
     * @param {Object} config parameters for the form and modal dialogue:
90
     * @paramy {String} config.formClass PHP class name that handles the form (should extend \core_form\modal )
91
     * @paramy {String} config.moduleName module name to use if different to core/modal_save_cancel (optional)
92
     * @paramy {Object} config.modalConfig modal config - title, header, footer, etc.
93
     *              Default: {removeOnClose: true, large: true}
94
     * @paramy {Object} config.args Arguments for the initial form rendering (for example, id of the edited entity)
95
     * @paramy {String} config.saveButtonText the text to display on the Modal "Save" button (optional)
96
     * @paramy {String} config.saveButtonClasses additional CSS classes for the Modal "Save" button
97
     * @paramy {HTMLElement} config.returnFocus element to return focus to after the dialogue is closed
98
     */
99
    constructor(config) {
100
        this.modal = null;
101
        this.config = config;
102
        this.config.modalConfig = {
103
            removeOnClose: true,
104
            large: true,
105
            ...(this.config.modalConfig || {}),
106
        };
107
        this.config.args = this.config.args || {};
108
        this.futureListeners = [];
109
    }
110
 
111
    /**
112
     * Loads the modal module and creates an instance
113
     *
114
     * @returns {Promise}
115
     */
116
    getModalModule() {
117
        if (!this.config.moduleName && this.config.modalConfig.type && this.config.modalConfig.type !== 'SAVE_CANCEL') {
118
            // Legacy loader for plugins that were not updated with Moodle 4.3 changes.
119
            window.console.warn(
120
                'Passing config.modalConfig.type to ModalForm has been deprecated since Moodle 4.3. ' +
121
                'Please pass config.modalName instead with the full module name.',
122
            );
123
            return import('core/modal_factory')
124
                .then((ModalFactory) => ModalFactory.create(this.config.modalConfig));
125
        } else {
126
            // New loader for Moodle 4.3 and above.
127
            const moduleName = this.config.moduleName ?? 'core/modal_save_cancel';
128
            return import(moduleName)
129
                .then((module) => module.create(this.config.modalConfig));
130
        }
131
    }
132
 
133
    /**
134
     * Initialise the modal and shows it
135
     *
136
     * @return {Promise}
137
     */
138
    show() {
139
        const pendingPromise = new Pending('core_form/modalform:init');
140
 
141
        return this.getModalModule()
142
        .then((modal) => {
143
            this.modal = modal;
144
 
145
            // Retrieve the form and set the modal body. We can not set the body in the modalConfig,
146
            // we need to make sure that the modal already exists when we render the form. Some form elements
147
            // such as date_selector inspect the existing elements on the page to find the highest z-index.
148
            const formParams = serialize(this.config.args || {});
149
            const bodyContent = this.getBody(formParams);
150
            this.modal.setBodyContent(bodyContent);
151
            bodyContent.catch(Notification.exception);
152
 
153
            // After successfull submit, when we press "Cancel" or close the dialogue by clicking on X in the top right corner.
154
            this.modal.getRoot().on(ModalEvents.hidden, () => {
155
                this.notifyResetFormChanges();
156
                this.modal.destroy();
157
                // Focus on the element that actually launched the modal.
158
                if (this.config.returnFocus) {
159
                    this.config.returnFocus.focus();
160
                }
161
            });
162
 
163
            // Add the class to the modal dialogue.
164
            this.modal.getModal().addClass('modal-form-dialogue');
165
 
166
            // We catch the press on submit buttons in the forms.
167
            this.modal.getRoot().on('click', 'form input[type=submit][data-no-submit]',
168
                (e) => {
169
                    e.preventDefault();
170
                    const event = this.trigger(this.events.NOSUBMIT_BUTTON_PRESSED, e.target);
171
                    if (!event.defaultPrevented) {
172
                        this.processNoSubmitButton(e.target);
173
                    }
174
                });
175
 
176
            // We catch the form submit event and use it to submit the form with ajax.
177
            this.modal.getRoot().on('submit', 'form', (e) => {
178
                e.preventDefault();
179
                const event = this.trigger(this.events.SUBMIT_BUTTON_PRESSED);
180
                if (!event.defaultPrevented) {
181
                    this.submitFormAjax();
182
                }
183
            });
184
 
185
            // Change the text for the save button.
186
            if (typeof this.config.saveButtonText !== 'undefined' &&
187
                typeof this.modal.setSaveButtonText !== 'undefined') {
188
                this.modal.setSaveButtonText(this.config.saveButtonText);
189
            }
190
            // Set classes for the save button.
191
            if (typeof this.config.saveButtonClasses !== 'undefined') {
192
                this.setSaveButtonClasses(this.config.saveButtonClasses);
193
            }
194
            // When Save button is pressed - submit the form.
195
            this.modal.getRoot().on(ModalEvents.save, (e) => {
196
                e.preventDefault();
197
                this.modal.getRoot().find('form').submit();
198
            });
199
 
200
            // When Cancel button is pressed - allow to intercept.
201
            this.modal.getRoot().on(ModalEvents.cancel, (e) => {
202
                const event = this.trigger(this.events.CANCEL_BUTTON_PRESSED);
203
                if (event.defaultPrevented) {
204
                    e.preventDefault();
205
                }
206
            });
207
            this.futureListeners.forEach(args => this.modal.getRoot()[0].addEventListener(...args));
208
            this.futureListeners = [];
209
            this.trigger(this.events.LOADED, null, false);
210
            return this.modal.show();
211
        })
212
        .then(pendingPromise.resolve);
213
    }
214
 
215
    /**
216
     * Triggers a custom event
217
     *
218
     * @private
219
     * @param {String} eventName
220
     * @param {*} detail
221
     * @param {Boolean} cancelable
222
     * @return {CustomEvent<unknown>}
223
     */
224
    trigger(eventName, detail = null, cancelable = true) {
225
        const e = new CustomEvent(eventName, {detail, cancelable});
226
        this.modal.getRoot()[0].dispatchEvent(e);
227
        return e;
228
    }
229
 
230
    /**
231
     * Add listener for an event
232
     *
233
     * @param {array} args
234
     * @example:
235
     *    const modalForm = new ModalForm(...);
236
     *    dynamicForm.addEventListener(modalForm.events.FORM_SUBMITTED, e => {
237
     *        window.console.log(e.detail);
238
     *    });
239
     */
240
    addEventListener(...args) {
241
        if (!this.modal) {
242
            this.futureListeners.push(args);
243
        } else {
244
            this.modal.getRoot()[0].addEventListener(...args);
245
        }
246
    }
247
 
248
    /**
249
     * Get form contents (to be used in ModalForm.setBodyContent())
250
     *
251
     * @param {String} formDataString form data in format of a query string
252
     * @method getBody
253
     * @private
254
     * @return {Promise}
255
     */
256
    getBody(formDataString) {
257
        const params = {
258
            formdata: formDataString,
259
            form: this.config.formClass
260
        };
261
        const pendingPromise = new Pending('core_form/modalform:form_body');
262
        return Ajax.call([{
263
            methodname: 'core_form_dynamic_form',
264
            args: params
265
        }])[0]
266
        .then(response => {
267
            pendingPromise.resolve();
268
            return {html: response.html, js: Fragment.processCollectedJavascript(response.javascript)};
269
        })
270
        .catch(exception => this.onSubmitError(exception));
271
    }
272
 
273
    /**
274
     * On exception during form processing or initial rendering. Caller may override.
275
     *
276
     * @param {Object} exception
277
     */
278
    onSubmitError(exception) {
279
        const event = this.trigger(this.events.ERROR, exception);
280
        if (event.defaultPrevented) {
281
            return;
282
        }
283
 
284
        Notification.exception(exception);
285
    }
286
 
287
    /**
288
     * Notifies listeners that form dirty state should be reset.
289
     *
290
     * @fires event:formSubmittedByJavascript
291
     */
292
    notifyResetFormChanges() {
293
        const form = this.getFormNode();
294
        if (!form) {
295
            return;
296
        }
297
 
298
        FormEvents.notifyFormSubmittedByJavascript(form, true);
299
 
300
        FormChangeChecker.resetFormDirtyState(form);
301
    }
302
 
303
    /**
304
     * Get the form node from the Dialogue.
305
     *
306
     * @returns {HTMLFormElement}
307
     */
308
    getFormNode() {
309
        return this.modal.getRoot().find('form')[0];
310
    }
311
 
312
    /**
313
     * Click on a "submit" button that is marked in the form as registerNoSubmitButton()
314
     *
315
     * @param {Element} button button that was pressed
316
     * @fires event:formSubmittedByJavascript
317
     */
318
    processNoSubmitButton(button) {
319
        const form = this.getFormNode();
320
        if (!form) {
321
            return;
322
        }
323
 
324
        FormEvents.notifyFormSubmittedByJavascript(form, true);
325
 
326
        // Add the button name to the form data and submit it.
327
        let formData = this.modal.getRoot().find('form').serialize();
328
        formData = formData + '&' + encodeURIComponent(button.getAttribute('name')) + '=' +
329
            encodeURIComponent(button.getAttribute('value'));
330
 
331
        const bodyContent = this.getBody(formData);
332
        this.modal.setBodyContent(bodyContent);
333
        bodyContent.catch(Notification.exception);
334
    }
335
 
336
    /**
337
     * Validate form elements
338
     * @return {Boolean} Whether client-side validation has passed, false if there are errors
339
     * @fires event:formSubmittedByJavascript
340
     */
341
    validateElements() {
342
        FormEvents.notifyFormSubmittedByJavascript(this.getFormNode());
343
 
344
        // Now the change events have run, see if there are any "invalid" form fields.
345
        /** @var {jQuery} list of elements with errors */
346
        const invalid = this.modal.getRoot().find('[aria-invalid="true"], .error');
347
 
348
        // If we found invalid fields, focus on the first one and do not submit via ajax.
349
        if (invalid.length) {
350
            invalid.first().focus();
351
            return false;
352
        }
353
 
354
        return true;
355
    }
356
 
357
    /**
358
     * Disable buttons during form submission
359
     */
360
    disableButtons() {
361
        this.modal.getFooter().find('[data-action]').attr('disabled', true);
362
    }
363
 
364
    /**
365
     * Enable buttons after form submission (on validation error)
366
     */
367
    enableButtons() {
368
        this.modal.getFooter().find('[data-action]').removeAttr('disabled');
369
    }
370
 
371
    /**
372
     * Submit the form via AJAX call to the core_form_dynamic_form WS
373
     */
374
    async submitFormAjax() {
375
        // If we found invalid fields, focus on the first one and do not submit via ajax.
376
        if (!this.validateElements()) {
377
            this.trigger(this.events.CLIENT_VALIDATION_ERROR, null, false);
378
            return;
379
        }
380
        this.disableButtons();
381
 
382
        // Convert all the form elements values to a serialised string.
383
        const form = this.modal.getRoot().find('form');
384
        const formData = form.serialize();
385
 
386
        // Now we can continue...
387
        Ajax.call([{
388
            methodname: 'core_form_dynamic_form',
389
            args: {
390
                formdata: formData,
391
                form: this.config.formClass
392
            }
393
        }])[0]
394
        .then((response) => {
395
            if (!response.submitted) {
396
                // Form was not submitted because validation failed.
397
                const promise = new Promise(
398
                    resolve => resolve({html: response.html, js: Fragment.processCollectedJavascript(response.javascript)}));
399
                this.modal.setBodyContent(promise);
400
                this.enableButtons();
401
                this.trigger(this.events.SERVER_VALIDATION_ERROR);
402
            } else {
403
                // Form was submitted properly. Hide the modal and execute callback.
404
                const data = JSON.parse(response.data);
405
                FormChangeChecker.markFormSubmitted(form[0]);
406
                const event = this.trigger(this.events.FORM_SUBMITTED, data);
407
                if (!event.defaultPrevented) {
408
                    this.modal.hide();
409
                }
410
            }
411
            return null;
412
        })
413
        .catch(exception => {
414
            this.enableButtons();
415
            this.onSubmitError(exception);
416
        });
417
    }
418
 
419
    /**
420
     * Set the classes for the 'save' button.
421
     *
422
     * @method setSaveButtonClasses
423
     * @param {(String)} value The 'save' button classes.
424
     */
425
    setSaveButtonClasses(value) {
426
        const button = this.modal.getFooter().find("[data-action='save']");
427
        if (!button) {
428
            throw new Error("Unable to find the 'save' button");
429
        }
430
        button.removeClass().addClass(value);
431
    }
432
}