lib/form/amd/src/modalform.js

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