From 8ed3671d4cd883bf13754234d339243ec2c4be57 Mon Sep 17 00:00:00 2001 From: Davo Smith Date: Thu, 7 Dec 2017 14:31:32 +0000 Subject: [PATCH] MDL-52167 admin: now able to show/hide settings based on conditions --- admin/settings.php | 7 + lib/adminlib.php | 112 +++++++++ lib/amd/build/showhidesettings.min.js | 1 + lib/amd/src/showhidesettings.js | 339 ++++++++++++++++++++++++++ 4 files changed, 459 insertions(+) create mode 100644 lib/amd/build/showhidesettings.min.js create mode 100644 lib/amd/src/showhidesettings.js diff --git a/admin/settings.php b/admin/settings.php index ecd24554855..0872e0375cc 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -156,4 +156,11 @@ $PAGE->requires->yui_module('moodle-core-formchangechecker', ); $PAGE->requires->string_for_js('changesmadereallygoaway', 'moodle'); +if ($settingspage->has_dependencies()) { + $opts = [ + 'dependencies' => $settingspage->get_dependencies_for_javascript() + ]; + $PAGE->requires->js_call_amd('core/showhidesettings', 'init', [$opts]); +} + echo $OUTPUT->footer(); diff --git a/lib/adminlib.php b/lib/adminlib.php index 877223577b1..22c805d7143 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -1317,6 +1317,84 @@ class admin_externalpage implements part_of_admin_tree { } } +/** + * Used to store details of the dependency between two settings elements. + * + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright 2017 Davo Smith, Synergy Learning + */ +class admin_settingdependency { + /** @var string the name of the setting to be shown/hidden */ + public $settingname; + /** @var string the setting this is dependent on */ + public $dependenton; + /** @var string the condition to show/hide the element */ + public $condition; + /** @var string the value to compare against */ + public $value; + + /** @var string[] list of valid conditions */ + private static $validconditions = ['checked', 'notchecked', 'noitemselected', 'eq', 'neq', 'in']; + + /** + * admin_settingdependency constructor. + * @param string $settingname + * @param string $dependenton + * @param string $condition + * @param string $value + * @throws \coding_exception + */ + public function __construct($settingname, $dependenton, $condition, $value) { + $this->settingname = $this->parse_name($settingname); + $this->dependenton = $this->parse_name($dependenton); + $this->condition = $condition; + $this->value = $value; + + if (!in_array($this->condition, self::$validconditions)) { + throw new coding_exception("Invalid condition '$condition'"); + } + } + + /** + * Convert the setting name into the form field name. + * @param string $name + * @return string + */ + private function parse_name($name) { + $bits = explode('/', $name); + $name = array_pop($bits); + $plugin = ''; + if ($bits) { + $plugin = array_pop($bits); + if ($plugin === 'moodle') { + $plugin = ''; + } + } + return 's_'.$plugin.'_'.$name; + } + + /** + * Gather together all the dependencies in a format suitable for initialising javascript + * @param admin_settingdependency[] $dependencies + * @return array + */ + public static function prepare_for_javascript($dependencies) { + $result = []; + foreach ($dependencies as $d) { + if (!isset($result[$d->dependenton])) { + $result[$d->dependenton] = []; + } + if (!isset($result[$d->dependenton][$d->condition])) { + $result[$d->dependenton][$d->condition] = []; + } + if (!isset($result[$d->dependenton][$d->condition][$d->value])) { + $result[$d->dependenton][$d->condition][$d->value] = []; + } + $result[$d->dependenton][$d->condition][$d->value][] = $d->settingname; + } + return $result; + } +} /** * Used to group a number of admin_setting objects into a page and add them to the admin tree. @@ -1334,6 +1412,9 @@ class admin_settingpage implements part_of_admin_tree { /** @var mixed An array of admin_setting objects that are part of this setting page. */ public $settings; + /** @var admin_settingdependency[] list of settings to hide when certain conditions are met */ + protected $dependencies = []; + /** @var string The role capability/permission a user must have to access this external page. */ public $req_capability; @@ -1463,6 +1544,18 @@ class admin_settingpage implements part_of_admin_tree { return true; } + /** + * Hide the named setting if the specified condition is matched. + * + * @param string $settingname + * @param string $dependenton + * @param string $condition + * @param string $value + */ + public function hide_if($settingname, $dependenton, $condition = 'notchecked', $value = '1') { + $this->dependencies[] = new admin_settingdependency($settingname, $dependenton, $condition, $value); + } + /** * see admin_externalpage * @@ -1521,6 +1614,25 @@ class admin_settingpage implements part_of_admin_tree { } return false; } + + /** + * Should any of the settings on this page be shown / hidden based on conditions? + * @return bool + */ + public function has_dependencies() { + return (bool)$this->dependencies; + } + + /** + * Format the setting show/hide conditions ready to initialise the page javascript + * @return array + */ + public function get_dependencies_for_javascript() { + if (!$this->has_dependencies()) { + return []; + } + return admin_settingdependency::prepare_for_javascript($this->dependencies); + } } diff --git a/lib/amd/build/showhidesettings.min.js b/lib/amd/build/showhidesettings.min.js new file mode 100644 index 00000000000..a0b75fc2e0a --- /dev/null +++ b/lib/amd/build/showhidesettings.min.js @@ -0,0 +1 @@ +define(["jquery"],function(a){function b(a){return a.is("input[type=hidden]")&&a.siblings('input[type=checkbox][name="'+a.attr("name")+'"]').length}function c(a,b){return a.is("input[type=radio]")&&a.attr("value")!==b}function d(a,d){return!b(a)&&!c(a,d)}function e(a){return a.is("input[type=radio]")&&!a.prop("checked")}function f(a){return a.is("input[type=checkbox]")&&!a.prop("checked")}function g(a){return a.is("select")&&a.prop("multiple")}function h(a,b){var c=a.val()||[];if(!b.length)return!1;if(c.length!==b.length)return!1;for(var d in c)if(c.hasOwnProperty(d)&&b.indexOf(c[d])===-1)return!1;return!0}function i(b){return a('[name="'+b+'"],[name="'+b+'[]"]')}function j(b){return a(b).attr("name").replace(/\[]/,"")}function k(b,c,d){return a.isFunction(o[c])?o[c](b,d):o.defaultCondition(b,d)}function l(b,c){c=c||j(b.currentTarget);var d=i(c);if(n.hasOwnProperty(c)){var e={};a.each(n[c],function(b,c){a.each(c,function(c,f){var g=k(d,b,c);a.each(f,function(a,b){e.hasOwnProperty(b)?e[b]=e[b]||g:e[b]=g})})}),a.each(e,function(b,c){i(b).each(function(b,d){var e=a(d).closest(".form-item");e.length&&(c?(e.attr("hidden","hidden"),e.css("display","none")):(e.removeAttr("hidden"),e.css("display","")))})})}}function m(){a.each(n,function(a){var b=i(a);b.length&&(b.on("change",l),l(null,a))})}var n,o={notchecked:function(b,c){var e=!1;return c=String(c),b.each(function(b,f){var g=a(f);d(g,c)&&(e=e||!g.prop("checked"))}),e},checked:function(b,c){var e=!1;return c=String(c),b.each(function(b,f){var g=a(f);d(g,c)&&(e=e||g.prop("checked"))}),e},noitemselected:function(b){var c=!1;return b.each(function(b,d){var e=a(d);c=c||e.prop("selectedIndex")===-1}),c},eq:function(c,d){var i=!1,j=!1;return d=String(d),c.each(function(c,k){var l=a(k);if(!e(l)){if(b(l))return void(j=l.val()===d);if(f(l))return void(i=i||j);if(g(l)){var m=d.split("|");return void(i=h(l,m))}i=i||l.val()===d}}),i},"in":function(c,d){var i=!1,j=!1,k=d.split("|");return c.each(function(c,d){var l=a(d);if(!e(l))return b(l)?void(j=k.indexOf(l.val())>-1):f(l)?void(i=i||j):g(l)?void(i=h(l,k)):void(i=i||k.indexOf(l.val())>-1)}),i},defaultCondition:function(c,d){var i=!1,j=!1;return d=String(d),c.each(function(c,k){var l=a(k);if(!e(l)){if(b(l))return void(j=l.val()!==d);if(f(l))return void(i=i||j);if(g(l)){var m=d.split("|");return void(i=!h(l,m))}i=i||l.val()!==d}}),i}};return{init:function(a){n=a.dependencies,m()}}}); \ No newline at end of file diff --git a/lib/amd/src/showhidesettings.js b/lib/amd/src/showhidesettings.js new file mode 100644 index 00000000000..dfbf921af30 --- /dev/null +++ b/lib/amd/src/showhidesettings.js @@ -0,0 +1,339 @@ +/** + * Show/hide admin settings based on other settings selected + * + * @package core + * @copyright 2018 Davo Smith, Synergy Learning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define(['jquery'], function($) { + var dependencies; + + // ------------------------------------------------- + // Support functions, used by dependency functions. + // ------------------------------------------------- + + /** + * Check to see if the given element is the hidden element that makes sure checkbox + * elements always submit a value. + * @param {jQuery} $el + * @returns {boolean} + */ + function isCheckboxHiddenElement($el) { + return ($el.is('input[type=hidden]') && $el.siblings('input[type=checkbox][name="' + $el.attr('name') + '"]').length); + } + + /** + * Check to see if this is a radio button with the wrong value (i.e. a radio button from + * the group we are interested in, but not the specific one we wanted). + * @param {jQuery} $el + * @param {string} value + * @returns {boolean} + */ + function isWrongRadioButton($el, value) { + return ($el.is('input[type=radio]') && $el.attr('value') !== value); + } + + /** + * Is this element relevant when we're looking for checked / not checked status? + * @param {jQuery} $el + * @param {string} value + * @returns {boolean} + */ + function isCheckedRelevant($el, value) { + return (!isCheckboxHiddenElement($el) && !isWrongRadioButton($el, value)); + } + + /** + * Is this an unchecked radio button? (If it is, we want to skip it, as + * we're only interested in the value of the radio button that is checked) + * @param {jQuery} $el + * @returns {boolean} + */ + function isUncheckedRadioButton($el) { + return ($el.is('input[type=radio]') && !$el.prop('checked')); + } + + /** + * Is this an unchecked checkbox? + * @param {jQuery} $el + * @returns {boolean} + */ + function isUncheckedCheckbox($el) { + return ($el.is('input[type=checkbox]') && !$el.prop('checked')); + } + + /** + * Is this a multi-select select element? + * @param {jQuery} $el + * @returns {boolean} + */ + function isMultiSelect($el) { + return ($el.is('select') && $el.prop('multiple')); + } + + /** + * Does the multi-select exactly match the list of values provided? + * @param {jQuery} $el + * @param {array} values + * @returns {boolean} + */ + function multiSelectMatches($el, values) { + var selected = $el.val() || []; + if (!values.length) { + // No values - nothing to match against. + return false; + } + if (selected.length !== values.length) { + // Different number of expected and actual values - cannot possibly be a match. + return false; + } + for (var i in selected) { + if (selected.hasOwnProperty(i)) { + if (values.indexOf(selected[i]) === -1) { + return false; // Found a non-matching value - give up immediately. + } + } + } + // Didn't find a non-matching value, so we have a match. + return true; + } + + // ------------------------------- + // Specific dependency functions. + // ------------------------------- + + var depFns = { + notchecked: function($dependon, value) { + var hide = false; + value = String(value); + $dependon.each(function(idx, el) { + var $el = $(el); + if (isCheckedRelevant($el, value)) { + hide = hide || !$el.prop('checked'); + } + }); + return hide; + }, + + checked: function($dependon, value) { + var hide = false; + value = String(value); + $dependon.each(function(idx, el) { + var $el = $(el); + if (isCheckedRelevant($el, value)) { + hide = hide || $el.prop('checked'); + } + }); + return hide; + }, + + noitemselected: function($dependon) { + var hide = false; + $dependon.each(function(idx, el) { + var $el = $(el); + hide = hide || ($el.prop('selectedIndex') === -1); + }); + return hide; + }, + + eq: function($dependon, value) { + var hide = false; + var hiddenVal = false; + value = String(value); + $dependon.each(function(idx, el) { + var $el = $(el); + if (isUncheckedRadioButton($el)) { + // For radio buttons, we're only interested in the one that is checked. + return; + } + if (isCheckboxHiddenElement($el)) { + // This is the hidden input that is part of the checkbox setting. + // We will use this value, if the associated checkbox is unchecked. + hiddenVal = ($el.val() === value); + return; + } + if (isUncheckedCheckbox($el)) { + // Checkbox is not checked - hide depends on the 'unchecked' value stored in + // the associated hidden element, which we have already found, above. + hide = hide || hiddenVal; + return; + } + if (isMultiSelect($el)) { + // Expect a list of values to match, separated by '|' - all of them must + // match the values selected. + var values = value.split('|'); + hide = multiSelectMatches($el, values); + return; + } + // All other element types - just compare the value directly. + hide = hide || ($el.val() === value); + }); + return hide; + }, + + 'in': function($dependon, value) { + var hide = false; + var hiddenVal = false; + var values = value.split('|'); + $dependon.each(function(idx, el) { + var $el = $(el); + if (isUncheckedRadioButton($el)) { + // For radio buttons, we're only interested in the one that is checked. + return; + } + if (isCheckboxHiddenElement($el)) { + // This is the hidden input that is part of the checkbox setting. + // We will use this value, if the associated checkbox is unchecked. + hiddenVal = (values.indexOf($el.val()) > -1); + return; + } + if (isUncheckedCheckbox($el)) { + // Checkbox is not checked - hide depends on the 'unchecked' value stored in + // the associated hidden element, which we have already found, above. + hide = hide || hiddenVal; + return; + } + if (isMultiSelect($el)) { + // For multiselect, we check to see if the list of values provided matches the list selected. + hide = multiSelectMatches($el, values); + return; + } + // All other element types - check to see if the value is in the list. + hide = hide || (values.indexOf($el.val()) > -1); + }); + return hide; + }, + + defaultCondition: function($dependon, value) { // Not equal. + var hide = false; + var hiddenVal = false; + value = String(value); + $dependon.each(function(idx, el) { + var $el = $(el); + if (isUncheckedRadioButton($el)) { + // For radio buttons, we're only interested in the one that is checked. + return; + } + if (isCheckboxHiddenElement($el)) { + // This is the hidden input that is part of the checkbox setting. + // We will use this value, if the associated checkbox is unchecked. + hiddenVal = ($el.val() !== value); + return; + } + if (isUncheckedCheckbox($el)) { + // Checkbox is not checked - hide depends on the 'unchecked' value stored in + // the associated hidden element, which we have already found, above. + hide = hide || hiddenVal; + return; + } + if (isMultiSelect($el)) { + // Expect a list of values to match, separated by '|' - all of them must + // match the values selected to *not* hide the element. + var values = value.split('|'); + hide = !multiSelectMatches($el, values); + return; + } + // All other element types - just compare the value directly. + hide = hide || ($el.val() !== value); + }); + return hide; + } + }; + + /** + * Find the element with the given name + * @param {String} name + * @returns {*|jQuery|HTMLElement} + */ + function getElementsByName(name) { + return $('[name="' + name + '"],[name="' + name + '[]"]'); + } + + /** + * Find the name of the given element + * @param {EventTarget} el + * @returns {String} + */ + function getElementName(el) { + return $(el).attr('name').replace(/\[]/, ''); + } + + /** + * Check to see whether a particular condition is met + * @param {*|jQuery|HTMLElement} $dependon + * @param {String} condition + * @param {mixed} value + * @returns {Boolean} + */ + function checkDependency($dependon, condition, value) { + if ($.isFunction(depFns[condition])) { + return depFns[condition]($dependon, value); + } + return depFns.defaultCondition($dependon, value); + } + + /** + * Show / hide the elements that depend on the element(s) with the given name + * OR (if no dependonname given) the element(s) with the same name as the element that + * triggered the event e. + * @param {Event} e + * @param {String} dependonname (optional) + */ + function updateDependencies(e, dependonname) { + dependonname = dependonname || getElementName(e.currentTarget); + var $dependon = getElementsByName(dependonname); + if (!dependencies.hasOwnProperty(dependonname)) { + return; + } + // Process all dependency conditions related to the updated element. + var toHide = {}; + $.each(dependencies[dependonname], function(condition, values) { + $.each(values, function(value, elements) { + var hide = checkDependency($dependon, condition, value); + $.each(elements, function(idx, elToHide) { + if (toHide.hasOwnProperty(elToHide)) { + toHide[elToHide] = toHide[elToHide] || hide; + } else { + toHide[elToHide] = hide; + } + }); + }); + }); + + // Update the hidden status of all relevant elements. + $.each(toHide, function(elToHide, hide) { + getElementsByName(elToHide).each(function(idx, el) { + var $parent = $(el).closest('.form-item'); + if ($parent.length) { + if (hide) { + $parent.attr('hidden', 'hidden'); + $parent.css('display', 'none'); + } else { + $parent.removeAttr('hidden'); + $parent.css('display', ''); + } + } + }); + }); + } + + /** + * Initialise the event handlers. + */ + function initHandlers() { + $.each(dependencies, function(depname) { + var $el = getElementsByName(depname); + if ($el.length) { + $el.on('change', updateDependencies); + updateDependencies(null, depname); + } + }); + } + + return { + init: function(opts) { + dependencies = opts.dependencies; + initHandlers(); + } + }; +}); \ No newline at end of file