// 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/>.
/**
* Manager for the accessreview block.
*
* @module block_accessreview/module
* @author Max Larkin <max@brickfieldlabs.ie>
* @copyright 2020 Brickfield Education Labs <max@brickfieldlabs.ie>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {call as fetchMany} from 'core/ajax';
import * as Templates from 'core/templates';
import {exception as displayError} from 'core/notification';
/**
* The number of colours used to represent the heatmap. (Indexed on 0.)
* @type {number}
*/
const numColours = 2;
/**
* The toggle state of the heatmap.
* @type {boolean}
*/
let toggleState = true;
/**
* Renders the HTML template onto a particular HTML element.
* @param {HTMLElement} element The element to attach the HTML to.
* @param {number} errorCount The number of errors on this module/section.
* @param {number} checkCount The number of checks triggered on this module/section.
* @param {String} displayFormat
* @param {Number} minViews
* @param {Number} viewDelta
* @returns {Promise}
*/
const renderTemplate = (element, errorCount, checkCount, displayFormat, minViews, viewDelta) => {
// Calculate a weight?
const weight = parseInt((errorCount - minViews) / viewDelta * numColours);
const context = {
resultPassed: !errorCount,
classList: '',
passRate: {
errorCount,
checkCount,
failureRate: Math.round(errorCount / checkCount * 100),
},
};
if (!element) {
return Promise.resolve();
}
const elementClassList = ['block_accessreview'];
if (context.resultPassed) {
elementClassList.push('block_accessreview_success');
} else if (weight) {
elementClassList.push('block_accessreview_danger');
} else {
elementClassList.push('block_accessreview_warning');
}
const showIcons = (displayFormat == 'showicons') || (displayFormat == 'showboth');
const showBackground = (displayFormat == 'showbackground') || (displayFormat == 'showboth');
if (showBackground && !showIcons) {
// Only the background is displayed.
// No need to display the template.
// Note: The case where both the background and icons are shown is handled later to avoid jankiness.
element.classList.add(...elementClassList, 'alert');
return Promise.resolve();
}
if (showIcons && !showBackground) {
context.classList = elementClassList.join(' ');
}
// The icons are displayed either with, or without, the background.
return Templates.renderForPromise('block_accessreview/status', context)
.then(({html, js}) => {
Templates.appendNodeContents(element, html, js);
if (showBackground) {
element.classList.add(...elementClassList, 'alert');
}
return;
})
.catch();
};
/**
* Applies the template to all sections and modules on the course page.
*
* @param {Number} courseId
* @param {String} displayFormat
* @param {Boolean} updatePreference
* @returns {Promise}
*/
const showAccessMap = (courseId, displayFormat, updatePreference = false) => {
// Get error data.
return Promise.all(fetchReviewData(courseId, updatePreference))
.then(([sectionData, moduleData]) => {
// Get total data.
const {minViews, viewDelta} = getErrorTotals(sectionData, moduleData);
sectionData.forEach(section => {
const element = document.querySelector(`#section-${section.section} .summary`);
if (!element) {
return;
}
renderTemplate(element, section.numerrors, section.numchecks, displayFormat, minViews, viewDelta);
});
moduleData.forEach(module => {
const element = document.getElementById(`module-${module.cmid}`);
if (!element) {
return;
}
renderTemplate(element, module.numerrors, module.numchecks, displayFormat, minViews, viewDelta);
});
// Change the icon display.
document.querySelector('.icon-accessmap').classList.remove(...['fa-eye-slash']);
document.querySelector('.icon-accessmap').classList.add(...['fa-eye']);
return {
sectionData,
moduleData,
};
})
.catch(displayError);
};
/**
* Hides or removes the templates from the HTML of the current page.
*
* @param {Boolean} updatePreference
*/
const hideAccessMap = (updatePreference = false) => {
// Removes the added elements.
document.querySelectorAll('.block_accessreview_view').forEach(node => node.remove());
const classList = [
'block_accessreview',
'block_accessreview_success',
'block_accessreview_warning',
'block_accessreview_danger',
'block_accessreview_view',
'alert',
];
// Removes the added classes.
document.querySelectorAll('.block_accessreview').forEach(node => node.classList.remove(...classList));
if (updatePreference) {
setToggleStatePreference(false);
}
// Change the icon display.
document.querySelector('.icon-accessmap').classList.remove(...['fa-eye']);
document.querySelector('.icon-accessmap').classList.add(...['fa-eye-slash']);
};
/**
* Toggles the heatmap on/off.
*
* @param {Number} courseId
* @param {String} displayFormat
*/
const toggleAccessMap = (courseId, displayFormat) => {
toggleState = !toggleState;
if (!toggleState) {
hideAccessMap(true);
} else {
showAccessMap(courseId, displayFormat, true);
}
};
/**
* Parses information on the errors, generating the min, max and totals.
*
* @param {Object[]} sectionData The error data for course sections.
* @param {Object[]} moduleData The error data for course modules.
* @returns {Object} An object representing the extra error information.
*/
const getErrorTotals = (sectionData, moduleData) => {
const totals = {
totalErrors: 0,
totalUsers: 0,
minViews: 0,
maxViews: 0,
viewDelta: 0,
};
[].concat(sectionData, moduleData).forEach(item => {
totals.totalErrors += item.numerrors;
if (item.numerrors < totals.minViews) {
totals.minViews = item.numerrors;
}
if (item.numerrors > totals.maxViews) {
totals.maxViews = item.numerrors;
}
totals.totalUsers += item.numchecks;
});
totals.viewDelta = totals.maxViews - totals.minViews + 1;
return totals;
};
const registerEventListeners = (courseId, displayFormat) => {
document.addEventListener('click', e => {
if (e.target.closest('#toggle-accessmap')) {
e.preventDefault();
toggleAccessMap(courseId, displayFormat);
}
});
};
/**
* Set the user preference for the toggle value.
*
* @param {Boolean} toggleState
* @returns {Promise}
*/
const getTogglePreferenceParams = toggleState => {
return {
methodname: 'core_user_update_user_preferences',
args: {
preferences: [{
type: 'block_accessreviewtogglestate',
value: toggleState,
}],
}
};
};
const setToggleStatePreference = toggleState => fetchMany([getTogglePreferenceParams(toggleState)]);
/**
* Fetch the review data.
*
* @param {Number} courseid
* @param {Boolean} updatePreference
* @returns {Promise[]}
*/
const fetchReviewData = (courseid, updatePreference = false) => {
const calls = [
{
methodname: 'block_accessreview_get_section_data',
args: {courseid}
},
{
methodname: 'block_accessreview_get_module_data',
args: {courseid}
},
];
if (updatePreference) {
calls.push(getTogglePreferenceParams(true));
}
return fetchMany(calls);
};
/**
* Setting up the access review module.
* @param {number} toggled A number represnting the state of the review toggle.
* @param {string} displayFormat A string representing the display format for icons.
* @param {number} courseId The course ID.
*/
export const init = (toggled, displayFormat, courseId) => {
// Settings consts.
toggleState = toggled == 1;
if (toggleState) {
showAccessMap(courseId, displayFormat);
}
registerEventListeners(courseId, displayFormat);
};