Merge branch 'MDL-64554-master' of git://github.com/andrewnicols/moodle

This commit is contained in:
Andrew Nicols 2021-02-18 10:21:55 +08:00
commit d6e50d14d2
60 changed files with 1920 additions and 461 deletions

View file

@ -1,2 +1,2 @@
define ("core/fragment",["jquery","core/ajax"],function(a,b){var c=function loadFragment(a,c,d,e){var f=[];for(var g in e){f.push({name:g,value:e[g]})}return b.call([{methodname:"core_get_fragment",args:{component:a,callback:c,contextid:d,args:f}}])[0]};return{loadFragment:function loadFragment(b,d,e,f){var g=a.Deferred();c(b,d,e,f).then(function(b){var c=a(b.javascript),d="";c.each(function(b,c){c=a(c);var e=c.prop("tagName");if(e&&"script"==e.toLowerCase()){if(c.attr("src")){var f=!1;a("script").each(function(b,d){if(a(d).attr("src")==c.attr("src")){f=!0}return!f});if(!f){d+=" { ";d+=" node = document.createElement(\"script\"); ";d+=" node.type = \"text/javascript\"; ";d+=" node.src = decodeURI(\""+encodeURI(c.attr("src"))+"\"); ";d+=" document.getElementsByTagName(\"head\")[0].appendChild(node); ";d+=" } "}}else{d+=" "+c.text()}}});g.resolve(b.html,d)}).fail(function(a){g.reject(a)});return g.promise()}}});
define ("core/fragment",["jquery","core/ajax"],function(a,b){var c=function loadFragment(a,c,d,e){var f=[];for(var g in e){f.push({name:g,value:e[g]})}return b.call([{methodname:"core_get_fragment",args:{component:a,callback:c,contextid:d,args:f}}])[0]},d=function processCollectedJavascript(b){var c=a(b),d="";c.each(function(b,c){c=a(c);var e=c.prop("tagName");if(e&&"script"==e.toLowerCase()){if(c.attr("src")){var f=!1;a("script").each(function(b,d){if(a(d).attr("src")==c.attr("src")){f=!0}return!f});if(!f){d+=" { ";d+=" node = document.createElement(\"script\"); ";d+=" node.type = \"text/javascript\"; ";d+=" node.src = decodeURI(\""+encodeURI(c.attr("src"))+"\"); ";d+=" document.getElementsByTagName(\"head\")[0].appendChild(node); ";d+=" } "}}else{d+=" "+c.text()}}});return d};return{loadFragment:function loadFragment(b,e,f,g){var h=a.Deferred();c(b,e,f,g).then(function(a){h.resolve(a.html,d(a.javascript))}).fail(function(a){h.reject(a)});return h.promise()},processCollectedJavascript:function processCollectedJavascript(a){return d(a)}}});
//# sourceMappingURL=fragment.min.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -56,6 +56,44 @@ define(['jquery', 'core/ajax'], function($, ajax) {
}])[0];
};
/**
* Converts the JS that was received from collecting JS requirements on the $PAGE so it can be added to the existing page
*
* @param {string} js
* @return {string}
*/
var processCollectedJavascript = function(js) {
var jsNodes = $(js);
var allScript = '';
jsNodes.each(function(index, scriptNode) {
scriptNode = $(scriptNode);
var tagName = scriptNode.prop('tagName');
if (tagName && (tagName.toLowerCase() == 'script')) {
if (scriptNode.attr('src')) {
// We only reload the script if it was not loaded already.
var exists = false;
$('script').each(function(index, s) {
if ($(s).attr('src') == scriptNode.attr('src')) {
exists = true;
}
return !exists;
});
if (!exists) {
allScript += ' { ';
allScript += ' node = document.createElement("script"); ';
allScript += ' node.type = "text/javascript"; ';
allScript += ' node.src = decodeURI("' + encodeURI(scriptNode.attr('src')) + '"); ';
allScript += ' document.getElementsByTagName("head")[0].appendChild(node); ';
allScript += ' } ';
}
} else {
allScript += ' ' + scriptNode.text();
}
}
});
return allScript;
};
return /** @alias module:core/fragment */{
/**
* Appends HTML and JavaScript fragments to specified nodes.
@ -72,40 +110,21 @@ define(['jquery', 'core/ajax'], function($, ajax) {
loadFragment: function(component, callback, contextid, params) {
var promise = $.Deferred();
loadFragment(component, callback, contextid, params).then(function(data) {
var jsNodes = $(data.javascript);
var allScript = '';
jsNodes.each(function(index, scriptNode) {
scriptNode = $(scriptNode);
var tagName = scriptNode.prop('tagName');
if (tagName && (tagName.toLowerCase() == 'script')) {
if (scriptNode.attr('src')) {
// We only reload the script if it was not loaded already.
var exists = false;
$('script').each(function(index, s) {
if ($(s).attr('src') == scriptNode.attr('src')) {
exists = true;
}
return !exists;
});
if (!exists) {
allScript += ' { ';
allScript += ' node = document.createElement("script"); ';
allScript += ' node.type = "text/javascript"; ';
allScript += ' node.src = decodeURI("' + encodeURI(scriptNode.attr('src')) + '"); ';
allScript += ' document.getElementsByTagName("head")[0].appendChild(node); ';
allScript += ' } ';
}
} else {
allScript += ' ' + scriptNode.text();
}
}
});
promise.resolve(data.html, allScript);
return;
promise.resolve(data.html, processCollectedJavascript(data.javascript));
}).fail(function(ex) {
promise.reject(ex);
});
return promise.promise();
},
/**
* Converts the JS that was received from collecting JS requirements on the $PAGE so it can be added to the existing page
*
* @param {string} js
* @return {string}
*/
processCollectedJavascript: function(js) {
return processCollectedJavascript(js);
}
};
});

