Browsed by
Tag: php

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 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();
    }
}
MS Word Regex Pattern

MS Word Regex Pattern

A wee pattern to strip out MS word nonsense from pasted in text.

(<[\/]?span[a-zA-Z0-9\',\.\#\:\;\%\{\s="\-\(\)]*>| |data-leveltext="[]"[\s]?|data-font="[a-zA-Z]*"[\s]?|data-listid="[0-9]*"[\s]?|data-list-defn-props="{"[\s]?|data-aria-posinset="[0-9]"[\s]?|data-aria-level="[0-9]"[\s]?|class="[A-Za-z]*"[\s]?|style="[a-z0-9\-\:\;\.\%,\s]*"[\s]?|class="[A-Za-z0-9\s]*"[\s]?|·)

View example

// Anti MS Word Pattern
$antiword = '/(<[\/]?span[a-zA-Z0-9\',\.\#\:\;\%\{\s="\-\(\)]*>| |data-leveltext="[]"[\s]?|data-font="[a-zA-Z]*"[\s]?|data-listid="[0-9]*"[\s]?|data-list-defn-props="{"[\s]?|data-aria-posinset="[0-9]"[\s]?|data-aria-level="[0-9]"[\s]?|class="[A-Za-z]*"[\s]?|style="[a-z0-9\-\:\;\s]*"[\s]?|class="[A-Za-z0-9\s]*"[\s]?|·)/i';

$text = preg_replace($antiword, "", $text);
' Anti MS Word
DIM pattern AS string = "(<[\/]?span[a-zA-Z0-9\',\:\;\%\{\s=""\-\(\)]*>| |data-leveltext=""[]""[\s]?|data-font=""[a-zA-Z]*""[\s]?|data-listid=""[0-9]*""[\s]?|data-list-defn-props=""{""[\s]?|data-aria-posinset=""[0-9]""[\s]?|data-aria-level=""[0-9]""[\s]?|class=""[A-Za-z]*""[\s]?|style=""[a-z0-9\-\:\;\s]*""[\s]?|class=""[A-Za-z0-9\s]*""[\s]?|·)"
Dim regex AS regex = new Regex(pattern)

text = Regex.Replace(text, pattern, "")
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

Moodle Academy : Plugin Development

Moodle Academy : Plugin Development

I have just completed the free Plugin Development course with Moodle Academy and I highly recommend it for anyone looking to learn how to code their own Moodle plugins. I completed the training so that I can better create Moodle plugins to help support our staff and students using our VLE LearnNet at the college.

Here’s a screenshot of my version of the Hello World plugin the course gets you to create.

Includes some custom capabilities and Italian translations.

Looking forward to creating some more plugins for our staff and students in the future…

Adding a compact logo to the frontpage (Moodle)

Adding a compact logo to the frontpage (Moodle)

Today I resolved a very small problem which had been bugging me for awhile. On our Moodle theme the small compact logo appeared in the top navbar everywhere except on the frontpage. On the frontpage it was displaying correctly.

To solve I first checked the mustache template file in the boost theme / my theme to get some clues as to why it wasn’t working.

<a href="{{{ config.wwwroot }}}" class="navbar-brand aabtn {{# output.should_display_navbar_logo }}has-logo{{/ output.should_display_navbar_logo }}
        {{^ output.should_display_navbar_logo }}
                d-none d-sm-inline
        {{/ output.should_display_navbar_logo }}
">
			
        {{# output.should_display_navbar_logo }}
                <span class="logo d-none d-sm-inline">
                        {{# output.get_compact_logo_url }}
			        <img src="{{output.get_compact_logo_url}}" alt="{{sitename}}">
			{{/ output.get_compact_logo_url }}
                        {{^ output.get_compact_logo_url }}
			        <span class="site-name d-none d-md-inline">{{{ sitename }}}</span>
			{{/ output.get_compact_logo_url }}
                </span>
        {{/ output.should_display_navbar_logo }}
           
        {{^ output.should_display_navbar_logo }}
                <span class="site-name d-none d-md-inline test">{{{ sitename }}}</span>
        {{/ output.should_display_navbar_logo }}
            
</a>

The code I found referenced the following function “should_display_navbar_logo”.

To find out where this was coming from I used grep and searched the moodle installation for a reference to this function and discovered it was coming from lib/outputrenderers.php

public function should_display_navbar_logo() {
        $logo = $this->get_compact_logo_url();
        return !empty($logo) && !$this->should_display_main_logo();
}

This function in turn referenced another function called “should_display_main_logo” guessing it may be in the same file I searched again and found the function.

public function should_display_main_logo($headinglevel = 1) {

        // Only render the logo if we're on the front page or login page and the we have a logo.
        $logo = $this->get_logo_url();
        if ($headinglevel == 1 && !empty($logo)) {
                if ($this->page->pagelayout == 'frontpage' || $this->page->pagelayout == 'login') {
                        return true;
                }
         }

         return false;
 }

It was this function that was causing the compact logo to disappear on the frontpage. In order to override it I needed to write a renderer override within my theme. I already have a core renderer override setup in my theme to handle some of the footer elements so I didn’t need to reset this up.

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

class theme_name_core_renderer extends core_renderer {

     /* ... */

}

I created a copy of the “Should_display_main_logo” function and added it into my renderer override class. I think modified the following line:

//if ($this->page->pagelayout == 'frontpage' || $this->page->pagelayout == 'login') {
if ($this->page->pagelayout == 'login') {

This change means that the compact logo will now show on the frontpage.

Building a new Moodle Theme

Building a new Moodle Theme

With the launch of the college’s new branding and a planned Moodle upgrade our Moodle theme needed a refresh. Over the last 6 years or so I’ve built numerous WordPress themes from scratch so I took on a new challenge and looked at the Moodle Documentation and began learning how to building our own using the college’s new colours and fonts.

This was entirely new to me as I only started picking up Moodle when I joined the College in December 2019 and had to pick it up rather quickly given the pandemic.

Design Principals

There were a few things I was keeping in mind.

  • To maximise the space as much as possible for the Lecturers and Students
  • To make everything as accessible as possible.
  • Keep it as simple as possible ( K.I.S.S. )

Accessibility Guide
WCAG Accessibility Guidelines
The Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018

Problems to overcome

There were (and still are) a few barriers to building the theme. Some of these will disappear once we are able to move to the new course layout that my colleague Steve has designed and developed. For now we need to support the legacy setup.

  • Multiple course formats have been installed into the existing VLE and need to be supported.
    I focused on the CORE functionality to start with then started working on adding in support as we worked out what course formats were still being used.
  • Multiple plugins have been installed into the existing VLE over the years and some need to be supported.
    This caused unexpected conflicts at points that took time to diagnose.
    Eventually our plan is to install a fresh Moodle with Steve’s course layout and we will go through and remove unnecessary plugins

Overview of the new design.

To break this very long post up I have separated out each component into a separate post. Some of the changes were simple changes to the SCSS files and others involved output renderer overrides. Click on each of the post embeds to find out more.

Screenshot of the new Moodle Theme.
Screenshot of the New Moodle Theme Dashboard

Navigation Panel

Role Descriptor on top Menu

You are logged in as….

Redesigning the footer…

Currently writing up

  • User Menu
  • Custom Menu to useful services for the Students.

Editor Button

Currently writing up…

Forum Post Layout

Currently writing up

Course Search Layout

Currently writing up

Book Navigation Modifications

Currently writing up

POST TO BE CONTINUED….

Useful Documentation

Creating a Theme using Boost (Moodle Documentation)
Themes Overview (Moodle Documentation)
Moodle Template Overview
Moodle Renderers
Moodle Output Renderers
Moodle Filters
Moodle Roles
Moodle Navigation API
Useful UnObvious things
How to Override a Template
How to Override a renderer

Mustache Documentation
Moodle Theme SCSS
Moodle Theme Pre and Post SCSS
Moodle HTML Writer

Moodle Data Manipulation API


Moodle Theme Building : Role Descriptor

Moodle Theme Building : Role Descriptor

This post is part of a series for documenting the work I did building a new Moodle Theme for our VLE’s. More to follow…

We have many different user roles in our VLE. I wanted a way to show this clearly at the top just under the user’s name at the top.

How I set this up….

This wasn’t a simple task as it turned out I needed to override a core library output renderer in order to complete the task. The renderer function I needed to override can be found in /lib/outputrenderers.php

https://github.com/moodle/moodle/blob/master/lib/outputrenderers.php

I created a copy of the user_menu function from this file and copied it into my theme’s renderer override class.

https://docs.moodle.org/dev/Output_renderers
https://docs.moodle.org/dev/Overriding_a_renderer

My Theme’s renderer override class lives in : /theme/nameoftheme/renderers.php
I’m extending the core_renderer here Line 517 of lib/outputrenderers.php so the class should have “… extends core_renderer” when it’s declared.

I copied in the user_menu function into the theme’s override class so I can override it.
On what would be line 3371 of the original file I replace the role section with a call to a function I’ve created in the override class called show_user_roles (see further down). This function should return a HTML String that describes the roles the current user is in.

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

class theme_nameoftheme_core_renderer extends core_renderer {

     // Original Function in /lib/outputrenderers.php 
     public function user_menu($user = null, $withlinks = null) {

          ...

          // fetch all role information....
          $usertextcontents .= $this->show_user_roles($user, $opts);

          ...

     }

}
// Original user_menu function
public function user_menu($user = null, $withlinks = null) {

     ...

     // Role. (Line 3371 of original)
     if (!empty($opts->metadata['asotherrole'])) {
          $role = core_text::strtolower(preg_replace('#[ ]+#', '-', trim($opts->metadata['rolename'])));
          $usertextcontents .= html_writer::span(
               $opts->metadata['rolename'],
                'meta role role-' . $role
          );
     }

     ...

}
public function show_user_roles($user = null, $opts = null) {
	// fetch all role information....
	$systemcontext = context_system::instance();
	$allRoles = role_fix_names(get_all_roles(), $systemcontext, ROLENAME_ORIGINAL);
	$usertextcontents = '';
        
	$usertextcontents .= html_writer::start_span('roles d-flex justify-content-end');

		
	// Role.
	if (!empty($opts->metadata['asotherrole'])) { // Logged in as....
		$role = core_text::strtolower(preg_replace('#[ ]+#', '-', trim($opts->metadata['rolename'])));
		$usertextcontents .= html_writer::span(
		$opts->metadata['rolename'],
			'meta role role-' . $role
		);
	} else {
		if(is_siteadmin()) { // Is Site Administrator
			$usertextcontents .= html_writer::span(
				'Site Administrator',
				'meta role role-admin'
			);
		} else { // possibly another role....
					
			$ismanagerorlecturer = false;
			foreach($allRoles as $key => $role) {
					
				if(user_has_role_assignment($user->id,$key)) {
					
					if(in_array($role->shortname, array(...))) {
						// skip these ones :).
					} else {
						if(in_array($role->shortname, array('manager','editinglecturer','teacher','visitinglecturer') ) ) {
							$ismanagerorlecturer = true;
						}
							
						// some lectureres are listed as students as well which seems sort of pointless to display this...
						if($role->shortname != 'student' || ($role->shortname == 'student' && !$ismanagerorlecturer)) {
							$usertextcontents .= html_writer::span(
								$role->localname,
									'meta role role-' . $role->shortname
							);
						}
					}
				}
			}
		}
	}
        
	$usertextcontents .= html_writer::end_span();
        
	return $usertextcontents;
}

That’s quite a bit so here’s a breakdown of what this script is doing:

  • Line 4 : Fetch all the user roles used on the site ($allRoles)
  • Line 5 : Create a string to store the html in ($usertextcontents = ”;)
  • Line 11 : Check if the current user is masquerading as another role e.g. a Lecturer viewing as a “Student”
    (if (!empty($opts->metadata[‘asotherrole’])) {)
    • Line 12 : turn the role name into a class (i.e. remove spaces etc)
    • Line 13 : Create a span for the role.
  • Line 17 : If current user isn’t masquerading….
    • Line 18 : Check if current user is a Site Admin….
      • Line 19 : Create a span for this role.
    • Line 23 : All users except the site admin….
      • Line 25 : This is a check to see if the user is a manager / lecturer since they may have multiple roles so we might want to skip some!
      • Line 26 : cycle through $allRoles
      • Line 28 : Check if current user has this role
      • Line 30 : This line I’m checking to see if this is one of the roles we should skip
        (we have some extra roles to set certain permissions for a custom course activity so we don’t want to display these since no one will care much about these)
      • Line 33 : Checking to see if the current user is in a manager / lecturer role in which case we set the flag to true.
      • Line 38 : If the role isn’t “Student” OR if the role is “Student” AND the user isn’t a manager or lecturer create a span for this role.
  • Line 52 – function returns the finished HTML string.

Handling a user with Multiple Roles

But what if the user has multiple roles?

When I initially wrote this I didn’t have the bit where I check for manager / lecturer or the bit where I skip some of the extra roles.

For some users where they were in multiple different roles on the site made it display weirdly as there wasn’t enough space.

screenshot of original issue

If they are viewing as a specific role we won’t see the additional roles (this is handled on line 11)

To deal with this. I first added the check for manager / lecturer since those roles we don’t really care that they are also in a student role.

I also hid some of the irrelevant roles we don’t care about by skipping them in the loop.

This reduced the list down from about 10 roles to maybe 3 but it was still displaying in a column.

I added some bootstrap classes (boost and my theme uses BootStrap) and CSS to handle this.

$usertextcontents .= html_writer::start_span('roles d-flex justify-content-end');

https://getbootstrap.com/docs/4.6/utilities/flex/

screenshot of the fixed issue
.roles {
	column-gap: 5px;
	.role:after {
		content: ',';
		display: block;
		float: right;
	}
	.role:last-child:after {
		display: none;
	}
}

This may need further improvements if the user is a member of even more roles but so far it hasn’t been an issue for me. I will revisit this if it becomes and issue.

What about Mobile?

On mobile I’m collapsing it down to make it more minimal.

Further Improvements?

I’m wondering if it’s a bit wasteful to fetch $allRoles everytime and if there is someway I can cache it (like set_transient does on WordPress). It won’t change very often.

There may be further ways to optimise the code and I’m open to suggestions.

Documentation Links

Moodle Documentation : Roles
Moodle Output Renderers
Moodle Documentation : Overriding a renderer

Moodle Theme Development : “Logged in as” Breadcrumbs

Moodle Theme Development : “Logged in as” Breadcrumbs

This post is part of a series documenting the work I did while developing a new Moodle Theme for Dumfries and Galloway College’s VLE. There will be further posts to follow. Watch this space!

As part of the design I wanted to display the “logged in as” information in a visually appealing way. For anyone not familiar with the log in as option, in Moodle certain user roles can preview courses as a different role e.g. as a Student. I settled on displaying it as a set of arrows across the top of the footer (see screenshot) rather than an un-styled block of text which was buried in the footer of the default theme boost.

Screenshot of the VLE footer

For the style of the breadcrumb arrows I reused my SVG clip-path Breadcrumb SCSS and HTML that I wrote earlier in the year for another project. See the link below for an overview of the SCSS.

Modifying the Footer Mustache File and Theme Renderers

To achieve this I needed to modify the mustache template file footer.mustache as well as write a renderer override in our theme. I based the new theme as a child version of the default Boost theme.

To override the footer.mustache file in boost I had to create a new footer.mustache file in our theme templates folder. This started out as a copy of the boost theme’s footer.mustache.

To figure out what renderer function I needed to find I ran a grep on my copy of moodle and discovered that the “Logged in as” information was being pulled into the footer.mustache file by the following line of code

{{{ output.login_info }}}

I tracked this down to coming from the login_info function in /lib/outputrenderers.php

/**
* Return the standard string that says whether you are logged in (and switched
* roles/logged in as another user).
* @param bool $withlinks if false, then don't include any links in the HTML produced.
* If not set, the default is the nologinlinks option from the theme config.php file,
* and if that is not set, then links are included.
* @return string HTML fragment.
*/
public function login_info($withlinks = null) {

    //...

}

This function also included a lot of information that also appeared in the footer which meant I needed to write an override renderer for login_info in our theme as well as an extra renderer to separate out the “Logged in as” information and display it separately.

I added the override and the new renderer to my theme renderers files ( theme_name/renderers.php ).

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

class theme_name_core_renderer extends core_renderer {

     /* ... */

}

The override of login_info was to start with a straight copy of the original function with the “Logged in as” part removed from it. To differentiate it from the original (and to make it easy to swap out if there were any issues) I renamed the override “footer_logininfo” and commented out any information I didn’t want to include in the footer itself. I also added in a few html elements to accommodate the breadcrumb styling. You may notice I have also changed some of the styling – this is due to another change to the footer that I may explain in a later post.

/**
     * Based on the login_info Function in /lib/outputrenderers.php 
     * The following changes have been made:
     *		$string['loggedinas'] changed in language package
			Logout link taken out of logged in link.
			Logged in as taken out to move into a new section
     *
     * Construct a user menu, returning HTML that can be echoed out by a
     * layout file.
     *
     * @param stdClass $user A user object, usually $USER.
     * @param bool $withlinks true if a dropdown should be built.
     * @return string HTML fragment.
     */
    public function footer_logininfo($withlinks = null) {
        global $USER, $CFG, $DB, $SESSION;

        if (during_initial_install()) {
            return '';
        }

        if (is_null($withlinks)) {
            $withlinks = empty($this->page->layout_options['nologinlinks']);
        }

        $course = $this->page->course;
        if (\core\session\manager::is_loggedinas()) {
            $realuser = \core\session\manager::get_realuser();
            $fullname = fullname($realuser, true);
            if ($withlinks) {
                $loginastitle = get_string('loginas');
                $realuserinfo = " [<a href=\"$CFG->wwwroot/course/loginas.php?id=$course->id&amp;sesskey=".sesskey()."\"";
                $realuserinfo .= "title =\"".$loginastitle."\">$fullname</a>] ";
                $realuserinfo = '';
            } else {
                $realuserinfo = " [$fullname] ";
                $realuserinfo = '';
            }
        } else {
            $realuserinfo = '';
        }

        $loginpage = $this->is_login_page();
        $loginurl = get_login_url();

        if (empty($course->id)) {
            // $course->id is not defined during installation
            return '';
        } else if (isloggedin()) {
            $context = context_course::instance($course->id);

            $fullname = fullname($USER, true);
            // Since Moodle 2.0 this link always goes to the public profile page (not the course profile page)
            if ($withlinks) {
                $linktitle = get_string('viewprofile');
                $username = "<a href=\"$CFG->wwwroot/user/profile.php?id=$USER->id\" title=\"$linktitle\"><span>$fullname</span></a>";
            } else {
                $username = $fullname;
            }
            
            if (is_mnet_remote_user($USER) and $idprovider = $DB->get_record('mnet_host', array('id'=>$USER->mnethostid))) {
                if ($withlinks) {
                    $username .= " from <a href=\"{$idprovider->wwwroot}\"><span>{$idprovider->name}</span></a>";
                } else {
                    $username .= " from {$idprovider->name}";
                }
            }
            
            if (isguestuser()) {
                $loggedinas = $realuserinfo.get_string('loggedinasguest');
                if (!$loginpage && $withlinks) {
                    $loggedinas .= " (<a href=\"$loginurl\">".get_string('login').'</a>)';
                }
            } else if (is_role_switched($course->id)) { // Has switched roles
                /*$rolename = '';
                if ($role = $DB->get_record('role', array('id'=>$USER->access['rsw'][$context->path]))) {
                    $rolename = ': '.role_get_name($role, $context);
                } */
                $loggedinas = get_string('loggedinas', 'moodle', $username).$rolename;
                /*if ($withlinks) {
                    $url = new moodle_url('/course/switchrole.php', array('id'=>$course->id,'sesskey'=>sesskey(), 'switchrole'=>0, 'returnurl'=>$this->page->url->out_as_local_url(false)));
                    $loggedinas .= ' ('.html_writer::tag('a', get_string('switchrolereturn'), array('href' => $url)).')';
                } */ // Going to move this to a seperate section.
            } else {
                $loggedinas = $realuserinfo.get_string('loggedinas', 'moodle', $username);
                /*if ($withlinks) {
                    $loggedinas .= " (<a href=\"$CFG->wwwroot/login/logout.php?sesskey=".sesskey()."\">".get_string('logout').'</a>)';
                } */
            }
        } else {
            $loggedinas = get_string('loggedinnot', 'moodle');
            if (!$loginpage && $withlinks) {
                $loggedinas .= " (<a href=\"$loginurl\">".get_string('login').'</a>)';
            }
        }

        //$loggedinas = '<div class="logininfo">'.$loggedinas.'</div>';

        if (isset($SESSION->justloggedin)) {
            unset($SESSION->justloggedin);
            if (!empty($CFG->displayloginfailures)) {
                if (!isguestuser()) {
                    // Include this file only when required.
                    require_once($CFG->dirroot . '/user/lib.php');
                    if ($count = user_count_login_failures($USER)) {
                        $loggedinas .= '<div class="loginfailures">';
                        $a = new stdClass();
                        $a->attempts = $count;
                        $loggedinas .= get_string('failedloginattempts', '', $a);
                        if (file_exists("$CFG->dirroot/report/log/index.php") and has_capability('report/log:view', context_system::instance())) {
                            $loggedinas .= ' ('.html_writer::link(new moodle_url('/report/log/index.php', array('chooselog' => 1,
                                    'id' => 0 , 'modid' => 'site_errors')), get_string('logs')).')';
                        }
                       $loggedinas .= '</div>';
                    }
                }
            }
        }

        return '<li>'.$loggedinas.'</li>';
    }

I then added a new renderer function called “footer_signedinas” which is where I moved the “logged in as” information to and added the necessary breadcrumb elements so I could use my clip path styling.

public function footer_signedinas($withlinks = null) {
        global $USER, $CFG, $DB, $SESSION;

        if (during_initial_install()) {
            return '';
        }
        
        if (is_null($withlinks)) {
            $withlinks = empty($this->page->layout_options['nologinlinks']);
        }
        
        if (\core\session\manager::is_loggedinas()) {
            $realuser = \core\session\manager::get_realuser();
            $fullname = fullname($realuser, true);
            if ($withlinks) {
                $loginastitle = get_string('loginas');
                $realuserinfo = '<li><a href="'.$CFG->wwwroot.'/course/loginas.php?id='.$course->id.'&amp;sesskey='.sesskey().'"';
                $realuserinfo .= ' title ="'.$loginastitle.'">'.$fullname.'</a></li>';
            } else {
                $realuserinfo = '<li><span>['.$fullname.']</span></li>';
            }
        } else {
            $realuserinfo = '';
        }
        
        $loggedinas = '';
		$course = $this->page->course; 
         
		if (isloggedin()) {
		
			$context = context_course::instance($course->id);
            $fullname = fullname($USER, true);
            //$linktitle = '';
            
            // Since Moodle 2.0 this link always goes to the public profile page (not the course profile page)
            if ($withlinks) {
                //$linktitle = get_string('viewprofile');
                $profilelink = $CFG->wwwroot.'/user/profile.php?id='.$USER->id;
                //$username = '<a href="'.$CFG->wwwroot.'/user/profile.php?id='.$USER->id.'" title="'.$linktitle.'">'.$fullname.'</a>';
                $username = $fullname;
            } else {
				
				$profilelink = '';
                $username = $fullname;
            }
            
            if (is_mnet_remote_user($USER) and $idprovider = $DB->get_record('mnet_host', array('id'=>$USER->mnethostid))) {
                if ($withlinks) {
					$profilelink = $idprovider->wwwroot;
                    $username .= " from {$idprovider->name}";
                } else {
					$profilelink = '';
                    $username .= " from {$idprovider->name}";
                }
            }
            
			if (is_role_switched($course->id)) { // Has switched roles
                $rolename = '';
                if ($role = $DB->get_record('role', array('id'=>$USER->access['rsw'][$context->path]))) {
                    $rolename = '<li><span>'.get_string('viewingasrole', 'theme_dgcollege2020', role_get_name($role, $context)).'</span></li>';
                    //viewingasrole
                }
                
                $loggedinas  = '<div class="switchedrole breadcrumb-wrapper bg-dorange-5" role="complementary" aria-label="Switch User Role Information">'.
									'<ul class="breadcrumbs">';
									
                $loggedinas .= (!empty($profilelink) ? '<li><a href="'.$profilelink.'">' : '<li><span>').get_string('loggedinas', 'moodle', $username).(!empty($profilelink) ? '</a></li>' : '</span></li>').$rolename;
                
                // signedinaslinktext
                
                if ($withlinks) {
                    $url = new moodle_url('/course/switchrole.php', array('id'=>$course->id,'sesskey'=>sesskey(), 'switchrole'=>0, 'returnurl'=>$this->page->url->out_as_local_url(false)));
                    $loggedinas .= '<li>'.html_writer::tag('a', get_string('switchrolereturn'), array('href' => $url, 'class' => '')).'</li>';
                } 
                
                $loggedinas .= '</ul></div>';
            } else if(\core\session\manager::is_loggedinas()) {
            
                $loggedinas  = '<div class="switchedrole bg-dorange-5" role="complementary" aria-label="Logged in as Information">'.
									'<ul class="breadcrumbs">';
                $loggedinas .= $realuserinfo.'<li><span>'.get_string('loggedinas', 'moodle', $username).'</span></li>';
                
                if ($withlinks) {
                    $loggedinas .= '<li><a href="'.$CFG->wwwroot.'/login/logout.php?sesskey='.sesskey().'">'.get_string('logout').'</a></li>';
                } 
                
                $loggedinas .= '</ul></div>';
            }
		}
		
		 return $loggedinas;
    
    }

Finally I needed to reference these new overrides in our theme’s footer.mustache file. This is done by adding {{{ output.function_name }}} this will fetch the output of those renderers.

{{{ output.footer_signedinas }}}
<footer id="page-footer" class="py-3 bg-dark text-light">
    <div class="container">
        /* ... */

        {{{ output.footer_logininfo }}}

       /* ... */
    </div>
</footer>

References

For more information on how to write a renderer override or modifying mustache template files check out these Moodle documentation pages.