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

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\"}"