View file

@ -445,6 +445,20 @@ define([
}
};
/**
* Alternative to setBody() that can be used from non-Jquery modules
*
* @param {Promise} promise promise that returns {html, js} object
* @return {Promise}
*/
Modal.prototype.setBodyContent = function(promise) {
// Call the leegacy API for now and pass it a jQuery Promise.
// This is a non-spec feature of jQuery and cannot be produced with spec promises.
// We can encourage people to migrate to this approach, and in future we can swap
// it so that setBody() calls setBodyPromise().
return promise.then(({html, js}) => this.setBody($.when(html, js)));
};
/**
* Set the modal footer element. The footer element is made visible, if it
* isn't already.

View file

@ -144,6 +144,7 @@ XPATH
XPATH
, 'dialogue' => <<<XPATH
.//div[contains(concat(' ', normalize-space(@class), ' '), ' moodle-dialogue ') and
not(contains(concat(' ', normalize-space(@class), ' '), ' moodle-dialogue-hidden ')) and
normalize-space(descendant::div[
contains(concat(' ', normalize-space(@class), ' '), ' moodle-dialogue-hd ')
]) = %locator%] |

View file

@ -837,6 +837,13 @@ $functions = array(
'loginrequired' => false,
'ajax' => true,
),
'core_form_dynamic_form' => array(
'classname' => 'core_form\external\dynamic_form',
'methodname' => 'execute',
'description' => 'Process submission of a dynamic (modal) form',
'type' => 'write',
'ajax' => true,
),
'core_get_component_strings' => array(
'classname' => 'core_external',
'methodname' => 'get_component_strings',

2
lib/form/amd/build/dynamicform.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
lib/form/amd/build/modalform.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,389 @@
// 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
*
* 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();
*
* See also https://docs.moodle.org/dev/Modal_and_AJAX_forms
*
* @module core_form/dynamicform
* @package core_form
* @copyright 2019 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Ajax from 'core/ajax';
import Notification from 'core/notification';
import Templates from 'core/templates';
import Event from 'core/event';
import {get_strings as getStrings} from 'core/str';
import Y from 'core/yui';
import Fragment from 'core/fragment';
import Pending from 'core/pending';
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 = new URLSearchParams(Object.entries(args || {}));
const pendingPromise = new Pending('core_form/dynamicform:load');
return this.getBody(formData.toString())
.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
*
* 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
*/
processNoSubmitButton(button) {
const pendingPromise = new Pending('core_form/dynamicform:nosubmit');
const form = this.container.querySelector('form');
const formData = new URLSearchParams([...(new FormData(form)).entries()]);
formData.append(button.getAttribute('name'), button.getAttribute('value'));
this.notifyFormSubmitAjax(true)
.then(() => {
// Add the button name to the form data and submit it.
this.disableButtons();
return this.getBody(formData.toString());
})
.then((resp) => this.updateForm(resp))
.finally(pendingPromise.resolve)
.catch(this.onSubmitError);
}
/**
* Wrapper for Event.notifyFormSubmitAjax that waits for the module to load
*
* We often destroy the form right after calling this function and we need to make sure that it actually
* completes before it, or otherwise it will try to work with a form that does not exist.
*
* @param {Boolean} skipValidation
* @return {Promise}
*/
notifyFormSubmitAjax(skipValidation = false) {
const form = this.container.querySelector('form');
return new Promise(resolve => {
Y.use('event', 'moodle-core-event', 'moodle-core-formchangechecker', function() {
Event.notifyFormSubmitAjax(form, skipValidation);
resolve();
});
});
}
/**
* Notifies listeners that form dirty state should be reset.
*
* @return {Promise<unknown>}
*/
notifyResetFormChanges() {
const form = this.container.querySelector('form');
return new Promise(resolve => {
Y.use('event', 'moodle-core-event', 'moodle-core-formchangechecker', () => {
Event.notifyFormSubmitAjax(form, true);
M.core_formchangechecker.reset_form_dirty_state();
resolve();
});
});
}
/**
* Click on a "cancel" button
*/
processCancelButton() {
// Notify listeners that the form is about to be submitted (this will reset atto autosave).
this.notifyResetFormChanges()
.then(() => {
const event = this.trigger(this.events.FORM_CANCELLED);
if (!event.defaultPrevented) {
// By default removes the form from the DOM.
this.container.innerHTML = '';
}
return null;
})
.catch(null);
}
/**
* Update form contents
*
* @param {string} html
* @param {string} js
*/
updateForm({html, js}) {
Templates.replaceNodeContents(this.container, html, js);
}
/**
* Validate form elements
* @return {Promise} promise that returns true if client-side validation has passed, false if there are errors
*/
validateElements() {
// Notify listeners that the form is about to be submitted (this will reset atto autosave).
return this.notifyFormSubmitAjax()
.then(() => {
// 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()
.then(() => this.onSubmitSuccess(data))
.catch();
}
return null;
})
.catch(this.onSubmitError);
}
}

View file

@ -0,0 +1,408 @@
// 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 a form in a modal dialogue
*
* Example:
* import ModalForm from 'core_form/modalform';
*
* const modalForm = new ModalForm({
* formClass: 'pluginname\\form\\formname',
* modalConfig: {title: 'Here comes the title'},
* args: {categoryid: 123},
* returnFocus: e.target,
* });
* modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (c) => window.console.log(c.detail));
* modalForm.show();
*
* See also https://docs.moodle.org/dev/Modal_and_AJAX_forms
*
* @module core_form/modalform
* @package core_form
* @copyright 2018 Mitxel Moriana <mitxel@tresipunt.>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import ModalFactory from 'core/modal_factory';
import ModalEvents from 'core/modal_events';
import Ajax from 'core/ajax';
import Notification from 'core/notification';
import Y from 'core/yui';
import Event from 'core/event';
import Fragment from 'core/fragment';
import Pending from 'core/pending';
export default class ModalForm {
/**
* Various events that can be observed.
*
* @type {Object}
*/
events = {
// Form was successfully submitted - the response is passed to the event listener.
// Cancellable (but it's hardly ever needed to cancel this event).
FORM_SUBMITTED: 'core_form_modalform_formsubmitted',
// Cancel button was pressed.
// Cancellable (but it's hardly ever needed to cancel this event).
FORM_CANCELLED: 'core_form_modalform_formcancelled',
// User attempted to submit the form but there was client-side validation error.
CLIENT_VALIDATION_ERROR: 'core_form_modalform_clientvalidationerror',
// User attempted to submit the form but server returned validation error.
SERVER_VALIDATION_ERROR: 'core_form_modalform_validationerror',
// Error occurred while performing request to the server.
// Cancellable (by default calls Notification.exception).
ERROR: 'core_form_modalform_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_modalform_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_modalform_submitbutton',
// Right after user pressed cancel button,
// listen to this event if you want to add confirmation dialog.
// Cancellable.
CANCEL_BUTTON_PRESSED: 'core_form_modalform_cancelbutton',
// Modal was loaded and this.modal is available (but the form content may not be loaded yet).
LOADED: 'core_form_modalform_loaded',
};
/**
* Constructor
*
* Shows the required form inside a modal dialogue
*
* @param {Object} config parameters for the form and modal dialogue:
* @property {String} config.formClass PHP class name that handles the form (should extend \core_form\modal )
* @property {Object} config.modalConfig modal config - title, type, etc.
* Default: {removeOnClose: true, type: ModalFactory.types.SAVE_CANCEL}
* @property {Object} config.args Arguments for the initial form rendering (for example, id of the edited entity)
* @property {String} config.saveButtonText the text to display on the Modal "Save" button (optional)
* @property {String} config.saveButtonClasses additional CSS classes for the Modal "Save" button
* @property {HTMLElement} config.returnFocus element to return focus to after the dialogue is closed
*/
constructor(config) {
this.modal = null;
this.config = config;
this.config.modalConfig = {
removeOnClose: true,
type: ModalFactory.types.SAVE_CANCEL,
large: true,
...(this.config.modalConfig || {}),
};
this.config.args = this.config.args || {};
this.futureListeners = [];
}
/**
* Initialise the modal and shows it
*
* @return {Promise}
*/
show() {
const pendingPromise = new Pending('core_form/modalform:init');
return ModalFactory.create(this.config.modalConfig)
.then((modal) => {
this.modal = modal;
// Retrieve the form and set the modal body. We can not set the body in the modalConfig,
// we need to make sure that the modal already exists when we render the form. Some form elements
// such as date_selector inspect the existing elements on the page to find the highest z-index.
const formParams = new URLSearchParams(Object.entries(this.config.args || {}));
this.modal.setBodyContent(this.getBody(formParams.toString()));
// After successfull submit, when we press "Cancel" or close the dialogue by clicking on X in the top right corner.
this.modal.getRoot().on(ModalEvents.hidden, () => {
this.notifyResetFormChanges()
.then(() => {
this.modal.destroy();
// Focus on the element that actually launched the modal.
if (this.config.returnFocus) {
this.config.returnFocus.focus();
}
return null;
})
.catch(() => null);
});
// Add the class to the modal dialogue.
this.modal.getModal().addClass('modal-form-dialogue');
// We catch the press on submit buttons in the forms.
this.modal.getRoot().on('click', 'form input[type=submit][data-no-submit]',
(e) => {
e.preventDefault();
const event = this.trigger(this.events.NOSUBMIT_BUTTON_PRESSED, e.target);
if (!event.defaultPrevented) {
this.processNoSubmitButton(e.target);
}
});
// We catch the form submit event and use it to submit the form with ajax.
this.modal.getRoot().on('submit', 'form', (e) => {
e.preventDefault();
const event = this.trigger(this.events.SUBMIT_BUTTON_PRESSED);
if (!event.defaultPrevented) {
this.submitFormAjax();
}
});
// Change the text for the save button.
if (typeof this.config.saveButtonText !== 'undefined' &&
typeof this.modal.setSaveButtonText !== 'undefined') {
this.modal.setSaveButtonText(this.config.saveButtonText);
}
// Set classes for the save button.
if (typeof this.config.saveButtonClasses !== 'undefined') {
this.setSaveButtonClasses(this.config.saveButtonClasses);
}
// When Save button is pressed - submit the form.
this.modal.getRoot().on(ModalEvents.save, (e) => {
e.preventDefault();
this.modal.getRoot().find('form').submit();
});
// When Cancel button is pressed - allow to intercept.
this.modal.getRoot().on(ModalEvents.cancel, (e) => {
const event = this.trigger(this.events.CANCEL_BUTTON_PRESSED);
if (event.defaultPrevented) {
e.preventDefault();
}
});
this.futureListeners.forEach(args => this.modal.getRoot()[0].addEventListener(...args));
this.futureListeners = [];
this.trigger(this.events.LOADED, null, false);
return this.modal.show();
})
.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.modal.getRoot()[0].dispatchEvent(e);
return e;
}
/**
* Add listener for an event
*
* Example:
* const modalForm = new ModalForm(...);
* dynamicForm.addEventListener(modalForm.events.FORM_SUBMITTED, e => {
* window.console.log(e.detail);
* });
*/
addEventListener(...args) {
if (!this.modal) {
this.futureListeners.push(args);
} else {
this.modal.getRoot()[0].addEventListener(...args);
}
}
/**
* Get form contents (to be used in ModalForm.setBodyContent())
*
* @param {String} formDataString form data in format of a query string
* @method getBody
* @private
* @return {Promise}
*/
getBody(formDataString) {
const params = {
formdata: formDataString,
form: this.config.formClass
};
const pendingPromise = new Pending('core_form/modalform:form_body');
return Ajax.call([{
methodname: 'core_form_dynamic_form',
args: params
}])[0]
.then(response => {
pendingPromise.resolve();
return {html: response.html, js: Fragment.processCollectedJavascript(response.javascript)};
});
}
/**
* On exception during form processing. Caller may override
*
* @param {Object} exception
*/
onSubmitError(exception) {
const event = this.trigger(this.events.ERROR, exception);
if (event.defaultPrevented) {
return;
}
Notification.exception(exception);
}
/**
* Notifies listeners that form dirty state should be reset.
*
* @return {Promise<unknown>}
*/
notifyResetFormChanges() {
return new Promise(resolve => {
Y.use('event', 'moodle-core-event', 'moodle-core-formchangechecker', () => {
Event.notifyFormSubmitAjax(this.modal.getRoot().find('form')[0], true);
M.core_formchangechecker.reset_form_dirty_state();
resolve();
});
});
}
/**
* Wrapper for Event.notifyFormSubmitAjax that waits for the module to load
*
* We often destroy the form right after calling this function and we need to make sure that it actually
* completes before it, or otherwise it will try to work with a form that does not exist.
*
* @param {Boolean} skipValidation
* @return {Promise}
*/
notifyFormSubmitAjax(skipValidation = false) {
return new Promise(resolve => {
Y.use('event', 'moodle-core-event', 'moodle-core-formchangechecker', () => {
Event.notifyFormSubmitAjax(this.modal.getRoot().find('form')[0], skipValidation);
resolve();
});
});
}
/**
* Click on a "submit" button that is marked in the form as registerNoSubmitButton()
*
* @param {Element} button button that was pressed
*/
processNoSubmitButton(button) {
this.notifyFormSubmitAjax(true)
.then(() => {
// Add the button name to the form data and submit it.
let formData = this.modal.getRoot().find('form').serialize();
formData = formData + '&' + encodeURIComponent(button.getAttribute('name')) + '=' +
encodeURIComponent(button.getAttribute('value'));
this.modal.setBodyContent(this.getBody(formData));
return null;
})
.catch(null);
}
/**
* Validate form elements
* @return {Promise} promise that returns true if client-side validation has passed, false if there are errors
*/
validateElements() {
return this.notifyFormSubmitAjax()
.then(() => {
// Now the change events have run, see if there are any "invalid" form fields.
/** @var {jQuery} list of elements with errors */
const invalid = this.modal.getRoot().find('[aria-invalid="true"], .error');
// If we found invalid fields, focus on the first one and do not submit via ajax.
if (invalid.length) {
invalid.first().focus();
return false;
}
return true;
});
}
/**
* Disable buttons during form submission
*/
disableButtons() {
this.modal.getFooter().find('[data-action]').attr('disabled', true);
}
/**
* Enable buttons after form submission (on validation error)
*/
enableButtons() {
this.modal.getFooter().find('[data-action]').removeAttr('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 formData = this.modal.getRoot().find('form').serialize();
// Now we can continue...
Ajax.call([{
methodname: 'core_form_dynamic_form',
args: {
formdata: formData,
form: this.config.formClass
}
}])[0]
.then((response) => {
if (!response.submitted) {
// Form was not submitted because validation failed.
const promise = new Promise(
resolve => resolve({html: response.html, js: Fragment.processCollectedJavascript(response.javascript)}));
this.modal.setBodyContent(promise);
this.enableButtons();
this.trigger(this.events.SERVER_VALIDATION_ERROR);
} else {
// Form was submitted properly. Hide the modal and execute callback.
const data = JSON.parse(response.data);
const event = this.trigger(this.events.FORM_SUBMITTED, data);
if (!event.defaultPrevented) {
this.modal.hide();
}
return null;
}
return null;
})
.catch(this.onSubmitError);
}
/**
* Set the classes for the 'save' button.
*
* @method setSaveButtonClasses
* @param {(String)} value The 'save' button classes.
*/
setSaveButtonClasses(value) {
const button = this.modal.getFooter().find("[data-action='save']");
if (!button) {
throw new Error("Unable to find the 'save' button");
}
button.removeClass().addClass(value);
}
}

View file

@ -0,0 +1,149 @@
<?php
// 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/>.
namespace core_form;
use context;
use moodle_url;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/formslib.php');
/**
* Class modal
*
* Extend this class to create a form that can be used in a modal dialogue.
*
* @package core_form
* @copyright 2020 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class dynamic_form extends \moodleform {
/**
* Constructor for modal forms can not be overridden, however the same form can be used both in AJAX and normally
*
* @param string $action
* @param array $customdata
* @param string $method
* @param string $target
* @param array $attributes
* @param bool $editable
* @param array $ajaxformdata Forms submitted via ajax, must pass their data here, instead of relying on _GET and _POST.
* @param bool $isajaxsubmission whether the form is called from WS and it needs to validate user access and set up context
*/
final public function __construct(
?string $action = null,
?array $customdata = null,
string $method = 'post',
string $target = '',
?array $attributes = [],
bool $editable = true,
?array $ajaxformdata = null,
bool $isajaxsubmission = false
) {
global $PAGE, $CFG;
$this->_ajaxformdata = $ajaxformdata;
if ($isajaxsubmission) {
require_once($CFG->libdir . '/externallib.php');
// This form was created from the WS that needs to validate user access to it and set page context.
// It has to be done before calling parent constructor because elements definitions may need to use
// format_string functions and other methods that expect the page to be set up.
\external_api::validate_context($this->get_context_for_dynamic_submission());
$PAGE->set_url($this->get_page_url_for_dynamic_submission());
$this->check_access_for_dynamic_submission();
}
$attributes = ['data-random-ids' => 1] + ($attributes ?: []);
parent::__construct($action, $customdata, $method, $target, $attributes, $editable, $ajaxformdata);
}
/**
* Returns context where this form is used
*
* This context is validated in {@see \external_api::validate_context()}
*
* If context depends on the form data, it is available in $this->_ajaxformdata or
* by calling $this->optional_param()
*
* Example:
* $cmid = $this->optional_param('cmid', 0, PARAM_INT);
* return context_module::instance($cmid);
*
* @return context
*/
abstract protected function get_context_for_dynamic_submission(): context;
/**
* Checks if current user has access to this form, otherwise throws exception
*
* Sometimes permission check may depend on the action and/or id of the entity.
* If necessary, form data is available in $this->_ajaxformdata or
* by calling $this->optional_param()
*
* Example:
* require_capability('dosomething', $this->get_context_for_dynamic_submission());
*/
abstract protected function check_access_for_dynamic_submission(): void;
/**
* Process the form submission, used if form was submitted via AJAX
*
* This method can return scalar values or arrays that can be json-encoded, they will be passed to the caller JS.
*
* Submission data can be accessed as: $this->get_data()
*
* Example:
* $data = $this->get_data();
* file_postupdate_standard_filemanager($data, ....);
* api::save_entity($data); // Save into the DB, trigger event, etc.
*
* @return mixed
*/
abstract public function process_dynamic_submission();
/**
* Load in existing data as form defaults
*
* Can be overridden to retrieve existing values from db by entity id and also
* to preprocess editor and filemanager elements
*
* Example:
* $id = $this->optional_param('id', 0, PARAM_INT);
* $data = api::get_entity($id); // For example, retrieve a row from the DB.
* file_prepare_standard_filemanager($data, ...);
* $this->set_data($data);
*/
abstract public function set_data_for_dynamic_submission(): void;
/**
* Returns url to set in $PAGE->set_url() when form is being rendered or submitted via AJAX
*
* This is used in the form elements sensitive to the page url, such as Atto autosave in 'editor'
*
* If the form has arguments (such as 'id' of the element being edited), the URL should
* also have respective argument.
*
* Example:
* $id = $this->optional_param('id', 0, PARAM_INT);
* return new moodle_url('/my/page/where/form/is/used.php', ['id' => $id]);
*
* @return moodle_url
*/
abstract protected function get_page_url_for_dynamic_submission(): moodle_url;
}

106
lib/form/classes/external/modal.php vendored Normal file
View file

@ -0,0 +1,106 @@
<?php
// 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/>.
namespace core_form\external;
use core_search\engine_exception;
use external_api;
use external_function_parameters;
use external_value;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/externallib.php');
/**
* Implements the external functions provided by the core_form subsystem.
*
* @copyright 2020 Marina Glancy
* @package core_form
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class dynamic_form extends external_api {
/**
* Parameters for modal form
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'form' => new external_value(PARAM_RAW_TRIMMED, 'Form class', VALUE_REQUIRED),
'formdata' => new external_value(PARAM_RAW, 'url-encoded form data', VALUE_REQUIRED),
]);
}
/**
* Submit a form from a modal dialogue.
*
* @param string $formclass
* @param string $formdatastr
* @return array
* @throws \moodle_exception
*/
public static function execute(string $formclass, string $formdatastr): array {
global $PAGE, $OUTPUT;
$params = self::validate_parameters(self::execute_parameters(), [
'form' => $formclass,
'formdata' => $formdatastr,
]);
$formclass = $params['form'];
parse_str($params['formdata'], $formdata);
if (!class_exists($formclass) || !is_subclass_of($formclass, \core_form\dynamic_form::class)) {
// For security reason we don't throw exception "class does not exist" but rather an access exception.
throw new \moodle_exception('nopermissionform', 'core_form');
}
/** @var \core_form\dynamic_form $form */
$form = new $formclass(null, null, 'post', '', [], true, $formdata, true);
$form->set_data_for_dynamic_submission();
if (!$form->is_cancelled() && $form->is_submitted() && $form->is_validated()) {
// Form was properly submitted, process and return results of processing. No need to render it again.
return ['submitted' => true, 'data' => json_encode($form->process_dynamic_submission())];
}
// Render actual form.
// Hack alert: Forcing bootstrap_renderer to initiate moodle page.
$OUTPUT->header();
$PAGE->start_collecting_javascript_requirements();
$data = $form->render();
$jsfooter = $PAGE->requires->get_end_code();
$output = ['submitted' => false, 'html' => $data, 'javascript' => $jsfooter];
return $output;
}
/**
* Return for modal
* @return \external_single_structure
*/
public static function execute_returns(): \external_single_structure {
return new \external_single_structure(
array(
'submitted' => new external_value(PARAM_BOOL, 'If form was submitted and validated'),
'data' => new external_value(PARAM_RAW, 'JSON-encoded return data from form processing method', VALUE_OPTIONAL),
'html' => new external_value(PARAM_RAW, 'HTML fragment of the form', VALUE_OPTIONAL),
'javascript' => new external_value(PARAM_RAW, 'JavaScript fragment of the form', VALUE_OPTIONAL)
)
);
}
}

