mirror of
https://github.com/moodle/moodle.git
synced 2025-08-08 02:16:41 +02:00
Merge branch 'MDL-64554-master' of git://github.com/andrewnicols/moodle
This commit is contained in:
commit
d6e50d14d2
60 changed files with 1920 additions and 461 deletions
2
lib/amd/build/fragment.min.js
vendored
2
lib/amd/build/fragment.min.js
vendored
|
@ -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
2
lib/amd/build/modal.min.js
vendored
2
lib/amd/build/modal.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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%] |
|
||||
|
|
|
@ -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
2
lib/form/amd/build/dynamicform.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
lib/form/amd/build/dynamicform.min.js.map
Normal file
1
lib/form/amd/build/dynamicform.min.js.map
Normal file
File diff suppressed because one or more lines are too long
2
lib/form/amd/build/modalform.min.js
vendored
Normal file
2
lib/form/amd/build/modalform.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
lib/form/amd/build/modalform.min.js.map
Normal file
1
lib/form/amd/build/modalform.min.js.map
Normal file
File diff suppressed because one or more lines are too long
389
lib/form/amd/src/dynamicform.js
Normal file
389
lib/form/amd/src/dynamicform.js
Normal 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);
|
||||
}
|
||||
}
|
408
lib/form/amd/src/modalform.js
Normal file
408
lib/form/amd/src/modalform.js
Normal 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);
|
||||
}
|
||||
}
|
149
lib/form/classes/dynamic_form.php
Normal file
149
lib/form/classes/dynamic_form.php
Normal 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
106
lib/form/classes/external/modal.php
vendored
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
77
lib/form/tests/behat/fixtures/repeat_with_delete_form.php
Normal file
77
lib/form/tests/behat/fixtures/repeat_with_delete_form.php
Normal 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();
|
|
@ -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\"}"
|
||||
|
|
134
lib/formslib.php
134
lib/formslib.php
|
@ -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)));
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue