Browsed by
Tag: Moodle

Moodle 4 : Course Header Image cont.

Moodle 4 : Course Header Image cont.

This is carrying on from my earlier post Moodle 4 : Adding Course Image to Header . But using the Course Image rather than a separate Header image is not without its problems as we could end up loading numerous larger images than we needed in the course listing on the homepage and dashboard.

To combat this I want to add a separate field to the course settings so that we can choose a different image for the course header and have a smaller image for the course listing.

Researching how to do this turned up a couple of pieces of interesting information:

I’m not quite sure if this is the best way forward so far but exploring it for now!

Creating a Course Custom Field plugin

First I created a new plugin in customfield/field called image following the documentation here : moodledev.io/docs/apis/plugintypes/customfield

<?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/>.

/**
 * Customfield checkbox plugin
 * @package   customfield_image
 * @copyright 2023 Author Name <email@example.ac.uk>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

defined('MOODLE_INTERNAL') || die();

$plugin->component = 'customfield_image';
$plugin->version   = 2023022300;
$plugin->requires  = 2022112800;
<?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/>.

/**
 * Customfield checkbox plugin
 * @package   customfield_image
 * @copyright 2023 Author Name <email@example.com>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

defined('MOODLE_INTERNAL') || die();

$string['pluginname'] = 'Image Upload';
$string['privacy:metadata'] = 'The image update field type plugin doesn\'t store any personal data; it uses tables defined in core.';
$string['specificsettings'] = 'Image Upload field settings';

and added /classes/data_controller.php and /classes/field_controller.php (more on these next)

Field Controller (classes/field_controller.php)

The field controller (/classes/field_controller.php)

  • Adds in field settings using the Form API ( moodledev.io/docs/apis/subsystems/form )
  • Allows our new field to appear in the option list in Site Administration -> Courses -> Course Custom Fields
  • Allows us to customise the settings form to add additional options our custom field needs
  • Validates the input of the settings (existing and new).

Create the file in classes/field_controller.php

Setup the file in the normal way. Note you should change the namespace to match your plugin’s component name.

<?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/>.

/**
 * Customfields image upload plugin
 *
 * @package   customfield_image
 * @copyright 2023 Author Name <email@example.ac.uk>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace customfield_image;

defined('MOODLE_INTERNAL') || die;

Create the class. At a minimum, the following two items are required:

  • the TYPE constant to match the name of the plugin; and
  • the config_form_definition() function.
/**
 * Class field
 *
 * @package customfield_image
 * @copyright 2023 Author Name <email@example.ac.uk>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class field_controller  extends \core_customfield\field_controller {
	/**
	 * Plugin type
	 */
	const TYPE = 'image';

        // More code will go here....

}

Next we want to add the config_form_definition function which sets up additional form fields for any settings we might want the custom field to have. These will appear when we choose “Image Upload” from the Course Custom Field screen.

Since we want to have an image upload a good setting to add is how many images we want the field to allow.

	/**
	 * Add fields for editing a checkbox field. Should include any settings we want for this field
	 *
	 * @param \MoodleQuickForm $mform
	 */
	public function config_form_definition(\MoodleQuickForm $mform) {
		$mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_image'));
		$mform->setExpanded('header_specificsettings', true);
		
		$mform->addElement('float', 'configdata[filelimit]', get_string('filelimit', 'customfield_image'), array('size' => 30));
		$mform->setType('configdata[filelimit]', PARAM_INT);
	}

The plugin is using the Form API to create the form fields : moodledev.io/docs/apis/subsystems/form

Unfortunately the Form API doesn’t yet include a number field – but it does include float which will do for now.

Notes : change the field labels & names to match your new setting and add the relevant language strings to /lang/en/customfield_image.php

Next we want to validate the form fields

	/**
	 * Validate the data on the field configuration form
	 *
	 * @param array $data from the add/edit profile field form
	 * @param array $files
	 * @return array associative array of error messages
	 */
	public function config_form_validation(array $data, $files = array()) : array {
		$errors = parent::config_form_validation($data, $files);
		
		if (!is_numeric($data['configdata']['filelimit']) || $data['configdata']['filelimit'] < 1) {
			$errors['configdata[filelimit]'] = get_string('errorconfiglimit', 'customfield_image');
		}
		
		return $errors;
	}

It’s important we check that the value added in a numeric one and is 1 or more. If not an error we define in /lang/en/customfield_image.php will appear.

$string['pluginname'] = 'Image Upload';
$string['errorconfiglimit'] = 'The file limit field must be a number and at least 1';
$string['filelimit'] = 'File Limit';
$string['privacy:metadata'] = 'The image update field type plugin doesn\'t store any personal data; it uses tables defined in core.';
$string['specificsettings'] = 'Image Upload field settings';

Data Controller (/classes/data_controller.php)

This class handles adding in the custom form fields into the course settings. This is the bit the lecturers will be filling out when creating their courses.

Create the file in the normal way and change the details and component name to match your plugin.

<?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/>.

/**
 * Customfield Upload Image plugin
 *
 * @package   customfield_image
 * @copyright 2023 Author Name <email@example.ac.uk>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace customfield_image;

use core_customfield\api;
use core_customfield\output\field_data;
use html_writer;
use moodle_url;
use MoodleQuickForm;
use stdClass;

defined('MOODLE_INTERNAL') || die;

I’m using the use keyword here as well to import some Moodle classes to use later in the code. Makes it a lot more straight forward to access them.

Create the class. At a minimum, the following two items are required:

  • the datafield(): string function; and
  • the instance_form_definition() function.
/**
 * Class data
 *
 * @package customfield_image
 * @copyright 2023 Author Name <email@example.ac.uk>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class data_controller extends \core_customfield\data_controller {

    // more code will go here...
	
}

Then add in the datafield. Note this describes which database field the data for the custom field is stored in. Options are shown here : moodledev.io/docs/apis/plugintypes/customfield#data-controller

The filemanager field will use an itemid string of numbers to represent the file within moodle so we are using intvalue here.

	 /**
	 * Return the name of the field where the information is stored
	 * @return string
	 */
	public function datafield() : string {
		return 'intvalue';
	}

Next add the instance_form_definition() function. This defines the fields that will appear on the course settings page.

This function uses the Form Api to create the form fields :

	 /**
	 * Add fields for editing a checkbox field.
	 *
	 * @param \MoodleQuickForm $mform
	 */
	public function instance_form_definition(\MoodleQuickForm $mform) {
		
		$field = $this->get_field();
		$config = $field->get('configdata');
		$elementname = $this->get_form_element_name();

		// If file upload is required 
		$isrequired = $field->get_configdata_property('required');
		
		$mform->addElement(
			'filemanager',
			$elementname,
			$this->get_field()->get_formatted_name(),
			null,
			$this->customfield_image_options($field)
		);
		
	}
	
	public function customfield_image_options($field) {
		global $CFG;
		
		$maxfiles = $field->get_configdata_property('filelimit');
		
		if(empty($maxfiles) && !empty($CFG->courseoverviewfileslimit)) {
			$maxfiles = $CFG->courseoverviewfileslimit;
		} else if(empty($maxfiles)) {
			$maxfiles = 1;
		}
		
		$options = array(
			'maxfiles' => $maxfiles,
			'maxbytes' => $CFG->maxbytes,
			'subdirs' => 0,
			'accepted_types' => array('png','jpg','jpeg','jpe','gif','svg','svgz')
		);
		
		return $options;
	}

Using the File Manager field from the Form API we add Moodle’s Image Uploader to the form:

Ref: moodledev.io/docs/apis/subsystems/form/usage/files#file-manager

I created a separate function ( customfield_image_options() ) here to set the file manager field options. This function uses the setting we created earlier in field_controller.php to set the file limit.

Accepted File Types are defined in /moodle/lib/classes/filetypes.php – I’ve limited these to some image types.

The above code however is not enough – you will probably find it will now probably throw an error on the homepage and course page. e.g.

Fatal error: Class customfield_image\data_controller contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (core_customfield\data_controller::get_default_value) in …/moodle/customfield/field/image/classes/data_controller.php on line 113

To fix this we just need to add a couple more functions (i.e. the remaining methods the error message mentions)…

	 /**
	 * Returns the default value as it would be stored in the database (not in human-readable format).
	 *
	 * @return mixed
	 */
	public function get_default_value() {
		return $this->get_field()->get_configdata_property('defaultvalue');
	}
	
	/**
	 * Returns value in a human-readable format
	 *
	 * @return mixed|null value or null if empty
	 */
	public function export_value() {
		$value = parent::export_value();
		if ($value === null) {
			return null;
		}
		
		return $value;
	}

Note that this is only saving the file as a user’s draft file which is a problem. We will need to adjust the code so that it saves the file correctly using Moodle’s File API Documentation as a reference.

First we need to make sure that the draft file is loaded into the settings form. If you take a look at the customfield data_controller class there is a class called “instance_form_before_set_data” it is this one we want to copy into our own data_controller class and extend to manage the draft file.

	public function instance_form_before_set_data(stdClass $instance) {

		$draftid = file_get_submitted_draft_itemid($this->get_form_element_name());
		$field = $this->get_field();
		$fieldname = $this->get_form_element_name();
		
		file_prepare_draft_area($draftid, $this->get_context()->id, 'customfield_image', $fieldname, $this->get('id'),$this->customfield_image_options($field));
		
		$instance->{$this->get_form_element_name()} = $draftid;

	}

We’re getting an unused draft item ID to use in this form using file_get_submitted_draft_itemid (see Moodle File Manager Documentation).

Then by using file_prepare_draft_area we are copying the previously uploaded draft file into the draft area of the form. Otherwise we won’t see the existing file in there.

Next we need to add another function taken from the customfield data_controller class to process and save the data coming from the form.

	public function instance_form_save(stdClass $data) {

		$fieldname = $this->get_form_element_name();
		$field = $this->get_field();

		// Trigger save.
		parent::instance_form_save((object) [$fieldname => $data->{$fieldname}]);
		
		file_save_draft_area_files($data->{$fieldname}, $this->get_context()->id, 'customfield_image', $fieldname,$this->get('id'), $this->customfield_image_options($field)); 

	}