View file

@ -0,0 +1,77 @@
<?php
// 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/>.
/**
* Test form repeat elements and delete button
*
* @copyright 2021 Marina Glancy
* @package core_form
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__.'/../../../../../config.php');
defined('BEHAT_SITE_RUNNING') || die();
global $CFG, $PAGE, $OUTPUT;
require_once($CFG->libdir.'/formslib.php');
$PAGE->set_url('/lib/form/tests/behat/fixtures/repeat_with_delete_form.php');
require_login();
$PAGE->set_context(context_system::instance());
/**
* Class repeat_with_delete_form
*
* @copyright 2021 Marina Glancy
* @package core_form
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class repeat_with_delete_form extends moodleform {
/**
* Form definition
*/
public function definition() {
$mform = $this->_form;
$repeatcount = $this->_customdata['repeatcount'];
$repeat = array();
$repeatopts = array();
$repeat[] = $mform->createElement('header', 'testheading', 'Heading {no}');
$repeat[] = $mform->createElement('text', 'testtext', 'Test text {no}');
$repeatopts['testtext']['default'] = 'Testing';
$repeatopts['testtext']['type'] = PARAM_TEXT;
$repeat[] = $mform->createElement('submit', 'deleteel', 'Delete option {no}', [], false);
$this->repeat_elements($repeat, $repeatcount, $repeatopts, 'test_repeat',
'test_repeat_add', 1, 'Add repeats', true, 'deleteel');
$this->add_action_buttons();
}
}
$repeatcount = optional_param('test_repeat', 1, PARAM_INT);
$form = new repeat_with_delete_form(null, array('repeatcount' => $repeatcount));
echo $OUTPUT->header();
if ($data = $form->get_data()) {
echo "<pre>".json_encode($data->testtext)."</pre>";
} else {
$form->display();
}
echo $OUTPUT->footer();

