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']
+ );
+ }
+}