The function file_save_draft_area_files is part of the file API and moves a copy of the draft file into the correct area using the parameters supplied. We need to reference the plugin’s component name and the file area name (this can be anything so long as you use the reference throughout – I’m using the field element name in case there are a number of image fields setup).

Because of how the filemanager works with Moodle Quick Form we also have to trigger the save data function so that there is a value in the custom field data – we can achieve this by running the parent class instance_form_save.

It’s also worth adding in a delete function so that when the field is deleted the files are also deleted.

	public function delete() {
		$fieldname = $this->get_form_element_name();
		get_file_storage()->delete_area_files($this->get_context()->id, 'customfield_image', $fieldname, $this->get('id'));
		
		return parent::delete();
	}

Ref: Moodle Documentation File API

File Display

Finally we need to get the file to display on the frontend rather than the file item id that is currently returned.

Because we will now need to get our accepted types twice I changed this into a function:

	 /**
	 * Returns the default accepted types for this field.
	 *
	 * @return mixed
	 */
	public function get_accepted_types() {
		return array('png','jpg','jpeg','jpe','gif','svg','svgz');
	}

And updated customfield_image_options function to call this new accepted types function:

	public function customfield_image_options($field) {
		global $CFG;
		
		$maxfiles = $field->get_configdata_property('filelimit');
		
		if(empty($maxfiles) && !empty($CFG->courseoverviewfileslimit)) {
			$maxfiles = $CFG->courseoverviewfileslimit;
		} else if(empty($maxfiles)) {
			$maxfiles = 1;
		}
		
		$options = array(
			'maxfiles' => $maxfiles,
			'maxbytes' => $CFG->maxbytes,
			'subdirs' => 0,
			'accepted_types' => $this->get_accepted_types()
		);
		
		return $options;
	}

We need to modify the export_value function in /classes/data_controller.php to display an image.

	public function export_value() {
		$fieldname = $this->get_form_element_name();
		
		$files = get_file_storage()->get_area_files($this->get_context()->id, 'customfield_image', $fieldname, $this->get('id'),'', false);
		
		if (empty($files)) {
			return null;
		}
		
		$html = '';
		
		foreach($files as $file) {
			$fileurl = moodle_url::make_pluginfile_url($file->get_contextid(), $file->get_component(), $file->get_filearea(),$file->get_itemid(), $file->get_filepath(), $file->get_filename());
			
			$html .= html_writer::tag('div',
									  html_writer::empty_tag('img', ['src' => $fileurl, 'loading' => 'lazy']),
									  ['class' => $this->get_form_element_name()]);
		}
		
		return $html;
		
	}

Using Moodle’s File API we fetch the file using the references we set earlier. If this is empty (i.e. there are no files of that name) the function just returns null.

If there are some files the function loops through these and creates the file url using moodle_url and then adds to the output html string using HTML_Writer. Finally the html string is returned.

Modifying the Theme Template to use our new field.

Create a new Custom Course Field

Once the new plugin is finished and installed we can go to Site Administration -> Courses -> Course Custom Fields.

Create a new category e.g. Additional Course Images

Add a new custom field – you should see “Image” in the dropdown now which is created by our new plugin.

Set the settings how you would like. e.g :

Name: Header Image
Shortname: headerimage
File Limit: 1

Save.

Add a Header Image to your course

Scroll down and you should see the new category “Additional Course Images”
Upload an image to that field.

Modify the Course Template / Renderer

In a previous post Moodle 4 : Adding Course Image to Header I created a renderer override within the theme to use the course image if there was one and place it in the header field. We can now modify that previously built renderer override to look for a course header image and insert that instead if one exists.

	public function course_header_image() {
		global $CFG;
		global $COURSE;
		
		if ($this->page->course->id == SITEID) {
			// return immediately and do not include /course/lib.php if not necessary
			return '';
		}
		
		// This section has been borrowed from course/renderer.php 
		require_once($CFG->libdir. '/filestorage/file_storage.php'); // need this for accessing file api.
		require_once($CFG->dirroot. '/course/lib.php'); // need this for checking custom fields
		
		// File API
		$fs = get_file_storage();
		
		// modified this line so we have the course id for the context.
		$context = context_course::instance($this->page->course->id);
		
		// This will be the final string.
		$contentimages = '';
		
		// We need these two for checking custom fields. 
		require_once($CFG->dirroot. '/course/classes/list_element.php');
		require_once($CFG->dirroot. '/customfield/classes/output/field_data.php');
		
		// Get the current list element for this course.
		$coursele = new core_course_list_element($COURSE);
		$cfurl = '';
		
		$cfshortname = 'headerimage';
		
		// check if we have any custom fields.... if not we can skip :)
		if ($coursele->has_custom_fields()) {
			
			global $PAGE;
			$output = $PAGE->get_renderer('core_customfield');
			$fieldsdata = $coursele->get_custom_fields();
			foreach ($fieldsdata as $key => $data) {
				
				$fd = new core_customfield\output\field_data($data);
				if($fd->get_shortname() == $cfshortname) {
					$cfurl = $fd->get_value();
				}
				
			}
		}
		
		if(empty($cfurl)) {
			$files = $fs->get_area_files($context->id, 'course', 'overviewfiles', false, 'filename', false);
			if (count($files)) {
				
				// modified this line so we have the course id
				$overviewfilesoptions = course_overviewfiles_options($this->page->course->id);
				
				$acceptedtypes = $overviewfilesoptions['accepted_types'];
				if ($acceptedtypes !== '*') {
					// Filter only files with allowed extensions.
					require_once($CFG->libdir. '/filelib.php');
					foreach ($files as $key => $file) {
						if (!file_extension_in_typegroup($file->get_filename(), $acceptedtypes)) {
							unset($files[$key]);
						}
					}
				}
				if (count($files) > $CFG->courseoverviewfileslimit) {
					// Return no more than $CFG->courseoverviewfileslimit files.
					$files = array_slice($files, 0, $CFG->courseoverviewfileslimit, true);
				}
			}
		}
		
		if(!empty($cfurl)) {
			$contentimages .= $cfurl;
		// This part has been borrowed and modified from course/classes/list_element.php
		} else if(!empty($files)) {
			foreach ($files as $file) {
				$isimage = $file->is_valid_image();
				$url = moodle_url::make_file_url("$CFG->wwwroot/pluginfile.php",
												'/' . $file->get_contextid() . '/' . $file->get_component() . '/' .
												$file->get_filearea() . $file->get_filepath() . $file->get_filename(), !$isimage);
				if ($isimage) {
					$contentimages .= html_writer::tag('div',
													html_writer::empty_tag('img', ['src' => $url,'loading' => 'lazy']),
													['class' => 'courseimage']);
				} 
			}
			
		}
		
		return $contentimages;
	}

Here I’m looping through the course’s custom fields and finding one that matches the right shortname ‘headerimage’ then I’m fetching the output for that (export_value) and returning that via $contentimages. There is very likely a quicker way of doing this since we know the shortname – something to look further into.

Moodle 4 : Adding Course Image to Header

Moodle 4 : Adding Course Image to Header

Being able to add a course header image has been a popular request so I’ve been looking into how best to do this within the theme…

The Header area’s mustache file is located in lib/templates/full_header.mustache

To add in a header image we would need a way to add the image to the json context and to override the mustache template in our own theme.

The full_header mustache template is renderered by the core_renderer class in lib/outputrenderers.php with the function full_header.

We will need to write a renderer override into our theme to modify this section. In the theme folder you will need a renderers.php file e.g. /example/renderers.php

<?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/>.

/**
 * Theme Renderer Functions go here.
 *
 * @package     theme_example
 * @copyright   2023 Author Name <email@example.com>
 * @license     https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

defined('MOODLE_INTERNAL') || die();

/**
 * Overrides for the standard implementation of the core_renderer interface.
 * Original class located in lib/outputrenders.php and in Boost/classes/output/core_renderer
 */
class theme_example_core_renderer extends \core_renderer {

    // This is where we will add any functions we want to override

}

Add in a class override to the renderers file (see example). The name of your override should follow this pattern : <<theme_component_name>>_core_renderer .

Once setup you can just take a copy of any functions you want to override and place them inside the class.

    /**
	 * Wrapper for header elements.
	 *
	 * @return string HTML to display the main header.
	 */
	public function full_header() {
		$pagetype = $this->page->pagetype;
		$homepage = get_home_page();
		$homepagetype = null;
		// Add a special case since /my/courses is a part of the /my subsystem.
		if ($homepage == HOMEPAGE_MY || $homepage == HOMEPAGE_MYCOURSES) {
			$homepagetype = 'my-index';
		} else if ($homepage == HOMEPAGE_SITE) {
			$homepagetype = 'site-index';
		}
		if ($this->page->include_region_main_settings_in_header_actions() &&
			!$this->page->blocks->is_block_present('settings')) {
			// Only include the region main settings if the page has requested it and it doesn't already have
			// the settings block on it. The region main settings are included in the settings block and
			// duplicating the content causes behat failures.
			$this->page->add_header_action(html_writer::div(
				$this->region_main_settings_menu(),
															'd-print-none',
												   ['id' => 'region-main-settings-menu']
			));
		}
			
		$header = new stdClass();
		$header->settingsmenu = $this->context_header_settings_menu();
		$header->contextheader = $this->context_header();
		$header->hasnavbar = empty($this->page->layout_options['nonavbar']);
		$header->navbar = $this->navbar();
		$header->pageheadingbutton = $this->page_heading_button();
		$header->courseheader = $this->course_header();
		$header->headeractions = $this->page->get_header_actions();
		if (!empty($pagetype) && !empty($homepagetype) && $pagetype == $homepagetype) {
			$header->welcomemessage = \core_user::welcome_message();
		}
		return $this->render_from_template('core/full_header', $header);
	}

Then create a copy of lib/templates/full_header.mustache into your theme’s template folder e.g. example/templates/core/full_header.mustache . This will allow you to change and override the template.

In your theme’s renderers.php file and inside theme_example_core_renderer class in the full_header function add the following to add a new context with some output.

// Add in a custom context
$header->courseimage = '<img src="example.jpg" alt="Course Image" class="courseimage">';

Then in the full_header.mustache file in your theme’s templates modify the following lines:

Add in our new context to the example:

{{!
    @template core/full_header

    This template renders the header.

    Example context (json):
    {
        "contextheader": "context_header_html",
        "settingsmenu": "settings_html",
        "hasnavbar": false,
        "navbar": "navbar_if_available",
        "courseheader": "course_header_html",
        "welcomemessage": "welcomemessage",
        "courseimage" : "course_image_html"
    }
}}

And add in the following where you want the image to appear:

{{{courseimage}}}

If you save all the above, purge the cache and view. You should see a broken image link that we used as an example (you can of course use an actual image url.

Next we need to get it to load the actual course image stored in the course settings.

Add some SCSS to make the course image full width. Note this is based on my own custom theme so may not work with yours – just showing for completeness.

/* Course Header Image */
#page-header {
	position: relative; // needed so the image doesn't overflow

	/* we are adding the surrounding page margins on and extending the image 
	outwards to fill the screen 
	(note my theme uses the full width play around with this as you need) */
	.courseimage {
		position: absolute;
		width: calc(100% + 3.5rem + 1.5rem);
		left: -3.5rem;
		top: 0;
		height: calc(100% + 3rem);
		margin-top: -3rem;
		img {
			object-fit: cover;
			width: 100%;
			height: 100%;
		}
	}
}

Next Steps:

As it is, this just loads the same image over and over using the URL we have given it. It could be better!

  • Fetch the course image from the course settings
  • Add a context check to check if there’s an image
  • modify the mustache template to check if there is an image.

Fetching Course Image

We know that the course image is already loaded on the course listing so it makes sense to see if we can reuse any existing functions.

The course image in the listing is fetched by the function course_overview_files in the core_course_renderer class in course/rendererers.php which uses the function get_course_overviewfiles in the core_course_list_element class in course/classes/list_element.php

First we want to create a new function in our theme_example_core_renderer class in example/rendererers.php . I’m reusing some of the code from the previous functioned mentioned. (There’s possibly a neater way of doing this but this was what I found worked).

	/**
	 * Renders course header image
	 *
	 * @return string HTML to display the course image
	 */
	public function course_header_image() {
		global $CFG;
		
		if ($this->page->course->id == SITEID) {
			// return immediately and do not include /course/lib.php if not necessary
			return '';
		}
		
		// This section has been borrowed from course/renderer.php 
		global $COURSE;
		
		require_once($CFG->libdir. '/filestorage/file_storage.php');
		require_once($CFG->dirroot. '/course/lib.php');
		
		$fs = get_file_storage();
		
		// modified this line so we have the course id for the context.
		$context = context_course::instance($this->page->course->id);
		
		$files = $fs->get_area_files($context->id, 'course', 'overviewfiles', false, 'filename', false);
		if (count($files)) {
			
			// modified this line so we have the course id
			$overviewfilesoptions = course_overviewfiles_options($this->page->course->id);
			
			$acceptedtypes = $overviewfilesoptions['accepted_types'];
			if ($acceptedtypes !== '*') {
				// Filter only files with allowed extensions.
				require_once($CFG->libdir. '/filelib.php');
				foreach ($files as $key => $file) {
					if (!file_extension_in_typegroup($file->get_filename(), $acceptedtypes)) {
						unset($files[$key]);
					}
				}
			}
			if (count($files) > $CFG->courseoverviewfileslimit) {
				// Return no more than $CFG->courseoverviewfileslimit files.
				$files = array_slice($files, 0, $CFG->courseoverviewfileslimit, true);
			}
		}
		$contentimages = '';
		
		// This part has been borrowed and modified from course/classes/list_element.php
		if(!empty($files)) {
			foreach ($files as $file) {
				$isimage = $file->is_valid_image();
				$url = moodle_url::make_file_url("$CFG->wwwroot/pluginfile.php",
												'/' . $file->get_contextid() . '/' . $file->get_component() . '/' .
												$file->get_filearea() . $file->get_filepath() . $file->get_filename(), !$isimage);
				if ($isimage) {
					$contentimages .= html_writer::tag('div',
													html_writer::empty_tag('img', ['src' => $url]),
													['class' => 'courseimage']);
				} else {
					$image = $this->output->pix_icon(file_file_icon($file, 24), $file->get_filename(), 'moodle');
					$filename = html_writer::tag('span', $image, ['class' => 'fp-icon']).
					html_writer::tag('span', $file->get_filename(), ['class' => 'fp-filename']);
					$contentfiles .= html_writer::tag('span',
													html_writer::link($url, $filename),
													['class' => 'coursefile fp-filename-icon']);
				}
			}
			
		}
		
		return $contentimages;
	}

Now if I go back to the full_header function in the theme’s renderer.php file I can change the courseimage to use this function instead:

		// Add in a custom context
		$header->courseimage = $this->course_header_image(); 
		$header->hascourseimage = (!empty($header->courseimage) ? true : false);

I’ve also added in a new check to see if this image is empty – I will need this later to make sure the title is visible over the header banner.

Modify the full_header mustache file to use the new check.

{{!
    @template core/full_header

    This template renders the header.

    Example context (json):
    {
        "contextheader": "context_header_html",
        "settingsmenu": "settings_html",
        "hasnavbar": false,
        "navbar": "navbar_if_available",
        "courseheader": "course_header_html",
        "welcomemessage": "welcomemessage",
        "courseimage" : "course_image_html",
        "hascourseimage" : false
    }
}}
<header id="page-header" class="header-maxwidth d-print-none {{#hascourseimage}}has-course-img{{/hascourseimage}}">
	{{#hascourseimage}}
		{{{courseimage}}}
	{{/hascourseimage}}

And modify our SCSS to make sure the header text is visible.

/* Course Header Image */
#page-header {
	position: relative;

	.courseimage {
		position: absolute;
		width: calc(100% + (3.5rem * 2));
		left: -3.5rem;
		top: 0;
		height: calc(100% + 3rem);
		margin-top: -3rem;
		img {
			object-fit: cover;
			width: 100%;
			height: 100%;
		}
	}
	// padding on the right is different when the right drawer is open
	.show-drawer-right & {
		.courseimage {
			width: calc(100% + 3.5rem + 1.5rem);
		}
	}
	&.has-course-img .page-header-headings h1 {
		background-color: $white;
		padding: 0.5rem 1rem;
		border-radius: 500px;
	}
}

Note : I have more SCSS in my theme to cover different responsive breakpoints and my theme uses the full width of the screen.

Further Improvements

There is one downside of using the course image here – we could end up loading too large an image for a small area – not very efficient! It would be better if we could add a separate setting in the course settings for adding a course header image.

Investigating this and will make another post when I know more about it.

Moodle 4 : Focus error fix (atto plugins)

Moodle 4 : Focus error fix (atto plugins)

I was hitting upon an error in one of my atto plugins whenever I clicked on a button in my dialogue form. It also appears when you use the Atto Styles button on the toolbar.

Uncaught RangeError: Maximum call stack size exceeded.

This appears to be a known bug see tracker

Using the information given by Jake Dallimore in the comments I added the following to my _displayDialogue function in my YUI module.

require(['core/local/aria/focuslock'], function(FocusLockManager) {
	// We don't want to trap focus.
	FocusLockManager.untrapFocus();
});

From the comments it seems as though what’s happening is we are ending up with two active focuses when there should just be one. The code seems to work by untrapping the focus when the dialogue is opened so that there is only one again

Moodle 4 : Using Images in Renderer Overrides

Moodle 4 : Using Images in Renderer Overrides

If you’re looking to customise your theme or plugin by using a specific image in a renderer override, it’s actually quite simple! Just follow these two steps:

Upload the image file (e.g. IMAGENAME.png) to the “pix” folder in your theme or plugin. This will make sure that the image is accessible to the code when referenced.

Use the following code in your function to return the url:

$this->image_url('IMAGENAME', 'theme') 

to reference the image you want. This will ensure that the image is loaded in the renderer override. Note if you are using a plugin you’ll want to reference the component plugin name rather than “theme”.

And that’s it! With these two simple steps, you can easily use any theme or plugin image in your renderer override.

Documentation : docs.moodle.org

Custom Menu Option : Moodle

Custom Menu Option : Moodle

There is a really easy way of adding custom menu items to Moodle without using any additional code.

Under the “Custom menu items” field, enter menu items in the form of:

-Link Text|Link URL

For example, if we wanted to add a link to an external site such as Google, we could enter the following:

Google|https://www.google.com

Once finished, click the “Save Changes” button at the bottom of the page to apply the changes. The newly added links should now appear in the primary navigation bar in Moodle.

Moodle Theming 4.1 : Login Background Image

Moodle Theming 4.1 : Login Background Image

Note : in order to work in addition to following the steps outlined here : https://docs.moodle.org/dev/Creating_a_theme_based_on_boost there needs to be an additional function added to the theme’s lib.php file otherwise the login background image and the body background image won’t load.

function theme_themename_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = array()) {
    if ($context->contextlevel == CONTEXT_SYSTEM && ($filearea === 'logo' || $filearea === 'backgroundimage' || $filearea === 'loginbackgroundimage')) {
        $theme = theme_config::load('themename');
        // By default, theme files must be cache-able by both browsers and proxies.
        if (!array_key_exists('cacheability', $options)) {
            $options['cacheability'] = 'public';
        }
        return $theme->setting_file_serve($filearea, $args, $forcedownload, $options);
    } else {
        send_file_not_found();
    }
}
Useful Moodle Documentation

Useful Moodle Documentation

Some useful links to Moodle documentation I’ve picked up while working through the Developer Moodle Academy Courses for my future reference.

Plugin Documentation

API Documentation

Database Documentation

Testing

Local Development Useful Links

MAMP Documentation

Adding Block Regions in Moodle Template

Adding Block Regions in Moodle Template

A very quick overview on what you need to add to add a block region in a Moodle theme template.

Config.php

Create a region id name then add it to your ‘regions’ paramater array for the page template (or templates) you want to use that block region on. You’ll need to set a defaultregion if you have a set region.

$THEME->layouts = [
    'base' => array(
        'file' => 'columns2.php',
        'regions' => array(),
    ),
    'frontpage' => array(
        'file' => 'frontpage.php',
        'regions' => array('side-pre','side-top'),
        'defaultregion' => 'side-pre',
    ),
];

/layout/frontpage.php

You will want to add the following lines of code to this file (or the template file you are adding the region to).

$blockshtml = $OUTPUT->blocks('side-pre');
$topblockshtml = $OUTPUT->blocks('side-top');

$hasblocks = strpos($blockshtml, 'data-block=') !== false;
$hastopblocks = strpos($topblockshtml, 'data-block=') !== false;

In your template context you’ll want to add the following parameters

$templatecontext = [
    ...
    'sidepreblocks' => $blockshtml,
    'sidetopblocks' => $topblockshtml,
    'hasblocks' => $hasblocks,
    'hastopblocks' => $hastopblocks,
    ...
];

/template/frontpage.mustache

Then in your template mustache file you will want to add the following to display any blocks in that region on your page.

{{#hastopblocks}}
	<aside data-region="blocks-column" class="d-print-none topblocks py-1 px-2" aria-label="{{#str}}blocks{{/str}}">
		{{{ sidetopblocks }}}
	</aside>
{{/hastopblocks}}

/lang/en/theme_name.php

You’ll also want to add a language string for any new block regions you add.

$string['region-side-top'] = 'Top Region';
Building An Atto Plugin (WIP)

Building An Atto Plugin (WIP)

My notes from working out how to build a plugin for Moodle’s Atto Editor.
(This post is not entirely finished – working out the dialogs)

Plugin Location

Atto Editor plugins can be found in /lib/editor/atto/plugins as defined here and listed here

Note an Atto Plugin is known as a sub-plugin rather than a full plugin although it follows a similar format.

Required Files

  • Version.php
  • /lang/en/atto_pluginname.php
  • /yui/src/button/

Version.php

defined('MOODLE_INTERNAL') || die();

$plugin->version   = 2022030700;        // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires  = 2020061511;        // Requires this Moodle version.
$plugin->component = 'atto_pluginname';  // Full name of the plugin (used for diagnostics). 

Current Moodle version can be found in Moodle’s own version.php file.

The component name is {plugin_type}_{plugin_name}
In this case the plugin type is “atto”

Language String File

This will contain all the translatable language strings for this plugin.
It should follow the frankenstyle naming convention of {plugin_type}_{plugin_name}.php i.e. the component name set in version.php

It should at least contain the name of the plugin in there.

$string['pluginname'] = 'Plugin Name';

YUI src button module

This is currently required but looking at the documentation it looks like Moodle are planning on replacing YUI with something else in the future. I worked through the documentation and experimented with it and have recorded more information below.

YUI Modules

Moodle used to use shifter to compile the YUI modules in the past. According to documentation it currently uses Grunt to compile the JS now but the documentation isn’t fully up to date on how this works in practise. I found much more information on how to use shifter so started off with this and will work on shifting to Grunt.

Basic File Structure

  • /build
    • This is where the compiled files get stored this is what Moodle actually loads.
  • /src
    • /button
      • /js
        • button.js
          (contents / working code for the module)
      • /meta
        • button.json
          (list of dependencies for the module)
      • build.json
        (essentially instructions for shifter or grunt on how to build the final js file)

Example build.json

{
  "name": "moodle-atto_pluginname-button",
  "builds": {
    "moodle-atto_pluginname-button": {
      "jsfiles": [
        "button.js"
      ]
    }
  }
}

Note the name “moodle-atto_pluginname-button” in “Builds” indicates the name of the folder it compiles to. The “Name” indicates the name of the javascript files after compiling.

Example /meta/button.json

{
    "moodle-atto_boostrap4moodle-button": {
        "requires": [
            "moodle-editor_atto-plugin"
        ]
    }
}

Example /js/button.js

Moodle Documentation has a list of examples for this file. Update the examples to use your component and plugin name. e.g. in the atto_strike example you would replace “atto_strike” with “atto_pluginname”

Compiling with Shifter

This is the old way of compiling the YUI module and reading through the threads on it, YUI will eventually be replaced but that may be sometime away yet due to the complexity. I’m going to take a look at compiling with Grunt as well but found more information on shifter so looked at it initially as a temporary way of compiling the YUI module.

To use shifter you need to have node.js installed then install shifter.

npm -g install shifter

Once installed navigate to your YUI /src folder and run the shifter watch command.

 cd ../yui/src/
 shifter --watch

Any change you make in button.js after that will cause the compiler to recompile all the files into the build folder.

Button Icon Locations

Icons need to be stored in /pix/filename within the plugin. SVG is supported 😊
They can then be referenced in button.js when creating a button – example below:

		this.addToolbarMenu({
			icon: 'e/bootstrap', 
			iconComponent: 'atto_pluginname',
			title: 'pluginname',
			globalItemConfig: {
				callback: this._changeStyle
			},
			items: items
		});

The icon name “e/bootstrap” is referring to an image stored at /pix/e/bootstrap.svg inside the plugin.
The iconComponent name is the plugin component name so it know to look in /lib/editor/atto/plugins/atto_pluginname
The title “pluginname” is reference to a language string in /lang/en/atto_pluginname.php

Experimenting with Button.js

I’ve added a few examples of what you can do in the YUI module. I learned a lot of this looking at the code for Core Moodle Atto plugins. A good one to check out is the atto_table plugin.

Using Language Strings in Button.js

You can reference language strings in your YUI module with the following reference:

M.util.get_string("stringReference", component)

Replace “stringReference” with the name of your string located in /lang/en/atto_pluginname.php

The variable component should be declared somewhere in the code and be the component name of your plugin e.g. atto_pluginname

The string should also be listed in the lib.php file for the plugin in the function atto_pluginname_strings_for_js (replace atto_pluginname with your plugin component name).

function atto_pluginname_strings_for_js() {
    global $PAGE;

    $PAGE->requires->strings_for_js(array('stringReference'),
                                    'atto_pluginname');
}

Reference : https://docs.moodle.org/dev/Atto#Atto_subplugin_Php_API

If using mustache templates you can also reference these strings using the following reference and passing the variable via javaScript

{{get_string "stringReference" component}}
var component = 'atto_pluginname',
EXAMPLETEMPLATE = '<div>{{get_string "stringReference" component}}</div>';

You can then use the below code to convert the mustache strings in the example template to the Language strings stored in your plugin language file ( /lang/en/atto_pluginname.php ). Remember to pass the component name (usually stored in a variable) and to reference the string in the plugin’s lib.php file otherwise it won’t work!

/* This example function can pass form content to a dialogue box in Atto */
_getDialogueContent: function() {
		
		// this will be the form template.
		var finaltemplate = Y.Handlebars.compile(EXAMPLETEMPLATE);
		
		// insert variables into the template
		var content = Y.Node.create(finaltemplate({
			component: component
		}));
		
		return content;
	},

Inserting a Template into Content Example

	 /**
	 * Change the title to the specified style.
	 *
	 * @method _insertTemplate
	 * @param {EventFacade} e
	 * @param {string} template
	 * @private
	 */
	_insertTemplate: function(e, template) {
		this._currentSelection = this.get('host').getSelection();
		var host = this.get('host');
		
		// Focus on the last point.
		host.setSelection(this._currentSelection);
		
		// And add the template. e.g. <p>test</p>
		host.insertContentAtFocusPoint(template);
		
		// Mark as updated
		this.markUpdated();
	}

A callback that can be added to globalItemConfig: { } to insert a HTML template into the main content editor. This can be extended to fill in information from a dialog form.

Note: I think there is probably a way to compile the template passed in like there is for the form dialogue but I ended up writing a custom one for inserting into Editor Content because if I use the same method as form dialogue it goes wrong – I’m suspecting I don’t have a setting right somewhere but I need to look deeper into this!

/**
	 * Replace Mustache Placeholders with content
	 *
	 * @method _replaceMustache
	 * @param {string} template (html)
	 * @param {object} variables
	 * @private
	 */
	_replaceMustache: function(template, variables) {
		
		// holder for the final output.
		var finalHtml = template;
		
		// this next bit is only if we have variables to play with.
		if(typeof variables == "object" && Object.keys(variables).length !== 0) {
			
			// Loop through the object
			for (const [key, value] of Object.entries(variables)) {
				
				// find each mustache key and replace with the value.
				var regex = new RegExp(`{{${key}}}`,`g`);
				finalHtml = finalHtml.replace(regex, value);
				
			}
			
		}
		
		return finalHtml;
	},

Troubleshooting

A few things that tripped me up while working out how to get this to work!

Button not appearing

Check it has been set in Site Administration > Plugins > Text Editors > Atto HTML Editor -> Atto Toolbar Settings ( ../admin/settings.php?section=editorsettingsatto )

Add “pluginname = pluginname” to the bottom of Toolbar Config to make it show at the end of the toolbar. You can also insert it inside a group of icons if needed.
Note you should change “pluginname” to match your component name

Question for later : is it possible to add it by default or is that a bad idea and if so why?

Missing Strings?

If a string is called in the YUI js files it must be specified in the language strings file in order to show. Otherwise you will get errors!

It also may need to be specified in lib.php in the atto_pluginname_strings_for_js() function.

function atto_pluginname_strings_for_js() {
    global $PAGE;

    $PAGE->requires->strings_for_js(array('accordion',
										  'pluginname',
										  'h3',
                                          'h4',
                                          'h5',
                                          'pre',
                                          'p'),
                                    'atto_pluginname');
}

Missing YUI component?

This seems very obvious in hindsight but although the documentation only mentions requiring yui/src/button.js you actually need javascript files in /yui/build/moodle-atto_pluginname-button/ to exist for example : moodle-atto_pluginname-button.js as well for this to work. Otherwise it just won’t load and you’ll get a missing module message in dev tools console. These files get automatically created when you compile with shifter or (presumably) Grunt.

YUI module not compiling

One of those silly obvious ones but make sure any custom functions you add are separated by a comma!

Useful Documentation

https://docs.moodle.org/dev/Atto
https://docs.moodle.org/dev/Frankenstyle
https://docs.moodle.org/dev/YUI/Modules
https://docs.moodle.org/dev/YUI/Shifter
https://docs.moodle.org/dev/Grunt