// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Display an embedded form, it is only loaded and reloaded inside its container
*
*
* @module core_form/dynamicform
* @copyright 2019 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* See also https://docs.moodle.org/dev/Modal_and_AJAX_forms
*
* @example
* import DynamicForm from 'core_form/dynamicform';
*
* const dynamicForm = new DynamicForm(document.querySelector('#mycontainer', 'pluginname\\form\\formname');
* dynamicForm.addEventListener(dynamicForm.events.FORM_SUBMITTED, e => {
* e.preventDefault();
* window.console.log(e.detail);
* dynamicForm.container.innerHTML = 'Thank you, your form is submitted!';
* });
* dynamicForm.load();
*
*/
import * as FormChangeChecker from 'core_form/changechecker';
import * as FormEvents from 'core_form/events';
import Ajax from 'core/ajax';
import Fragment from 'core/fragment';
import Notification from 'core/notification';
import Pending from 'core/pending';
import Templates from 'core/templates';
import {getStrings} from 'core/str';
import {serialize} from './util';
/**
* @class core_form/dynamicform
*/
export default class DynamicForm {
/**
* Various events that can be observed.
*
* @type {Object}
*/
events = {
// Form was successfully submitted - the response is passed to the event listener.
// Cancellable (in order to prevent default behavior to clear the container).
FORM_SUBMITTED: 'core_form_dynamicform_formsubmitted',
// Cancel button was pressed.
// Cancellable (in order to prevent default behavior to clear the container).
FORM_CANCELLED: 'core_form_dynamicform_formcancelled',
// User attempted to submit the form but there was client-side validation error.
CLIENT_VALIDATION_ERROR: 'core_form_dynamicform_clientvalidationerror',
// User attempted to submit the form but server returned validation error.
SERVER_VALIDATION_ERROR: 'core_form_dynamicform_validationerror',
// Error occurred while performing request to the server.
// Cancellable (by default calls Notification.exception).
ERROR: 'core_form_dynamicform_error',
// Right after user pressed no-submit button,
// listen to this event if you want to add JS validation or processing for no-submit button.
// Cancellable.
NOSUBMIT_BUTTON_PRESSED: 'core_form_dynamicform_nosubmitbutton',
// Right after user pressed submit button,
// listen to this event if you want to add additional JS validation or confirmation dialog.
// Cancellable.
SUBMIT_BUTTON_PRESSED: 'core_form_dynamicform_submitbutton',
// Right after user pressed cancel button,
// listen to this event if you want to add confirmation dialog.
// Cancellable.
CANCEL_BUTTON_PRESSED: 'core_form_dynamicform_cancelbutton',
};
/**
* Constructor
*
* Creates an instance
*
* @param {Element} container - the parent element for the form
* @param {string} formClass full name of the php class that extends \core_form\modal , must be in autoloaded location
*/
constructor(container, formClass) {
this.formClass = formClass;
this.container = container;
// Ensure strings required for shortforms are always available.
getStrings([
{key: 'collapseall', component: 'moodle'},
{key: 'expandall', component: 'moodle'}
]).catch(Notification.exception);
// Register delegated events handlers in vanilla JS.
this.container.addEventListener('click', e => {
if (e.target.matches('form input[type=submit][data-cancel]')) {
e.preventDefault();
const event = this.trigger(this.events.CANCEL_BUTTON_PRESSED, e.target);
if (!event.defaultPrevented) {
this.processCancelButton();
}
} else if (e.target.matches('form input[type=submit][data-no-submit="1"]')) {
e.preventDefault();
const event = this.trigger(this.events.NOSUBMIT_BUTTON_PRESSED, e.target);
if (!event.defaultPrevented) {
this.processNoSubmitButton(e.target);
}
}
});
this.container.addEventListener('submit', e => {
if (e.target.matches('form')) {
e.preventDefault();
const event = this.trigger(this.events.SUBMIT_BUTTON_PRESSED);
if (!event.defaultPrevented) {
this.submitFormAjax();
}
}
});
}
/**
* Loads the form via AJAX and shows it inside a given container
*
* @param {Object} args
* @return {Promise}
* @public
*/
load(args = null) {
const formData = serialize(args || {});
const pendingPromise = new Pending('core_form/dynamicform:load');
return this.getBody(formData)
.then((resp) => this.updateForm(resp))
.then(pendingPromise.resolve);
}
/**
* Triggers a custom event
*
* @private
* @param {String} eventName
* @param {*} detail
* @param {Boolean} cancelable
* @return {CustomEvent<unknown>}
*/
trigger(eventName, detail = null, cancelable = true) {
const e = new CustomEvent(eventName, {detail, cancelable});
this.container.dispatchEvent(e);
return e;
}
/**
* Add listener for an event
*
* @param {array} args
* @example:
* const dynamicForm = new DynamicForm(...);
* dynamicForm.addEventListener(dynamicForm.events.FORM_SUBMITTED, e => {
* e.preventDefault();
* window.console.log(e.detail);
* dynamicForm.container.innerHTML = 'Thank you, your form is submitted!';
* });
*/
addEventListener(...args) {
this.container.addEventListener(...args);
}
/**
* Get form body
*
* @param {String} formDataString form data in format of a query string
* @private
* @return {Promise}
*/
getBody(formDataString) {
return Ajax.call([{
methodname: 'core_form_dynamic_form',
args: {
formdata: formDataString,
form: this.formClass,
}
}])[0]
.then(response => {
return {html: response.html, js: Fragment.processCollectedJavascript(response.javascript)};
});
}
/**
* On form submit
*
* @param {*} response Response received from the form's "process" method
*/
onSubmitSuccess(response) {
const event = this.trigger(this.events.FORM_SUBMITTED, response);
if (event.defaultPrevented) {
return;
}
// Default implementation is to remove the form. Event listener should either remove or reload the form
// since its contents is no longer correct. For example, if an element was created as a result of
// form submission, the "id" in the form would be still zero. Also the server-side validation
// errors from the previous submission may still be present.
this.container.innerHTML = '';
}
/**
* On exception during form processing
*
* @private
* @param {Object} exception
*/
onSubmitError(exception) {
const event = this.trigger(this.events.ERROR, exception);
if (event.defaultPrevented) {
return;
}
Notification.exception(exception);
}
/**
* Click on a "submit" button that is marked in the form as registerNoSubmitButton()
*
* @method submitButtonPressed
* @param {Element} button that was pressed
* @fires event:formSubmittedByJavascript
*/
processNoSubmitButton(button) {
const pendingPromise = new Pending('core_form/dynamicform:nosubmit');
const form = this.getFormNode();
const formData = new URLSearchParams([...(new FormData(form)).entries()]);
formData.append(button.getAttribute('name'), button.getAttribute('value'));
FormEvents.notifyFormSubmittedByJavascript(form, true);
// Add the button name to the form data and submit it.
this.disableButtons();
this.getBody(formData.toString())
.then(resp => this.updateForm(resp))
.then(pendingPromise.resolve)
.catch(exception => this.onSubmitError(exception));
}
/**
* Get the form node from the Dialogue.
*
* @returns {HTMLFormElement}
*/
getFormNode() {
return this.container.querySelector('form');
}
/**
* Notifies listeners that form dirty state should be reset.
*
* @fires event:formSubmittedByJavascript
*/
notifyResetFormChanges() {
FormEvents.notifyFormSubmittedByJavascript(this.getFormNode(), true);
FormChangeChecker.resetFormDirtyState(this.getFormNode());
}
/**
* Click on a "cancel" button
*/
processCancelButton() {
// Notify listeners that the form is about to be submitted (this will reset atto autosave).
this.notifyResetFormChanges();
const event = this.trigger(this.events.FORM_CANCELLED);
if (!event.defaultPrevented) {
// By default removes the form from the DOM.
this.container.innerHTML = '';
}
}
/**
* Update form contents
*
* @param {object} param
* @param {string} param.html
* @param {string} param.js
* @returns {Promise}
*/
updateForm({html, js}) {
return Templates.replaceNodeContents(this.container, html, js);
}
/**
* Validate form elements
* @return {Boolean} Whether client-side validation has passed, false if there are errors
* @fires event:formSubmittedByJavascript
*/
validateElements() {
// Notify listeners that the form is about to be submitted (this will reset atto autosave).
FormEvents.notifyFormSubmittedByJavascript(this.getFormNode());
// Now the change events have run, see if there are any "invalid" form fields.
const invalid = [...this.container.querySelectorAll('[aria-invalid="true"], .error')];
// If we found invalid fields, focus on the first one and do not submit via ajax.
if (invalid.length) {
invalid[0].focus();
return false;
}
return true;
}
/**
* Disable buttons during form submission
*/
disableButtons() {
this.container.querySelectorAll('form input[type="submit"]')
.forEach(el => el.setAttribute('disabled', true));
}
/**
* Enable buttons after form submission (on validation error)
*/
enableButtons() {
this.container.querySelectorAll('form input[type="submit"]')
.forEach(el => el.removeAttribute('disabled'));
}
/**
* Submit the form via AJAX call to the core_form_dynamic_form WS
*/
async submitFormAjax() {
// If we found invalid fields, focus on the first one and do not submit via ajax.
if (!(await this.validateElements())) {
this.trigger(this.events.CLIENT_VALIDATION_ERROR, null, false);
return;
}
this.disableButtons();
// Convert all the form elements values to a serialised string.
const form = this.container.querySelector('form');
const formData = new URLSearchParams([...(new FormData(form)).entries()]);
// Now we can continue...
Ajax.call([{
methodname: 'core_form_dynamic_form',
args: {
formdata: formData.toString(),
form: this.formClass
}
}])[0]
.then((response) => {
if (!response.submitted) {
// Form was not submitted, it could be either because validation failed or because no-submit button was pressed.
this.updateForm({html: response.html, js: Fragment.processCollectedJavascript(response.javascript)});
this.enableButtons();
this.trigger(this.events.SERVER_VALIDATION_ERROR, null, false);
} else {
// Form was submitted properly.
const data = JSON.parse(response.data);
this.enableButtons();
this.notifyResetFormChanges();
this.onSubmitSuccess(data);
}
return null;
})
.catch(exception => this.onSubmitError(exception));
}
}