Note:

If you want to create a new page for developers, you should create it on the Moodle Developer Resource site.

MForm Modal: Difference between revisions

From MoodleDocs
Line 3: Line 3:


{{Note| This tutorial is for advanced Moodle devs. You need to know a lot of APIs in order to understand all of this. See the list of related pages at the end of this tutorial for some of them. }}
{{Note| This tutorial is for advanced Moodle devs. You need to know a lot of APIs in order to understand all of this. See the list of related pages at the end of this tutorial for some of them. }}
{{Note| In Moodle 3.11 and above you can use specialised API for [[Modal and AJAX forms]]}}


This is a walkthrough of the steps required to convert an existing page in moodle with an mform - to an mform that displays in a popup and submits data via AJAX.
This is a walkthrough of the steps required to convert an existing page in moodle with an mform - to an mform that displays in a popup and submits data via AJAX.

Revision as of 21:45, 18 February 2021

Advanced tutorial: MForm in a modal

Note: This tutorial is for advanced Moodle devs. You need to know a lot of APIs in order to understand all of this. See the list of related pages at the end of this tutorial for some of them.


Note: In Moodle 3.11 and above you can use specialised API for Modal and AJAX forms


This is a walkthrough of the steps required to convert an existing page in moodle with an mform - to an mform that displays in a popup and submits data via AJAX.

If you want to get all the code in this walkthrough - here is the link to the branch on github: https://github.com/damyon/moodle/tree/MFORM_AJAX

Lets get started - first lets pick a page (spins a wheel)..... "Create a group".

We will modify the "Create group" button on the groups page to open a modal with the create a group form, instead of directing you to a new page. Whether this is actually a useful improvement or not does not really matter for now - we are just trying to show how to do it.

Step 1 - Attach some javascript to the button

The first step is to add some javascript to the "Create group" button so that we can open a modal. The page we are modifying is group/index.php which we get by visiting the page and checking the url. If we read through that page, we can see it uses a renderable to generate the HTML for the page.

$renderable = new \core_group\output\index_page($courseid, $groupoptions, $selectedname, $members, $disableaddedit, $disabledelete,

             $preventgroupremoval);                                                                                                      

$output = $PAGE->get_renderer('core_group'); echo $output->render($renderable);

The renderer is in group/classes/output/renderer.php and the renderable is in group/classes/output/index_page.php (see Automatic_class_loading) for how we worked that out.

Looking at the render_index_page function of the renderer (see https://docs.moodle.org/dev/Output_renderers) we can see that this renderer uses Templates to separate the HTML from the php (good).

public function render_index_page(index_page $page) {

   $data = $page->export_for_template($this);                                                                                  
   return parent::render_from_template('core_group/index', $data);                                                             

}

The name of the template is "core_group/index". This maps to a mustache template located at group/templates/index.mustache.

Hooray - we found the HTML we want to modify to add our javascript.

This template already has a {{#js}} block we can use to add our custom javascript. We don't want to code all the javascript in a mustache template - we will just add the minimal code to call a new AMD module for creating a group.

{{#js}}

   require(['jquery', 'core/yui'], function($) {                                                                                   
       $("#groups").change(function() {                                                                                            
           M.core_group.membersCombo.refreshMembers();                                                                             
       });                                                                                                                         
       M.core_group.init_index(Y, "Template:wwwroot", Template:courseid);                                                                    
       var undeletableGroups = JSON.parse('{{{undeletablegroups}}}');                                                              
       M.core_group.groupslist(Y, undeletableGroups);                                                                              
   });                                                                                                                             
   // New code to create groups in a modal.                                                                                        
   require(['core_group/newgroup'], function(NewGroup) {                                                                           
       NewGroup.init('[data-action=creategroupmodal]', Template:contextid);                                                                            
   });

Template:/js

We added contextid to the template (We always need a contextid to use the fragments API) but it's not in the context for the template yet. We need to add it to the export_for_template function for the renderable group/classes/output/index_page.php

$context = context_course::instance($this->courseid);
$data->contextid = $context->id;

We added that code at the end to call the core_group/newgroup AMD module. The argument we will pass to this new module is a "selector" which is a flexible way to allow this same module to be used on other pages with different markup. In this case for the selector, we are looking for a node with a data attribute. We also need to modify the create group button to contain this data-attribute. In general data-attributes are a great way to add hooks to the DOM that are used for Javascript, but should not affect the appearance (unlike classes which are just gross and disgusting).

    <input type="submit" name="act_showcreateorphangroupform" id="showcreateorphangroupform" data-action="creategroupmodal" value="{{#str}}creategroup, group{{/str}}" class="btn btn-default" />

Step 2 - Add a new AMD module to show the modal

So we tried to load an AMD module "core_group/newgroup" but it doesn't exist yet. Time to create it.

Make a new folder at group/amd/src. "group" in this case is the directory for the core_group component, so amd modules for this component go in amd/src relative to that directory. When we run Grunt it will build the javascript to group/amd/build.

Make a new file in this group/amd/src folder named "newgroup.js". This will be our new javascript module. It exports a single function "init" which attaches event listeners to the elements that match the selector.

/**

* Add a create new group modal to the page.
*
* @module     core_group/newgroup
* @class      NewGroup
* @package    core_group
* @copyright  2017 Damyon Wiese <damyon@moodle.com>
* @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/fragment', 'core/ajax', 'core/yui'],

       function($, Str, ModalFactory, ModalEvents, Fragment, Ajax, Y) {
   /**
    * Constructor
    *
    * @param {String} selector used to find triggers for the new group modal.
    * @param {int} contextid
    *
    * Each call to init gets it's own instance of this class.
    */
   var NewGroup = function(selector, contextid) {
       this.contextid = contextid;
       this.init(selector);
   };
   /**
    * @var {Modal} modal
    * @private
    */
   NewGroup.prototype.modal = null;
   /**
    * @var {int} contextid
    * @private
    */
   NewGroup.prototype.contextid = -1;
   /**
    * Initialise the class.
    *
    * @param {String} selector used to find triggers for the new group modal.
    * @private
    * @return {Promise}
    */
   NewGroup.prototype.init = function(selector) {
       var triggers = $(selector);
       // Fetch the title string.
       return Str.get_string('creategroup', 'core_group').then(function(title) {
           // Create the modal.
           return ModalFactory.create({
               type: ModalFactory.types.SAVE_CANCEL,
               title: title,
               body: this.getBody()
           }, triggers);
       }.bind(this)).then(function(modal) {
           // Keep a reference to the modal.
           this.modal = modal;
           // Forms are big, we want a big modal.
           this.modal.setLarge();
           // We want to reset the form every time it is opened.
           this.modal.getRoot().on(ModalEvents.hidden, function() {
               this.modal.setBody(this.getBody());
           }.bind(this));
           // We want to hide the submit buttons every time it is opened.
           this.modal.getRoot().on(ModalEvents.shown, function() {
               this.modal.getRoot().append('<style>[data-fieldtype=submit] { display: none ! important; }</style>');
           }.bind(this));


           // We catch the modal save event, and use it to submit the form inside the modal.
           // Triggering a form submission will give JS validation scripts a chance to check for errors.
           this.modal.getRoot().on(ModalEvents.save, this.submitForm.bind(this));
           // We also catch the form submit event and use it to submit the form with ajax.
           this.modal.getRoot().on('submit', 'form', this.submitFormAjax.bind(this));
           return this.modal;
       }.bind(this));
   };
   /**
    * @method getBody
    * @private
    * @return {Promise}
    */
   NewGroup.prototype.getBody = function(formdata) {
       if (typeof formdata === "undefined") {
           formdata = {};
       }
       // Get the content of the modal.
       var params = {jsonformdata: JSON.stringify(formdata)};
       return Fragment.loadFragment('core_group', 'new_group_form', this.contextid, params);
   };
   /**
    * @method handleFormSubmissionResponse
    * @private
    * @return {Promise}
    */
   NewGroup.prototype.handleFormSubmissionResponse = function() {
       this.modal.hide();
       // We could trigger an event instead.
       // Yuk.
       Y.use('moodle-core-formchangechecker', function() {
           M.core_formchangechecker.reset_form_dirty_state();
       });
       document.location.reload();
   };
   /**
    * @method handleFormSubmissionFailure
    * @private
    * @return {Promise}
    */
   NewGroup.prototype.handleFormSubmissionFailure = function(data) {
       // Oh noes! Epic fail :(
       // Ah wait - this is normal. We need to re-display the form with errors!
       this.modal.setBody(this.getBody(data));
   };
   /**
    * Private method
    *
    * @method submitFormAjax
    * @private
    * @param {Event} e Form submission event.
    */
   NewGroup.prototype.submitFormAjax = function(e) {
       // We don't want to do a real form submission.
       e.preventDefault();
       var changeEvent = document.createEvent('HTMLEvents');
      changeEvent.initEvent('change', true, true);
      // Prompt all inputs to run their validation functions.
      // Normally this would happen when the form is submitted, but
      // since we aren't submitting the form normally we need to run client side
      // validation.
      this.modal.getRoot().find(':input').each(function(index, element) {
          element.dispatchEvent(changeEvent);
      });
      // Now the change events have run, see if there are any "invalid" form fields.
      var invalid = $.merge(
          this.modal.getRoot().find('[aria-invalid="true"]'),
          this.modal.getRoot().find('.error')
      );
      // If we found invalid fields, focus on the first one and do not submit via ajax.
      if (invalid.length) {
          invalid.first().focus();
          return;
      }
       // Convert all the form elements values to a serialised string.
       var formData = this.modal.getRoot().find('form').serialize();
       // Now we can continue...
       Ajax.call([{
           methodname: 'core_group_submit_create_group_form',
           args: {contextid: this.contextid, jsonformdata: JSON.stringify(formData)},
           done: this.handleFormSubmissionResponse.bind(this, formData),
           fail: this.handleFormSubmissionFailure.bind(this, formData)
       }]);
   };
   /**
    * This triggers a form submission, so that any mform elements can do final tricks before the form submission is processed.
    *
    * @method submitForm
    * @param {Event} e Form submission event.
    * @private
    */
   NewGroup.prototype.submitForm = function(e) {
       e.preventDefault();
       this.modal.getRoot().find('form').submit();
   };
   return /** @alias module:core_group/newgroup */ {
       // Public variables and functions.
       /**
        * Attach event listeners to initialise this module.
        *
        * @method init
        * @param {string} selector The CSS selector used to find nodes that will trigger this module.
        * @param {int} contextid The contextid for the course.
        * @return {Promise}
        */
       init: function(selector, contextid) {
           return new NewGroup(selector, contextid);
       }
   };

});

Some things to note: This is the standard boilerplate for an AMD module that only returns a list of functions (in this case one function named "init"). Everything else is private to the module. There is a bunch of code here but the main parts are:

a) opening the modal b) using the fragment api to load the body of the modal c) catching the submit from the form so we can do something else with it

Step 3 - Add a callback for the fragments API to load the form

In the JS above we load the fragment for new_group_form from the core_group component. We need to add a callback to the core_group component that renders this fragment. This is a new global function in group/lib.php

/**

* Serve the new group form as a fragment.
*
* @param array $args List of named arguments for the fragment loader.
* @return string
*/

function core_group_output_fragment_new_group_form($args) {

   global $CFG;
   require_once($CFG->dirroot . '/group/group_form.php');
   $args = (object) $args;
   $context = $args->context;
   $o = ;
   $formdata = [];
   if (!empty($args->jsonformdata)) {
       $serialiseddata = json_decode($args->jsonformdata);
       parse_str($serialiseddata, $formdata);
   }
   list($ignored, $course) = get_context_info_array($context->id);
   $group = new stdClass();
   $group->courseid = $course->id;
   require_capability('moodle/course:managegroups', $context);
   $editoroptions = [
       'maxfiles' => EDITOR_UNLIMITED_FILES,
       'maxbytes' => $course->maxbytes,
       'trust' => false,
       'context' => $context,
       'noclean' => true,
       'subdirs' => false
   ];
   $group = file_prepare_standard_editor($group, 'description', $editoroptions, $context, 'group', 'description', null);
   $mform = new group_form(null, array('editoroptions' => $editoroptions), 'post', , null, true, $formdata);
   // Used to set the courseid.
   $mform->set_data($group);
   if (!empty($args->jsonformdata)) {
       // If we were passed non-empty form data we want the mform to call validation functions and show errors.
       $mform->is_validated();
   }
   ob_start();
   $mform->display();
   $o .= ob_get_contents();
   ob_end_clean();
   return $o;

}

Most of this code is the same as in group/group.php to build the mform. Note we accept a parameter which is the json encoded form data so we can pass it to the mform constructor. This is normally done by mforms parsing data from GET and POST params, but when we are in an ajax script we need to explicitly set the data like this. We also need to call is_validated() so the mform will render with error messages if this is required.

Step 4 - Add a webservice function to handle the form submission

We need to add a new webservice function to handle our ajax form submission. This is webservice is different to a normal one in that the list of parameters are accepted in one json encoded blob, rather than separated and validated properly. This is because we want the mform to perform the validation for us.

group/externallib.php

/**

    * Describes the parameters for submit_create_group_form webservice.
    * @return external_function_parameters
    */
   public static function submit_create_group_form_parameters() {
       return new external_function_parameters(
           array(
               'contextid' => new external_value(PARAM_INT, 'The context id for the course'),
               'jsonformdata' => new external_value(PARAM_RAW, 'The data from the create group form, encoded as a json array')
           )
       );
   }
   /**
    * Submit the create group form.
    *
    * @param int $contextid The context id for the course.
    * @param string $jsonformdata The data from the form, encoded as a json array.
    * @return int new group id.
    */
   public static function submit_create_group_form($contextid, $jsonformdata) {
       global $CFG, $USER;
       require_once($CFG->dirroot . '/group/lib.php');
       require_once($CFG->dirroot . '/group/group_form.php');
       // We always must pass webservice params through validate_parameters.
       $params = self::validate_parameters(self::submit_create_group_form_parameters(),
                                           ['contextid' => $contextid, 'jsonformdata' => $jsonformdata]);
       $context = context::instance_by_id($params['contextid'], MUST_EXIST);
       // We always must call validate_context in a webservice.
       self::validate_context($context);
       require_capability('moodle/course:managegroups', $context);
       list($ignored, $course) = get_context_info_array($context->id);
       $serialiseddata = json_decode($params['jsonformdata']);
       $data = array();
       parse_str($serialiseddata, $data);
       $warnings = array();
       $editoroptions = [
           'maxfiles' => EDITOR_UNLIMITED_FILES,
           'maxbytes' => $course->maxbytes,
           'trust' => false,
           'context' => $context,
           'noclean' => true,
           'subdirs' => false
       ];
       $group = new stdClass();
       $group->courseid = $course->id;
       $group = file_prepare_standard_editor($group, 'description', $editoroptions, $context, 'group', 'description', null);
       // The last param is the ajax submitted data.
       $mform = new group_form(null, array('editoroptions' => $editoroptions), 'post', , null, true, $data);
       $validateddata = $mform->get_data();
       if ($validateddata) {
           // Do the action.
           $groupid = groups_create_group($validateddata, $mform, $editoroptions);
       } else {
           // Generate a warning.
           throw new moodle_exception('erroreditgroup', 'group');
       }
       return $groupid;
   }
   /**
    * Returns description of method result value.
    *
    * @return external_description
    * @since Moodle 3.0
    */
   public static function submit_create_group_form_returns() {
       return new external_value(PARAM_INT, 'group id');
   }

We also need to add the webservice to lib/db/services.php and bump the moodle version number.

lib/db/services.php

 'core_group_submit_create_group_form' => array(
       'classname' => 'core_group_external',
       'methodname' => 'submit_create_group_form',
       'classpath' => 'group/externallib.php',
       'description' => 'Creates a group from submitted form data',
       'ajax' => true,
       'type' => 'write',
       'capabilities' => 'moodle/course:managegroups',
   ),

Summary

This shows a working example of displaying an mform in a modal with server side validation. It was a bit messy and fiddly with quite a few things that can catch you out - but there are benefits too - we re-used an existing mform with all it's validation logic and mform elements including a text editor, file manager and password field. This will work for auto-complete fields and other advanced fields too.

Related APIs