View file

@ -1,5 +1,5 @@
@core_form
Feature: Newly created repeat elements have the correct default values
Feature: Repeated elements in moodleforms
Scenario: Clicking button to add repeat elements creates repeat elements with the correct default values
Given I log in as "admin"
@ -22,3 +22,27 @@ Feature: Newly created repeat elements have the correct default values
| testselectyes[1] | Yes |
| testselectno[1] | No |
| testtext[1] | Testing 123 |
Scenario: Functionality to delete an option in the repeated elements
Given I log in as "admin"
And I am on fixture page "/lib/form/tests/behat/fixtures/repeat_with_delete_form.php"
And I set the field "Test text 1" to "value 1"
When I press "Add repeats"
Then the following fields match these values:
| Test text 1 | value 1 |
| Test text 2 | Testing |
And I set the field "Test text 2" to "value 2"
And I press "Add repeats"
And the following fields match these values:
| Test text 1 | value 1 |
| Test text 2 | value 2 |
| Test text 3 | Testing |
And I set the field "Test text 3" to "value 3"
And I press "Delete option 2"
And the following fields match these values:
| Test text 1 | value 1 |
| Test text 3 | value 3 |
And I should not see "Test text 2"
And I should not see "Delete option 2"
And I press "Save changes"
And I should see "{\"0\":\"value 1\",\"2\":\"value 3\"}"

View file

@ -519,20 +519,58 @@ abstract class moodleform {
return $nosubmit;
}
/**
* Returns an element of multi-dimensional array given the list of keys
*
* Example:
* $array['a']['b']['c'] = 13;
* $v = $this->get_array_value_by_keys($array, ['a', 'b', 'c']);
*
* Will result it $v==13
*
* @param array $array
* @param array $keys
* @return mixed returns null if keys not present
*/
protected function get_array_value_by_keys(array $array, array $keys) {
$value = $array;
foreach ($keys as $key) {
if (array_key_exists($key, $value)) {
$value = $value[$key];
} else {
return null;
}
}
return $value;
}
/**
* Checks if a parameter was passed in the previous form submission
*
* @param string $name the name of the page parameter we want
* @param string $name the name of the page parameter we want, for example 'id' or 'element[sub][13]'
* @param mixed $default the default value to return if nothing is found
* @param string $type expected type of parameter
* @return mixed
*/
public function optional_param($name, $default, $type) {
if (isset($this->_ajaxformdata[$name])) {
return clean_param($this->_ajaxformdata[$name], $type);
} else {
return optional_param($name, $default, $type);
$nameparsed = [];
// Convert element name into a sequence of keys, for example 'element[sub][13]' -> ['element', 'sub', '13'].
parse_str($name . '=1', $nameparsed);
$keys = [];
while (is_array($nameparsed)) {
$key = key($nameparsed);
$keys[] = $key;
$nameparsed = $nameparsed[$key];
}
// Search for the element first in $this->_ajaxformdata, then in $_POST and then in $_GET.
if (($value = $this->get_array_value_by_keys($this->_ajaxformdata ?? [], $keys)) !== null ||
($value = $this->get_array_value_by_keys($_POST, $keys)) !== null ||
($value = $this->get_array_value_by_keys($_GET, $keys)) !== null) {
return $type == PARAM_RAW ? $value : clean_param($value, $type);
}
return $default;
}
/**
@ -1099,11 +1137,14 @@ abstract class moodleform {
* @param int $addfieldsno how many fields to add at a time
* @param string $addstring name of button, {no} is replaced by no of blanks that will be added.
* @param bool $addbuttoninside if true, don't call closeHeaderBefore($addfieldsname). Default false.
* @param string $deletebuttonname if specified, treats the no-submit button with this name as a "delete element" button
* in each of the elements
* @return int no of repeats of element in this page
*/
function repeat_elements($elementobjs, $repeats, $options, $repeathiddenname,
$addfieldsname, $addfieldsno=5, $addstring=null, $addbuttoninside=false){
if ($addstring===null){
public function repeat_elements($elementobjs, $repeats, $options, $repeathiddenname,
$addfieldsname, $addfieldsno = 5, $addstring = null, $addbuttoninside = false,
$deletebuttonname = '') {
if ($addstring === null) {
$addstring = get_string('addfields', 'form', $addfieldsno);
} else {
$addstring = str_ireplace('{no}', $addfieldsno, $addstring);
@ -1121,7 +1162,18 @@ abstract class moodleform {
//value not to be overridden by submitted value
$mform->setConstants(array($repeathiddenname=>$repeats));
$namecloned = array();
$no = 1;
for ($i = 0; $i < $repeats; $i++) {
if ($deletebuttonname) {
$mform->registerNoSubmitButton($deletebuttonname . "[$i]");
$isdeleted = $this->optional_param($deletebuttonname . "[$i]", false, PARAM_RAW) ||
$this->optional_param($deletebuttonname . "-hidden[$i]", false, PARAM_RAW);
if ($isdeleted) {
$mform->addElement('hidden', $deletebuttonname . "-hidden[$i]", 1);
$mform->setType($deletebuttonname . "-hidden[$i]", PARAM_INT);
continue;
}
}
foreach ($elementobjs as $elementobj){
$elementclone = fullclone($elementobj);
$this->repeat_elements_fix_clone($i, $elementclone, $namecloned);
@ -1130,7 +1182,13 @@ abstract class moodleform {
foreach ($elementclone->getElements() as $el) {
$this->repeat_elements_fix_clone($i, $el, $namecloned);
}
$elementclone->setLabel(str_replace('{no}', $i + 1, $elementclone->getLabel()));
$elementclone->setLabel(str_replace('{no}', $no, $elementclone->getLabel()));
} else if ($elementobj instanceof \HTML_QuickForm_submit && $elementobj->getName() == $deletebuttonname) {
// Mark the "Delete" button as no-submit.
$onclick = $elementclone->getAttribute('onclick');
$skip = 'skipClientValidation = true;';
$onclick = ($onclick !== null) ? $skip . ' ' . $onclick : $skip;
$elementclone->updateAttributes(['data-skip-validation' => 1, 'data-no-submit' => 1, 'onclick' => $onclick]);
}
// Mark newly created elements, so they know not to look for any submitted data.
@ -1139,6 +1197,7 @@ abstract class moodleform {
}
$mform->addElement($elementclone);
$no++;
}
}
for ($i=0; $i<$repeats; $i++) {
@ -1161,24 +1220,22 @@ abstract class moodleform {
call_user_func_array(array(&$mform, 'addHelpButton'), $params);
break;
case 'disabledif' :
foreach ($namecloned as $num => $name){
if ($params[0] == $name){
$params[0] = $params[0]."[$i]";
break;
}
}
$params = array_merge(array($realelementname), $params);
call_user_func_array(array(&$mform, 'disabledIf'), $params);
break;
case 'hideif' :
$pos = strpos($params[0], '[');
$ending = '';
if ($pos !== false) {
$ending = substr($params[0], $pos);
$params[0] = substr($params[0], 0, $pos);
}
foreach ($namecloned as $num => $name){
if ($params[0] == $name){
$params[0] = $params[0]."[$i]";
$params[0] = $params[0] . "[$i]" . $ending;
break;
}
}
$params = array_merge(array($realelementname), $params);
call_user_func_array(array(&$mform, 'hideIf'), $params);
$function = ($option === 'disabledif') ? 'disabledIf' : 'hideIf';
call_user_func_array(array(&$mform, $function), $params);
break;
case 'rule' :
if (is_string($params)){
@ -1203,7 +1260,7 @@ abstract class moodleform {
}
}
}
$mform->addElement('submit', $addfieldsname, $addstring);
$mform->addElement('submit', $addfieldsname, $addstring, [], false);
if (!$addbuttoninside) {
$mform->closeHeaderBefore($addfieldsname);
@ -1432,6 +1489,40 @@ abstract class moodleform {
}
}
/**
* Used by tests to simulate submitted form data submission via AJAX.
*
* For form fields where no data is submitted the default for that field as set by set_data or setDefault will be passed to
* get_data.
*
* This method sets $_POST or $_GET and $_FILES with the data supplied. Our unit test code empties all these
* global arrays after each test.
*
* @param array $simulatedsubmitteddata An associative array of form values (same format as $_POST).
* @param array $simulatedsubmittedfiles An associative array of files uploaded (same format as $_FILES). Can be omitted.
* @param string $method 'post' or 'get', defaults to 'post'.
* @param null $formidentifier the default is to use the class name for this class but you may need to provide
* a different value here for some forms that are used more than once on the
* same page.
* @return array array to pass to form constructor as $ajaxdata
*/
public static function mock_ajax_submit($simulatedsubmitteddata, $simulatedsubmittedfiles = array(), $method = 'post',
$formidentifier = null) {
$_FILES = $simulatedsubmittedfiles;
if ($formidentifier === null) {
$formidentifier = get_called_class();
$formidentifier = str_replace('\\', '_', $formidentifier); // See MDL-56233 for more information.
}
$simulatedsubmitteddata['_qf__'.$formidentifier] = 1;
$simulatedsubmitteddata['sesskey'] = sesskey();
if (strtolower($method) === 'get') {
$_GET = ['sesskey' => sesskey()];
} else {
$_POST = ['sesskey' => sesskey()];
}
return $simulatedsubmitteddata;
}
/**
* Used by tests to generate valid submit keys for moodle forms that are
* submitted with ajax data.
@ -3052,7 +3143,6 @@ class MoodleQuickForm_Renderer extends HTML_QuickForm_Renderer_Tableless{
if (count($this->_collapsibleElements) > 1) {
$this->_collapseButtons = $this->_collapseButtonsTemplate;
$this->_collapseButtons = str_replace('{strexpandall}', get_string('expandall'), $this->_collapseButtons);
$PAGE->requires->strings_for_js(array('collapseall', 'expandall'), 'moodle');
}
$PAGE->requires->yui_module('moodle-form-shortforms', 'M.form.shortforms', array(array('formid' => $formid)));
}

View file

@ -1684,6 +1684,9 @@ class page_requirements_manager {
'error',
'file',
'url',
// TODO MDL-70830 shortforms should preload the collapseall/expandall strings properly.
'collapseall',
'expandall',
), 'moodle');
$this->strings_for_js(array(
'debuginfo',

View file

@ -30,6 +30,8 @@ information provided here is intended especially for developers.
* Behat now supports date and time selection from the datetime form element. Examples:
- I set the field "<field_string>" to "##15 March 2021 08:15##"
- I set the field "<field_string>" to "##first day of January last year noon##"
* Added new class, AMD modules and WS that allow displaying forms in modal popups or load and submit in AJAX requests.
See https://docs.moodle.org/dev/Modal_and_AJAX_forms for more details.
=== 3.10 ===
* PHPUnit has been upgraded to 8.5. That comes with a few changes: