From 1aca8a1f00ab767ad79f7fb2b7d97534fbe6ba67 Mon Sep 17 00:00:00 2001 From: vietlx426 Date: Fri, 21 Mar 2025 15:57:53 +0700 Subject: [PATCH] MDL-74149 Usertours: Filters to exclude categories and courses --- .../usertours/amd/build/tour_filters.min.js | 3 + .../amd/build/tour_filters.min.js.map | 1 + admin/tool/usertours/amd/src/tour_filters.js | 117 ++++++++++++ .../usertours/classes/local/filter/base.php | 13 ++ .../classes/local/filter/category.php | 71 +++++--- .../usertours/classes/local/filter/course.php | 110 +++++++++--- .../classes/local/forms/edittour.php | 12 ++ admin/tool/usertours/classes/manager.php | 1 + .../tool/usertours/lang/en/tool_usertours.php | 26 ++- .../usertours/tests/behat/tour_filter.feature | 133 +++++++++++++- .../usertours/tests/category_filter_test.php | 168 ++++++++++++++++++ .../usertours/tests/course_filter_test.php | 118 ++++++++++++ 12 files changed, 725 insertions(+), 48 deletions(-) create mode 100644 admin/tool/usertours/amd/build/tour_filters.min.js create mode 100644 admin/tool/usertours/amd/build/tour_filters.min.js.map create mode 100755 admin/tool/usertours/amd/src/tour_filters.js mode change 100644 => 100755 admin/tool/usertours/classes/local/filter/category.php mode change 100644 => 100755 admin/tool/usertours/classes/local/filter/course.php mode change 100644 => 100755 admin/tool/usertours/classes/local/forms/edittour.php mode change 100644 => 100755 admin/tool/usertours/classes/manager.php mode change 100644 => 100755 admin/tool/usertours/lang/en/tool_usertours.php mode change 100644 => 100755 admin/tool/usertours/tests/behat/tour_filter.feature create mode 100755 admin/tool/usertours/tests/category_filter_test.php create mode 100755 admin/tool/usertours/tests/course_filter_test.php diff --git a/admin/tool/usertours/amd/build/tour_filters.min.js b/admin/tool/usertours/amd/build/tour_filters.min.js new file mode 100644 index 00000000000..796ba8226b9 --- /dev/null +++ b/admin/tool/usertours/amd/build/tour_filters.min.js @@ -0,0 +1,3 @@ +define("tool_usertours/tour_filters",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0;_exports.init=()=>{initConfigurationCategoryFilter()};const initConfigurationCategoryFilter=()=>{const categorySelect=document.querySelector("[name='filter_category[]']"),excludeSelect=document.querySelector("[name='filter_exclude_category[]']"),excludeCategoriesContainer=document.getElementById("fitem_id_filter_exclude_category");categorySelect&&excludeSelect&&(categorySelect.addEventListener("change",(()=>{updateExcludeCategories(categorySelect,excludeSelect,excludeCategoriesContainer)})),updateExcludeCategories(categorySelect,excludeSelect,excludeCategoriesContainer))},updateExcludeCategories=(categorySelect,excludeSelect,excludeCategoriesContainer)=>{const selectedCategories=new Set(Array.from(categorySelect.selectedOptions).map((option=>option.value))),excludeSelected=new Set(Array.from(excludeSelect.selectedOptions).map((option=>option.value))),excludeOptions=new Map,anySelected=selectedCategories.has("__ANYVALUE__");var select;Array.from(categorySelect.options).forEach((option=>{const isNotAny="__ANYVALUE__"!==option.value;if(anySelected&&isNotAny)excludeOptions.set(option.value,option.text);else if(isNotAny)for(const selected of selectedCategories){const selectedOption=categorySelect.querySelector('option[value="'.concat(selected,'"]'));if(option.text.startsWith("".concat(selectedOption.text," / "))){excludeOptions.set(option.value,option.text);break}}})),excludeOptions.size?(excludeSelect.innerHTML="",Array.from(excludeOptions).sort(((_ref,_ref2)=>{let[,a]=_ref,[,b]=_ref2;return a.localeCompare(b)})).forEach((_ref3=>{let[key,value]=_ref3;const option=document.createElement("option");option.value=key,option.text=value,excludeSelected.has(key)&&(option.selected=!0),excludeSelect.appendChild(option)})),(select=excludeSelect).size=Math.min(select.options.length||1,10),excludeCategoriesContainer.style.display="flex"):(excludeCategoriesContainer.style.display="none",excludeSelect.innerHTML="")}})); + +//# sourceMappingURL=tour_filters.min.js.map \ No newline at end of file diff --git a/admin/tool/usertours/amd/build/tour_filters.min.js.map b/admin/tool/usertours/amd/build/tour_filters.min.js.map new file mode 100644 index 00000000000..0fbe9b3aad0 --- /dev/null +++ b/admin/tool/usertours/amd/build/tour_filters.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"tour_filters.min.js","sources":["../src/tour_filters.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * User tours filters.\n *\n * @module tool_usertours/tour_filters\n * @copyright 2025 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nconst ANY_VALUE = \"__ANYVALUE__\";\n\nexport const init = () => {\n // Initialize the category filter\n initConfigurationCategoryFilter();\n};\n\n/**\n * Initialize the category filter for the configuration page.\n */\nconst initConfigurationCategoryFilter = () => {\n const categorySelect = document.querySelector(\"[name='filter_category[]']\");\n const excludeSelect = document.querySelector(\"[name='filter_exclude_category[]']\");\n const excludeCategoriesContainer = document.getElementById('fitem_id_filter_exclude_category');\n\n if (categorySelect && excludeSelect) {\n // Add event listeners to update the exclude categories when the include categories change.\n categorySelect.addEventListener(\"change\", () => {\n updateExcludeCategories(categorySelect, excludeSelect, excludeCategoriesContainer);\n });\n\n // Initialize the exclude categories based on the selected include categories.\n updateExcludeCategories(categorySelect, excludeSelect, excludeCategoriesContainer);\n }\n};\n\n/**\n * Adjust the height of a select element based on the number of options.\n *\n * @param {HTMLSelectElement} select\n */\nconst adjustHeight = (select) => {\n select.size = Math.min(select.options.length || 1, 10);\n};\n\n/**\n * Update the exclude categories based on the selected include categories.\n *\n * @param {HTMLSelectElement} categorySelect\n * @param {HTMLSelectElement} excludeSelect\n * @param {HTMLElement} excludeCategoriesContainer\n */\nconst updateExcludeCategories = (categorySelect, excludeSelect, excludeCategoriesContainer) => {\n // Get the selected categories and update the 'Any' option.\n const selectedCategories = new Set(Array.from(categorySelect.selectedOptions).map(option => option.value));\n\n // Get the selected exclude categories and create a map of options.\n const excludeSelected = new Set(Array.from(excludeSelect.selectedOptions).map(option => option.value));\n const excludeOptions = new Map();\n\n // Flag to check if 'Any' value is selected.\n const anySelected = selectedCategories.has(ANY_VALUE);\n Array.from(categorySelect.options).forEach(option => {\n const isNotAny = option.value !== ANY_VALUE;\n\n // If 'Any' is selected, include all options in excludeOptions.\n if (anySelected && isNotAny) {\n excludeOptions.set(option.value, option.text);\n } else if (isNotAny) {\n // Otherwise, check if the option is a child of any selected category.\n for (const selected of selectedCategories) {\n const selectedOption = categorySelect.querySelector(`option[value=\"${selected}\"]`);\n if (option.text.startsWith(`${selectedOption.text} / `)) {\n excludeOptions.set(option.value, option.text);\n break;\n }\n }\n }\n });\n if (excludeOptions.size) {\n // Update the exclude categories select element.\n excludeSelect.innerHTML = '';\n Array.from(excludeOptions)\n .sort(([, a], [, b]) => a.localeCompare(b))\n .forEach(([key, value]) => {\n const option = document.createElement(\"option\");\n option.value = key;\n option.text = value;\n if (excludeSelected.has(key)) {\n option.selected = true;\n }\n excludeSelect.appendChild(option);\n });\n\n // Adjust the height of the select elements.\n adjustHeight(excludeSelect);\n excludeCategoriesContainer.style.display = 'flex';\n } else {\n // Hide the exclude categories container when no child categories exist.\n excludeCategoriesContainer.style.display = 'none';\n // Clear selections to prevent submitting excluded categories when container is hidden.\n excludeSelect.innerHTML = '';\n }\n\n};\n"],"names":["initConfigurationCategoryFilter","categorySelect","document","querySelector","excludeSelect","excludeCategoriesContainer","getElementById","addEventListener","updateExcludeCategories","selectedCategories","Set","Array","from","selectedOptions","map","option","value","excludeSelected","excludeOptions","Map","anySelected","has","select","options","forEach","isNotAny","set","text","selected","selectedOption","startsWith","size","innerHTML","sort","a","b","localeCompare","_ref3","key","createElement","appendChild","Math","min","length","style","display"],"mappings":"gKAwBoB,KAEhBA,yCAMEA,gCAAkC,WAC9BC,eAAiBC,SAASC,cAAc,8BACxCC,cAAgBF,SAASC,cAAc,sCACvCE,2BAA6BH,SAASI,eAAe,oCAEvDL,gBAAkBG,gBAElBH,eAAeM,iBAAiB,UAAU,KACtCC,wBAAwBP,eAAgBG,cAAeC,+BAI3DG,wBAAwBP,eAAgBG,cAAeC,8BAoBzDG,wBAA0B,CAACP,eAAgBG,cAAeC,oCAEtDI,mBAAqB,IAAIC,IAAIC,MAAMC,KAAKX,eAAeY,iBAAiBC,KAAIC,QAAUA,OAAOC,SAG7FC,gBAAkB,IAAIP,IAAIC,MAAMC,KAAKR,cAAcS,iBAAiBC,KAAIC,QAAUA,OAAOC,SACzFE,eAAiB,IAAIC,IAGrBC,YAAcX,mBAAmBY,IAnDzB,gBA+BIC,IAAAA,OAqBlBX,MAAMC,KAAKX,eAAesB,SAASC,SAAQT,eACjCU,SArDI,iBAqDOV,OAAOC,SAGpBI,aAAeK,SACfP,eAAeQ,IAAIX,OAAOC,MAAOD,OAAOY,WACrC,GAAIF,aAEF,MAAMG,YAAYnB,mBAAoB,OACjCoB,eAAiB5B,eAAeE,sCAA+ByB,mBACjEb,OAAOY,KAAKG,qBAAcD,eAAeF,aAAY,CACrDT,eAAeQ,IAAIX,OAAOC,MAAOD,OAAOY,iBAMpDT,eAAea,MAEf3B,cAAc4B,UAAY,GAC1BrB,MAAMC,KAAKM,gBACNe,MAAK,oBAAIC,UAAOC,gBAAOD,EAAEE,cAAcD,MACvCX,SAAQa,YAAEC,IAAKtB,mBACND,OAASb,SAASqC,cAAc,UACtCxB,OAAOC,MAAQsB,IACfvB,OAAOY,KAAOX,MACVC,gBAAgBI,IAAIiB,OACpBvB,OAAOa,UAAW,GAEtBxB,cAAcoC,YAAYzB,YAlDpBO,OAsDDlB,eArDV2B,KAAOU,KAAKC,IAAIpB,OAAOC,QAAQoB,QAAU,EAAG,IAsD/CtC,2BAA2BuC,MAAMC,QAAU,SAG3CxC,2BAA2BuC,MAAMC,QAAU,OAE3CzC,cAAc4B,UAAY"} \ No newline at end of file diff --git a/admin/tool/usertours/amd/src/tour_filters.js b/admin/tool/usertours/amd/src/tour_filters.js new file mode 100755 index 00000000000..f6520b6f95c --- /dev/null +++ b/admin/tool/usertours/amd/src/tour_filters.js @@ -0,0 +1,117 @@ +// 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 . + +/** + * User tours filters. + * + * @module tool_usertours/tour_filters + * @copyright 2025 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +const ANY_VALUE = "__ANYVALUE__"; + +export const init = () => { + // Initialize the category filter + initConfigurationCategoryFilter(); +}; + +/** + * Initialize the category filter for the configuration page. + */ +const initConfigurationCategoryFilter = () => { + const categorySelect = document.querySelector("[name='filter_category[]']"); + const excludeSelect = document.querySelector("[name='filter_exclude_category[]']"); + const excludeCategoriesContainer = document.getElementById('fitem_id_filter_exclude_category'); + + if (categorySelect && excludeSelect) { + // Add event listeners to update the exclude categories when the include categories change. + categorySelect.addEventListener("change", () => { + updateExcludeCategories(categorySelect, excludeSelect, excludeCategoriesContainer); + }); + + // Initialize the exclude categories based on the selected include categories. + updateExcludeCategories(categorySelect, excludeSelect, excludeCategoriesContainer); + } +}; + +/** + * Adjust the height of a select element based on the number of options. + * + * @param {HTMLSelectElement} select + */ +const adjustHeight = (select) => { + select.size = Math.min(select.options.length || 1, 10); +}; + +/** + * Update the exclude categories based on the selected include categories. + * + * @param {HTMLSelectElement} categorySelect + * @param {HTMLSelectElement} excludeSelect + * @param {HTMLElement} excludeCategoriesContainer + */ +const updateExcludeCategories = (categorySelect, excludeSelect, excludeCategoriesContainer) => { + // Get the selected categories and update the 'Any' option. + const selectedCategories = new Set(Array.from(categorySelect.selectedOptions).map(option => option.value)); + + // Get the selected exclude categories and create a map of options. + const excludeSelected = new Set(Array.from(excludeSelect.selectedOptions).map(option => option.value)); + const excludeOptions = new Map(); + + // Flag to check if 'Any' value is selected. + const anySelected = selectedCategories.has(ANY_VALUE); + Array.from(categorySelect.options).forEach(option => { + const isNotAny = option.value !== ANY_VALUE; + + // If 'Any' is selected, include all options in excludeOptions. + if (anySelected && isNotAny) { + excludeOptions.set(option.value, option.text); + } else if (isNotAny) { + // Otherwise, check if the option is a child of any selected category. + for (const selected of selectedCategories) { + const selectedOption = categorySelect.querySelector(`option[value="${selected}"]`); + if (option.text.startsWith(`${selectedOption.text} / `)) { + excludeOptions.set(option.value, option.text); + break; + } + } + } + }); + if (excludeOptions.size) { + // Update the exclude categories select element. + excludeSelect.innerHTML = ''; + Array.from(excludeOptions) + .sort(([, a], [, b]) => a.localeCompare(b)) + .forEach(([key, value]) => { + const option = document.createElement("option"); + option.value = key; + option.text = value; + if (excludeSelected.has(key)) { + option.selected = true; + } + excludeSelect.appendChild(option); + }); + + // Adjust the height of the select elements. + adjustHeight(excludeSelect); + excludeCategoriesContainer.style.display = 'flex'; + } else { + // Hide the exclude categories container when no child categories exist. + excludeCategoriesContainer.style.display = 'none'; + // Clear selections to prevent submitting excluded categories when container is hidden. + excludeSelect.innerHTML = ''; + } + +}; diff --git a/admin/tool/usertours/classes/local/filter/base.php b/admin/tool/usertours/classes/local/filter/base.php index fc73c58dedd..7f6e98d2c38 100644 --- a/admin/tool/usertours/classes/local/filter/base.php +++ b/admin/tool/usertours/classes/local/filter/base.php @@ -123,4 +123,17 @@ abstract class base { $tour->set_filter_values($filtername, $newvalue); } + + /** + * Default validation for filter forms. + * Returns an empty array by default if not overridden. + * + * @param array $data The submitted form data. + * @param array $files The files submitted with the form. + * @return array The errors array. + */ + public static function validate_form(array $data, array $files): array { + // Default implementation, returns no errors. + return []; + } } diff --git a/admin/tool/usertours/classes/local/filter/category.php b/admin/tool/usertours/classes/local/filter/category.php old mode 100644 new mode 100755 index fd5566d998c..3a4eaabe188 --- a/admin/tool/usertours/classes/local/filter/category.php +++ b/admin/tool/usertours/classes/local/filter/category.php @@ -28,42 +28,48 @@ use context; */ class category extends base { /** - * The name of the filter. - * - * @return string + * The exclude key constant. */ + public const EXCLUDE_KEY = 'exclude_category'; + + #[\Override] public static function get_filter_name() { return 'category'; } - /** - * Retrieve the list of available filter options. - * - * @return array An array whose keys are the valid options - * And whose values are the values to display - */ + #[\Override] public static function get_filter_options() { $options = \core_course_category::make_categories_list(); return $options; } - /** - * Check whether the filter matches the specified tour and/or context. - * - * @param tour $tour The tour to check - * @param context $context The context to check - * @return boolean - */ + #[\Override] + public static function add_filter_to_form(\MoodleQuickForm &$mform) { + parent::add_filter_to_form($mform); + $excludekey = 'filter_' . self::EXCLUDE_KEY; + $mform->addElement( + 'select', + $excludekey, + get_string($excludekey, 'tool_usertours'), + static::get_filter_options(), + ['multiple' => true] + ); + $mform->addHelpButton($excludekey, $excludekey, 'tool_usertours'); + } + + #[\Override] public static function filter_matches(tour $tour, context $context) { - $values = $tour->get_filter_values(self::get_filter_name()); - if (empty($values) || empty($values[0])) { - // There are no values configured, meaning all. - return true; + $includevalues = $tour->get_filter_values(static::get_filter_name()); + $excludevalues = $tour->get_filter_values(self::EXCLUDE_KEY); + + if (empty($includevalues) || empty($includevalues[0])) { + return !static::check_contexts($context, $excludevalues); } + if ($context->contextlevel < CONTEXT_COURSECAT) { return false; } - return self::check_contexts($context, $values); + return self::check_contexts($context, $includevalues) && !self::check_contexts($context, $excludevalues); } /** @@ -73,7 +79,11 @@ class category extends base { * @param array $values * @return boolean */ - private static function check_contexts(context $context, $values) { + private static function check_contexts(context $context, array $values): bool { + if (empty($values)) { + return false; + } + if ($context->contextlevel > CONTEXT_COURSECAT) { return self::check_contexts($context->get_parent_context(), $values); } else if ($context->contextlevel == CONTEXT_COURSECAT) { @@ -86,4 +96,21 @@ class category extends base { return false; } } + + #[\Override] + public static function prepare_filter_values_for_form(tour $tour, \stdClass $data) { + parent::prepare_filter_values_for_form($tour, $data); + $excludekey = 'filter_' . self::EXCLUDE_KEY; + $data->$excludekey = $tour->get_filter_values(self::EXCLUDE_KEY); + + return $data; + } + + #[\Override] + public static function save_filter_values_from_form(tour $tour, \stdClass $data) { + parent::save_filter_values_from_form($tour, $data); + $excludekey = 'filter_' . self::EXCLUDE_KEY; + $excludevalues = $data->$excludekey; + $tour->set_filter_values(self::EXCLUDE_KEY, $excludevalues); + } } diff --git a/admin/tool/usertours/classes/local/filter/course.php b/admin/tool/usertours/classes/local/filter/course.php old mode 100644 new mode 100755 index a440bc0598c..a0adfcefc1c --- a/admin/tool/usertours/classes/local/filter/course.php +++ b/admin/tool/usertours/classes/local/filter/course.php @@ -27,12 +27,23 @@ use context; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course extends base { + /** @var string Option to select all courses. */ + public const OPERATOR_ALL = 'all'; + /** @var string Option to select specific courses. */ + public const OPERATOR_SELECT = 'select'; + /** @var string Option to select all courses except specific courses. */ + public const OPERATOR_EXCEPT = 'except'; + /** @var string The filter operator key constant. */ + public const OPERATOR_KEY = 'course_operator'; + /** @var string The filter key constant. */ + public const FILTER_KEY = 'filter_course'; + /** * The name of the filter. * * @return string */ - public static function get_filter_name() { + public static function get_filter_name(): string { return 'course'; } @@ -42,14 +53,37 @@ class course extends base { * @param \MoodleQuickForm $mform */ public static function add_filter_to_form(\MoodleQuickForm &$mform) { + // Add the operator selector. + $operatorkey = 'filter_' . self::OPERATOR_KEY; + $mform->addElement('select', $operatorkey, get_string($operatorkey, 'tool_usertours'), static::get_operator_options()); + $mform->setDefault($operatorkey, static::OPERATOR_ALL); + $mform->addHelpButton($operatorkey, $operatorkey, 'tool_usertours'); + + // Add the course selector. + $key = self::FILTER_KEY; $options = ['multiple' => true]; - - $filtername = self::get_filter_name(); - $key = "filter_{$filtername}"; - - $mform->addElement('course', $key, get_string($key, 'tool_usertours'), $options); + $mform->addElement("course", $key, get_string($key, 'tool_usertours'), $options); $mform->setDefault($key, '0'); $mform->addHelpButton($key, $key, 'tool_usertours'); + $mform->hideIf($key, $operatorkey, 'eq', self::OPERATOR_ALL); + } + + /** + * Validate form data specific to the course filter. + * + * @param array $data The current form data. + * @param array $files The current form files. + * @return array Any validation errors for this filter. + */ + public static function validate_form(array $data, array $files): array { + $errors = []; + $key = static::FILTER_KEY; + $operatorkey = 'filter_' . self::OPERATOR_KEY; + if ($data[$operatorkey] !== static::OPERATOR_ALL && empty($data[$key])) { + $errors[$key] = get_string('filter_course_error_course_selection', 'tool_usertours'); + } + + return $errors; } /** @@ -59,17 +93,24 @@ class course extends base { * @param context $context The context to check * @return boolean */ - public static function filter_matches(tour $tour, context $context) { + public static function filter_matches(tour $tour, context $context): bool { global $COURSE; - $values = $tour->get_filter_values(self::get_filter_name()); + $values = $tour->get_filter_values(static::get_filter_name()); + $operator = $tour->get_filter_values(static::OPERATOR_KEY)[0] ?? static::OPERATOR_ALL; + if (empty($values) || empty($values[0])) { - // There are no values configured, meaning all. return true; } + if (empty($COURSE->id)) { return false; } - return in_array($COURSE->id, $values); + + return match ($operator) { + static::OPERATOR_SELECT => in_array($COURSE->id, $values), + static::OPERATOR_EXCEPT => !in_array($COURSE->id, $values), + default => true, + }; } /** @@ -80,13 +121,18 @@ class course extends base { * @return stdClass */ public static function prepare_filter_values_for_form(tour $tour, \stdClass $data) { + // Prepare the operator value. + $operatorfiltername = static::OPERATOR_KEY; + $operatorkey = 'filter_' . $operatorfiltername; + $operator = $tour->get_filter_values($operatorfiltername)[0] ?? static::OPERATOR_ALL; + $data->$operatorkey = $operator; + + // Prepare the course value. $filtername = static::get_filter_name(); - $key = "filter_{$filtername}"; - $values = $tour->get_filter_values($filtername); - if (empty($values)) { - $values = 0; - } - $data->$key = $values; + $key = 'filter_' . $filtername; + $values = $tour->get_filter_values($filtername) ?: 0; + $data->$key = $data->$operatorkey === static::OPERATOR_ALL ? 0 : $values; + return $data; } @@ -96,13 +142,37 @@ class course extends base { * @param tour $tour The tour to save values to * @param stdClass $data The data submitted in the form */ - public static function save_filter_values_from_form(tour $tour, \stdClass $data) { + public static function save_filter_values_from_form( + tour $tour, + \stdClass $data, + ) { + $operatorfiltername = static::OPERATOR_KEY; + $operatorkey = 'filter_' . $operatorfiltername; + $tour->set_filter_values($operatorfiltername, [$data->$operatorkey]); $filtername = static::get_filter_name(); - $key = "filter_{$filtername}"; - $newvalue = $data->$key; - if (empty($data->$key)) { + if ($data->$operatorkey === static::OPERATOR_ALL) { $newvalue = []; + } else { + $key = 'filter_' . $filtername; + $newvalue = $data->$key; + if (empty($data->$key)) { + $newvalue = []; + } } $tour->set_filter_values($filtername, $newvalue); } + + /** + * Retrieve the available operator options. + * + * @return string[] The available operator options. + */ + public static function get_operator_options(): array { + $operatorkey = 'filter_' . self::OPERATOR_KEY; + return [ + static::OPERATOR_ALL => get_string($operatorkey . '_' . static::OPERATOR_ALL, 'tool_usertours'), + static::OPERATOR_SELECT => get_string($operatorkey . '_' . static::OPERATOR_SELECT, 'tool_usertours'), + static::OPERATOR_EXCEPT => get_string($operatorkey . '_' . static::OPERATOR_EXCEPT, 'tool_usertours'), + ]; + } } diff --git a/admin/tool/usertours/classes/local/forms/edittour.php b/admin/tool/usertours/classes/local/forms/edittour.php old mode 100644 new mode 100755 index cf77608c303..58eb280cded --- a/admin/tool/usertours/classes/local/forms/edittour.php +++ b/admin/tool/usertours/classes/local/forms/edittour.php @@ -113,4 +113,16 @@ class edittour extends \moodleform { $this->add_action_buttons(); } + + #[\Override] + public function validation($data, $files): array { + $errors = parent::validation($data, $files); + + // Loop through each filter class and merge any validation errors. + foreach (helper::get_all_filters() as $filterclass) { + $errors = array_merge($errors, $filterclass::validate_form($data, $files)); + } + + return $errors; + } } diff --git a/admin/tool/usertours/classes/manager.php b/admin/tool/usertours/classes/manager.php old mode 100644 new mode 100755 index cd1cfb5d2b1..65b69b57b26 --- a/admin/tool/usertours/classes/manager.php +++ b/admin/tool/usertours/classes/manager.php @@ -398,6 +398,7 @@ class manager { } $form->display(); + $PAGE->requires->js_call_amd('tool_usertours/tour_filters', 'init'); $this->footer(); } } diff --git a/admin/tool/usertours/lang/en/tool_usertours.php b/admin/tool/usertours/lang/en/tool_usertours.php old mode 100644 new mode 100755 index 12e15dbac65..38cac8b8f80 --- a/admin/tool/usertours/lang/en/tool_usertours.php +++ b/admin/tool/usertours/lang/en/tool_usertours.php @@ -73,13 +73,33 @@ $string['filter_accessdate'] = 'Access date'; $string['filter_accessdate_enabled'] = 'Enable access date filter'; $string['filter_accessdate_enabled_help'] = 'Only show the tour to new users or users who have accessed the site recently.'; $string['filter_category'] = 'Category'; -$string['filter_category_help'] = 'Show the tour on a page that is associated with a course in the selected category.'; -$string['filter_course'] = 'Courses'; -$string['filter_course_help'] = 'Show the tour on a page that is associated with the selected course.'; +$string['filter_category_help'] = "Show this tour on pages associated with courses in the selected categories."; +$string['filter_course'] = 'Selected courses'; +$string['filter_course_error_course_selection'] = 'You must select at least one course'; +$string['filter_course_help'] = 'Depending on the value of the Courses filter: + +* **Selected courses only**: Show this tour on pages associated with the selected courses. +* **All courses except selected**: Do not show this tour on pages associated with the selected courses.'; +$string['filter_course_operator'] = 'Courses'; +$string['filter_course_operator_all'] = 'All courses'; +$string['filter_course_operator_except'] = 'All courses except selected'; +$string['filter_course_operator_help'] = " +The tour can be configured to appear only on certain courses. + +* **All courses**: Show this tour regardless of course. +* **Selected courses only**: Show this tour only on pages associated with the course(s) selected below. +* **All courses except selected**: Do not show this tour on pages associated with the course(s) selected below."; +$string['filter_course_operator_select'] = 'Selected courses only'; $string['filter_courseformat'] = 'Course format'; $string['filter_courseformat_help'] = 'Show the tour on a page that is associated with a course using the selected course format.'; $string['filter_cssselector'] = 'CSS selector'; $string['filter_cssselector_help'] = 'Only show the tour when the specified CSS selector is found on the page.'; +$string['filter_exclude_category'] = 'Exclude categories'; +$string['filter_exclude_category_help'] = 'Do not show this tour on pages associated with the selected course categories. + +If you select a parent category, the related sub-categories will automatically be selected and cannot be selected individually. + +Unselect the parent category to select sub-categories again.'; $string['filter_header'] = 'Tour filters'; $string['filter_help'] = 'Select the conditions under which the tour will be shown. All of the filters must match for a tour to be shown to a user.'; $string['filter_date_account_creation'] = 'User account creation date within'; diff --git a/admin/tool/usertours/tests/behat/tour_filter.feature b/admin/tool/usertours/tests/behat/tour_filter.feature old mode 100644 new mode 100755 index 0ec7d15944b..5b300da474a --- a/admin/tool/usertours/tests/behat/tour_filter.feature +++ b/admin/tool/usertours/tests/behat/tour_filter.feature @@ -74,11 +74,60 @@ Feature: Apply tour filters to a tour And I log in as "student1" When I am on "Course 1" course homepage And I wait until the page is ready - Then I should see "Welcome to your course tour." + And I should see "Welcome to your course tour." When I am on "Course 2" course homepage And I wait until the page is ready Then I should see "Welcome to your course tour." + @javascript + Scenario: Add tour for multiple categories and exclude category + Given the following "categories" exist: + | name | category | idnumber | + | MainCat | 0 | CAT1 | + | SubCat | CAT1 | CAT2 | + | SubCat2 | CAT1 | CAT3 | + | MainCat2| 0 | CAT4 | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | CAT1 | + | Course 2 | C2 | CAT2 | + | Course 3 | C3 | CAT3 | + | Course 4 | C4 | CAT4 | + And the following "users" exist: + | username | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + | student1 | C3 | student | + | student1 | C4 | student | + And I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /course/view.php% | + | Tour is enabled | 1 | + | Category | MainCat,MainCat2 | + | Exclude categories | MainCat / SubCat2 | + And I add steps to the "First tour" tour: + | targettype | Title | id_content | Content type | + | Display in middle of page | Welcome | Welcome to your course tour. | Manual | + And I log out + And I log in as "student1" + And I am on "Course 1" course homepage + When I wait until the page is ready + Then I should see "Welcome to your course tour." + And I am on "Course 2" course homepage + And I wait until the page is ready + And I should see "Welcome to your course tour." + And I am on "Course 3" course homepage + And I wait until the page is ready + And I should not see "Welcome to your course tour." + And I am on "Course 4" course homepage + And I wait until the page is ready + And I should see "Welcome to your course tour." + @javascript Scenario: Add tour for a specific courseformat Given the following "courses" exist: @@ -111,6 +160,38 @@ Feature: Apply tour filters to a tour And I wait until the page is ready Then I should see "Welcome to your course tour." + @javascript + Scenario: Add tour for a specific course with all courses filter + Given the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + | Course 2 | C2 | weeks | + And the following "users" exist: + | username | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + And I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /course/view.php% | + | Tour is enabled | 1 | + | Courses | All courses | + And I add steps to the "First tour" tour: + | targettype | Title | id_content | Content type | + | Display in middle of page | Welcome | Welcome to your course tour. | Manual | + And I log out + And I log in as "student1" + And I am on "Course 1" course homepage + And I wait until the page is ready + And I should see "Welcome to your course tour." + When I am on "Course 2" course homepage + And I wait until the page is ready + Then I should see "Welcome to your course tour." + @javascript Scenario: Add tour for a specific course Given the following "courses" exist: @@ -130,10 +211,11 @@ Feature: Apply tour filters to a tour | Description | My first tour | | Apply to URL match | /course/view.php% | | Tour is enabled | 1 | - | Courses | C1 | + | Courses | Selected courses | + | Selected courses | C1 | And I add steps to the "First tour" tour: | targettype | Title | id_content | Content type | - | Display in middle of page | Welcome | Welcome to your course tour. | Manual | + | Display in middle of page | Welcome | Welcome to your course tour. | Manual | And I log out And I log in as "student1" When I am on "Course 1" course homepage @@ -143,6 +225,39 @@ Feature: Apply tour filters to a tour And I wait until the page is ready Then I should not see "Welcome to your course tour." + @javascript + Scenario: Add tour for a excluded course + Given the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + | Course 2 | C2 | weeks | + And the following "users" exist: + | username | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + And I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /course/view.php% | + | Tour is enabled | 1 | + | Courses | All courses except selected | + | Selected courses | C1 | + And I add steps to the "First tour" tour: + | targettype | Title | id_content | Content type | + | Display in middle of page | Welcome | Welcome to your course tour. | Manual | + And I log out + And I log in as "student1" + And I am on "Course 1" course homepage + And I wait until the page is ready + And I should not see "Welcome to your course tour." + When I am on "Course 2" course homepage + And I wait until the page is ready + Then I should see "Welcome to your course tour." + @javascript Scenario: Add tours with CSS selectors Given the following "users" exist: @@ -220,3 +335,15 @@ Feature: Apply tour filters to a tour And I click on "Move tour down" "link" in the "The first tour" "table_row" And I am on homepage Then I should see "Welcome to the Third tour" + + @javascript + Scenario: Show or hide the Exclude Categories option if the selected categories have no child categories + Given I log in as "admin" + And I open the User tour settings page + When I click on "Create a new tour" "link" + Then "Exclude categories" "select" should be visible + And I should see "Category 1" in the "Exclude categories" "select" + And I select "Category 1" from the "Category" singleselect + And "Exclude categories" "select" should not be visible + And I select "All" from the "Category" singleselect + And I should see "Category 1" in the "Exclude categories" "select" diff --git a/admin/tool/usertours/tests/category_filter_test.php b/admin/tool/usertours/tests/category_filter_test.php new file mode 100755 index 00000000000..1da8257ff51 --- /dev/null +++ b/admin/tool/usertours/tests/category_filter_test.php @@ -0,0 +1,168 @@ +. + +namespace tool_usertours; + +use context_course; +use context_coursecat; +use context_system; +use tool_usertours\local\filter\category; +use context; + +/** + * Tests for category filter. + * + * @package tool_usertours + * @copyright 2025 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \tool_usertours\local\filter\category + */ +final class category_filter_test extends \advanced_testcase { + /** @var \core_course_category */ + private \core_course_category $category1; + /** @var \core_course_category */ + private \core_course_category $category2; + /** @var \core_course_category */ + private \core_course_category $childcategory1; + /** @var \core_course_category */ + private \core_course_category $childcategory2; + /** @var \stdClass */ + private \stdClass $course1; + /** @var \stdClass */ + private \stdClass $course2; + /** @var \stdClass */ + private \stdClass $coursechild1; + + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + + // Create parent categories. + $this->category1 = $this->getDataGenerator()->create_category(); + $this->category2 = $this->getDataGenerator()->create_category(); + + // Create child categories. + $this->childcategory1 = $this->getDataGenerator()->create_category(['parent' => $this->category1->id]); + $this->childcategory2 = $this->getDataGenerator()->create_category(['parent' => $this->category2->id]); + + // Create courses. + $this->course1 = $this->getDataGenerator()->create_course(['category' => $this->category1->id]); + $this->course2 = $this->getDataGenerator()->create_course(['category' => $this->category2->id]); + $this->coursechild1 = $this->getDataGenerator()->create_course(['category' => $this->childcategory1->id]); + } + + /** + * Data provider for test_filter_matches. + * + * @return array + */ + public static function filter_matches_provider(): array { + return [ + 'Parent category excluded, child category context' => [ + ['exclude' => ['{{CATEGORY1_ID}}']], + 'category:{{CHILD_CATEGORY1_ID}}', + false, + ], + ]; + } + + /** + * Test the filter_matches method. + * + * @dataProvider filter_matches_provider + * @param array $tourconfig Tour configuration + * @param string $contextinfo Context information + * @param bool $expected Expected result + */ + public function test_filter_matches(array $tourconfig, string $contextinfo, bool $expected): void { + $this->resetAfterTest(); + + // Replace placeholder IDs with actual IDs. + $tourconfig = $this->replace_ids($tourconfig); + $contextinfo = $this->replace_ids($contextinfo); + + $context = $this->create_context_from_string($contextinfo); + + $tour = new tour(); + $tour->set_filter_values('category', $tourconfig['include'] ?? []); + $tour->set_filter_values('exclude_category', $tourconfig['exclude'] ?? []); + + $result = category::filter_matches($tour, $context); + $this->assertEquals($expected, $result); + } + + /** + * Test the get_filter_name method. + */ + public function test_get_filter_name(): void { + $this->assertEquals('category', category::get_filter_name()); + } + + /** + * Test the get_filter_options method. + */ + public function test_get_filter_options(): void { + $options = category::get_filter_options(); + + $this->assertIsArray($options); + $this->assertArrayHasKey($this->category1->id, $options); + $this->assertArrayHasKey($this->category2->id, $options); + $this->assertArrayHasKey($this->childcategory1->id, $options); + $this->assertArrayHasKey($this->childcategory2->id, $options); + } + + /** + * Create a context object from a string. + * + * @param string $contextinfo The context information. + * @return context The context object. + */ + private function create_context_from_string(string $contextinfo): context { + $parts = explode(':', $contextinfo); + $contextlevel = $parts[0]; + $instanceid = isset($parts[1]) && is_numeric($parts[1]) ? (int)$parts[1] : 0; + + return match ($contextlevel) { + 'system' => context_system::instance(), + 'category' => context_coursecat::instance($instanceid), + 'course' => context_course::instance($instanceid) + }; + } + + /** + * Replace placeholder IDs with actual IDs. + * + * @param mixed $data The data to process. + * @return mixed The processed data. + */ + private function replace_ids($data) { + if (is_array($data)) { + return array_map([$this, 'replace_ids'], $data); + } else if (is_string($data)) { + $replacements = [ + '{{CATEGORY1_ID}}' => $this->category1->id, + '{{CATEGORY2_ID}}' => $this->category2->id, + '{{CHILD_CATEGORY1_ID}}' => $this->childcategory1->id, + '{{CHILD_CATEGORY2_ID}}' => $this->childcategory2->id, + '{{COURSE1_ID}}' => $this->course1->id, + '{{COURSE2_ID}}' => $this->course2->id, + '{{COURSE_CHILD1_ID}}' => $this->coursechild1->id, + ]; + return strtr($data, $replacements); + } + return $data; + } +} diff --git a/admin/tool/usertours/tests/course_filter_test.php b/admin/tool/usertours/tests/course_filter_test.php new file mode 100755 index 00000000000..4f78049a8f1 --- /dev/null +++ b/admin/tool/usertours/tests/course_filter_test.php @@ -0,0 +1,118 @@ +. + +namespace tool_usertours; + +use tool_usertours\local\filter\course; + +/** + * Tests for course filter. + * + * @package tool_usertours + * @copyright 2025 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \tool_usertours\local\filter\course + */ +final class course_filter_test extends \advanced_testcase { + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + } + + /** + * Data Provider for filter_matches method. + * + * @return array + */ + public static function filter_matches_provider(): array { + return [ + 'No filter set; Matches' => [ + 'all', + true, + ], + 'Select specific courses; Match' => [ + 'select', + true, + ], + 'Select specific courses; No match' => [ + 'select', + false, + ], + 'Except specific courses; Match' => [ + 'except', + true, + ], + 'Except specific courses; No match' => [ + 'except', + false, + ], + ]; + } + + /** + * Test filter matches. + * + * @dataProvider filter_matches_provider + * + * @param string $operator the filter operator. + * @param bool $expected result expected. + */ + public function test_filter_matches(string $operator, bool $expected): void { + global $COURSE; + + // Create courses for testing. + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $course3 = $this->getDataGenerator()->create_course(); + + // Set global $COURSE variable to the first course created. + $COURSE = $course1; + + $tour = new tour(); + if ($operator === course::OPERATOR_SELECT) { + // Test case for selecting specific courses. + $tour->set_filter_values('course', $expected ? [$course1->id, $course2->id] : [$course2->id, $course3->id]); + } else if ($operator === course::OPERATOR_EXCEPT) { + // Test case for excluding specific courses. + $tour->set_filter_values('course', $expected ? [$course2->id, $course3->id] : [$course1->id, $course2->id]); + } + + $tour->set_filter_values('course_operator', [$operator]); + + $context = \context_course::instance($COURSE->id); + + $this->assertEquals($expected, course::filter_matches($tour, $context)); + } + + /** + * Test validating course selection. + */ + public function test_validate_form(): void { + $fields = [ + 'filter_course_operator' => course::OPERATOR_SELECT, + 'filter_course' => [], + ]; + $errors = []; + + $errors = course::validate_form($fields, $errors); + + $this->assertArrayHasKey('filter_course', $errors); + $this->assertEquals( + get_string('filter_course_error_course_selection', 'tool_usertours'), + $errors['filter_course'] + ); + } +}