From dd94dea8c28525f0fb049e1bb819ab6ae4752a03 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Tue, 27 Feb 2024 12:22:39 +0800 Subject: [PATCH] MDL-81063 tool_usertours: Allow hooks to manage usertour filters Two new hooks are introduced to allow plugins to add their own user tour filters. Separate hooks are identified for both clientside, and serverside filters. --- admin/tool/usertours/amd/build/usertours.min.js | 6 +- .../tool/usertours/amd/build/usertours.min.js.map | 2 +- admin/tool/usertours/amd/src/usertours.js | 4 +- admin/tool/usertours/classes/helper.php | 68 +++++++++--- .../hook/before_clientside_filter_fetch.php | 74 +++++++++++++ .../hook/before_serverside_filter_fetch.php | 78 +++++++++++++ .../clientside_filter_for_serverside_hook.php | 45 ++++++++ .../usertours/tests/fixtures/hook_fixtures.php | 61 +++++++++++ .../fixtures/invalid_clientside_hook_fixture.php | 43 ++++++++ .../fixtures/invalid_serverside_hook_fixture.php | 43 ++++++++ .../serverside_filter_for_clientside_hook.php | 45 ++++++++ admin/tool/usertours/tests/helper_test.php | 122 ++++++++++++++++++++- admin/tool/usertours/upgrade.txt | 7 ++ 13 files changed, 570 insertions(+), 28 deletions(-) rewrite admin/tool/usertours/amd/build/usertours.min.js (65%) rewrite admin/tool/usertours/amd/build/usertours.min.js.map (87%) create mode 100644 admin/tool/usertours/classes/hook/before_clientside_filter_fetch.php create mode 100644 admin/tool/usertours/classes/hook/before_serverside_filter_fetch.php create mode 100644 admin/tool/usertours/tests/fixtures/clientside_filter_for_serverside_hook.php create mode 100644 admin/tool/usertours/tests/fixtures/hook_fixtures.php create mode 100644 admin/tool/usertours/tests/fixtures/invalid_clientside_hook_fixture.php create mode 100644 admin/tool/usertours/tests/fixtures/invalid_serverside_hook_fixture.php create mode 100644 admin/tool/usertours/tests/fixtures/serverside_filter_for_clientside_hook.php diff --git a/admin/tool/usertours/amd/build/usertours.min.js b/admin/tool/usertours/amd/build/usertours.min.js dissimilarity index 65% index 652ed1d1c90..4778d0b8860 100644 --- a/admin/tool/usertours/amd/build/usertours.min.js +++ b/admin/tool/usertours/amd/build/usertours.min.js @@ -1,3 +1,3 @@ -define("tool_usertours/usertours",["exports","./tour","core/templates","core/log","core/notification","./repository","core/pending","./events"],(function(_exports,_tour,_templates,_log,_notification,tourRepository,_pending,_events){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.resetTourState=_exports.init=void 0,_tour=_interopRequireDefault(_tour),_templates=_interopRequireDefault(_templates),_log=_interopRequireDefault(_log),_notification=_interopRequireDefault(_notification),tourRepository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(tourRepository),_pending=_interopRequireDefault(_pending);var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}let currentTour=null,tourId=null,restartTourAndKeepProgress=!1,currentStepNo=null;_exports.init=async(tourDetails,filters)=>{const requirements=[];filters.forEach((filter=>{requirements.push("function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require(["tool_usertours/filter_".concat(filter)],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require("tool_usertours/filter_".concat(filter))):Promise.resolve(_systemImportTransformerGlobalIdentifier["tool_usertours/filter_".concat(filter)]))}));const matchingTour=((tourDetails,filters)=>tourDetails.find((tour=>filters.some((filter=>!filter||!filter.filterMatches||filter.filterMatches(tour))))))(tourDetails,await Promise.all(requirements));if(!matchingTour)return;tourId=matchingTour.tourId;let startTour=matchingTour.startTour;void 0===startTour&&(startTour=!0),startTour&&fetchTour(tourId),addResetLink(),document.querySelector("body").addEventListener("click",(e=>{e.target.closest("#resetpagetour")&&(e.preventDefault(),resetTourState(tourId))})),window.addEventListener("resize",(()=>{currentTour&¤tTour.tourRunning&&(clearTimeout(window.resizedFinished),window.resizedFinished=setTimeout((()=>{currentStepNo=currentTour.getCurrentStepNumber(),restartTourAndKeepProgress=!0,resetTourState(tourId)}),250))}))};const fetchTour=async tourId=>{const pendingPromise=new _pending.default("admin_usertour_fetchTour:".concat(tourId));try{const response=await tourRepository.fetchTour(tourId);if(response.hasOwnProperty("tourconfig")){const{html:html}=await _templates.default.renderForPromise("tool_usertours/tourstep",response.tourconfig);startBootstrapTour(tourId,html,response.tourconfig)}pendingPromise.resolve()}catch(error){pendingPromise.resolve(),_notification.default.exception(error)}},addResetLink=()=>{const pendingPromise=new _pending.default("admin_usertour_addResetLink");_templates.default.render("tool_usertours/resettour",{}).then((function(html,js){_templates.default.appendNodeContents((()=>{let location=document.querySelector(".tool_usertours-resettourcontainer");return location||(location=document.querySelector(".logininfo"),location||(location=document.querySelector("footer"),location||document.body))})(),html,js)})).catch().then(pendingPromise.resolve).catch()},startBootstrapTour=(tourId,template,tourConfig)=>{currentTour&¤tTour.tourRunning&&(currentTour.endTour(),currentTour=null),document.addEventListener(_events.eventTypes.tourEnded,markTourComplete),document.addEventListener(_events.eventTypes.stepRenderer,markStepShown),tourConfig.tourName=tourConfig.name,delete tourConfig.name,tourConfig.template=template,tourConfig.steps=tourConfig.steps.map((function(step){return void 0!==step.element&&(step.target=step.element,delete step.element),void 0!==step.reflex&&(step.moveOnClick=!!step.reflex,delete step.reflex),void 0!==step.content&&(step.body=step.content,delete step.content),step})),currentTour=new _tour.default(tourConfig);let startAt=0;return restartTourAndKeepProgress&¤tStepNo&&(startAt=currentStepNo,restartTourAndKeepProgress=!1,currentStepNo=null),currentTour.startTour(startAt)},markStepShown=e=>{const tour=e.detail.tour,stepConfig=tour.getStepConfig(tour.getCurrentStepNumber());tourRepository.markStepShown(stepConfig.stepid,tourId,tour.getCurrentStepNumber()).catch(_log.default.error)},markTourComplete=e=>{document.removeEventListener(_events.eventTypes.tourEnded,markTourComplete),document.removeEventListener(_events.eventTypes.stepRenderer,markStepShown);const tour=e.detail.tour,stepConfig=tour.getStepConfig(tour.getCurrentStepNumber());tourRepository.markTourComplete(stepConfig.stepid,tourId,tour.getCurrentStepNumber()).catch(_log.default.error)},resetTourState=tourId=>tourRepository.resetTourState(tourId).then((response=>{response.startTour&&fetchTour(response.startTour)})).catch(_notification.default.exception);_exports.resetTourState=resetTourState})); - -//# sourceMappingURL=usertours.min.js.map \ No newline at end of file +define("tool_usertours/usertours",["exports","./tour","core/templates","core/log","core/notification","./repository","core/pending","./events"],(function(_exports,_tour,_templates,_log,_notification,tourRepository,_pending,_events){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.resetTourState=_exports.init=void 0,_tour=_interopRequireDefault(_tour),_templates=_interopRequireDefault(_templates),_log=_interopRequireDefault(_log),_notification=_interopRequireDefault(_notification),tourRepository=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(tourRepository),_pending=_interopRequireDefault(_pending);var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}let currentTour=null,tourId=null,restartTourAndKeepProgress=!1,currentStepNo=null;_exports.init=async(tourDetails,filters)=>{const requirements=[];filters.forEach((filter=>{requirements.push("function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([filter],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(filter)):Promise.resolve(_systemImportTransformerGlobalIdentifier[filter]))}));const matchingTour=((tourDetails,filters)=>tourDetails.find((tour=>filters.some((filter=>!filter||!filter.filterMatches||filter.filterMatches(tour))))))(tourDetails,await Promise.all(requirements));if(!matchingTour)return;tourId=matchingTour.tourId;let startTour=matchingTour.startTour;void 0===startTour&&(startTour=!0),startTour&&fetchTour(tourId),addResetLink(),document.querySelector("body").addEventListener("click",(e=>{e.target.closest("#resetpagetour")&&(e.preventDefault(),resetTourState(tourId))})),window.addEventListener("resize",(()=>{currentTour&¤tTour.tourRunning&&(clearTimeout(window.resizedFinished),window.resizedFinished=setTimeout((()=>{currentStepNo=currentTour.getCurrentStepNumber(),restartTourAndKeepProgress=!0,resetTourState(tourId)}),250))}))};const fetchTour=async tourId=>{const pendingPromise=new _pending.default("admin_usertour_fetchTour:".concat(tourId));try{const response=await tourRepository.fetchTour(tourId);if(response.hasOwnProperty("tourconfig")){const{html:html}=await _templates.default.renderForPromise("tool_usertours/tourstep",response.tourconfig);startBootstrapTour(tourId,html,response.tourconfig)}pendingPromise.resolve()}catch(error){pendingPromise.resolve(),_notification.default.exception(error)}},addResetLink=()=>{const pendingPromise=new _pending.default("admin_usertour_addResetLink");_templates.default.render("tool_usertours/resettour",{}).then((function(html,js){_templates.default.appendNodeContents((()=>{let location=document.querySelector(".tool_usertours-resettourcontainer");return location||(location=document.querySelector(".logininfo"),location||(location=document.querySelector("footer"),location||document.body))})(),html,js)})).catch().then(pendingPromise.resolve).catch()},startBootstrapTour=(tourId,template,tourConfig)=>{currentTour&¤tTour.tourRunning&&(currentTour.endTour(),currentTour=null),document.addEventListener(_events.eventTypes.tourEnded,markTourComplete),document.addEventListener(_events.eventTypes.stepRenderer,markStepShown),tourConfig.tourName=tourConfig.name,delete tourConfig.name,tourConfig.template=template,tourConfig.steps=tourConfig.steps.map((function(step){return void 0!==step.element&&(step.target=step.element,delete step.element),void 0!==step.reflex&&(step.moveOnClick=!!step.reflex,delete step.reflex),void 0!==step.content&&(step.body=step.content,delete step.content),step})),currentTour=new _tour.default(tourConfig);let startAt=0;return restartTourAndKeepProgress&¤tStepNo&&(startAt=currentStepNo,restartTourAndKeepProgress=!1,currentStepNo=null),currentTour.startTour(startAt)},markStepShown=e=>{const tour=e.detail.tour,stepConfig=tour.getStepConfig(tour.getCurrentStepNumber());tourRepository.markStepShown(stepConfig.stepid,tourId,tour.getCurrentStepNumber()).catch(_log.default.error)},markTourComplete=e=>{document.removeEventListener(_events.eventTypes.tourEnded,markTourComplete),document.removeEventListener(_events.eventTypes.stepRenderer,markStepShown);const tour=e.detail.tour,stepConfig=tour.getStepConfig(tour.getCurrentStepNumber());tourRepository.markTourComplete(stepConfig.stepid,tourId,tour.getCurrentStepNumber()).catch(_log.default.error)},resetTourState=tourId=>tourRepository.resetTourState(tourId).then((response=>{response.startTour&&fetchTour(response.startTour)})).catch(_notification.default.exception);_exports.resetTourState=resetTourState})); + +//# sourceMappingURL=usertours.min.js.map \ No newline at end of file diff --git a/admin/tool/usertours/amd/build/usertours.min.js.map b/admin/tool/usertours/amd/build/usertours.min.js.map dissimilarity index 87% index 8c3fa410513..05c28d7d39e 100644 --- a/admin/tool/usertours/amd/build/usertours.min.js.map +++ b/admin/tool/usertours/amd/build/usertours.min.js.map @@ -1 +1 @@ -{"version":3,"file":"usertours.min.js","sources":["../src/usertours.js"],"sourcesContent":["/**\n * User tour control library.\n *\n * @module tool_usertours/usertours\n * @copyright 2016 Andrew Nicols \n */\nimport BootstrapTour from './tour';\nimport Templates from 'core/templates';\nimport log from 'core/log';\nimport notification from 'core/notification';\nimport * as tourRepository from './repository';\nimport Pending from 'core/pending';\nimport {eventTypes} from './events';\n\nlet currentTour = null;\nlet tourId = null;\nlet restartTourAndKeepProgress = false;\nlet currentStepNo = null;\n\n/**\n * Find the first matching tour.\n *\n * @param {object[]} tourDetails\n * @param {object[]} filters\n * @returns {null|object}\n */\nconst findMatchingTour = (tourDetails, filters) => {\n return tourDetails.find(tour => filters.some(filter => {\n if (filter && filter.filterMatches) {\n return filter.filterMatches(tour);\n }\n\n return true;\n }));\n};\n\n/**\n * Initialise the user tour for the current page.\n *\n * @method init\n * @param {Array} tourDetails The matching tours for this page.\n * @param {Array} filters The names of all client side filters.\n */\nexport const init = async(tourDetails, filters) => {\n const requirements = [];\n filters.forEach(filter => {\n requirements.push(import(`tool_usertours/filter_${filter}`));\n });\n\n const filterPlugins = await Promise.all(requirements);\n\n const matchingTour = findMatchingTour(tourDetails, filterPlugins);\n if (!matchingTour) {\n return;\n }\n\n // Only one tour per page is allowed.\n tourId = matchingTour.tourId;\n\n let startTour = matchingTour.startTour;\n if (typeof startTour === 'undefined') {\n startTour = true;\n }\n\n if (startTour) {\n // Fetch the tour configuration.\n fetchTour(tourId);\n }\n\n addResetLink();\n\n // Watch for the reset link.\n document.querySelector('body').addEventListener('click', e => {\n const resetLink = e.target.closest('#resetpagetour');\n if (resetLink) {\n e.preventDefault();\n resetTourState(tourId);\n }\n });\n\n // Watch for the resize event.\n window.addEventListener(\"resize\", () => {\n // Only listen for the running tour.\n if (currentTour && currentTour.tourRunning) {\n clearTimeout(window.resizedFinished);\n window.resizedFinished = setTimeout(() => {\n // Wait until the resize event has finished.\n currentStepNo = currentTour.getCurrentStepNumber();\n restartTourAndKeepProgress = true;\n resetTourState(tourId);\n }, 250);\n }\n });\n};\n\n/**\n * Fetch the configuration specified tour, and start the tour when it has been fetched.\n *\n * @method fetchTour\n * @param {Number} tourId The ID of the tour to start.\n */\nconst fetchTour = async tourId => {\n const pendingPromise = new Pending(`admin_usertour_fetchTour:${tourId}`);\n\n try {\n // If we don't have any tour config (because it doesn't need showing for the current user), return early.\n const response = await tourRepository.fetchTour(tourId);\n if (response.hasOwnProperty('tourconfig')) {\n const {html} = await Templates.renderForPromise('tool_usertours/tourstep', response.tourconfig);\n startBootstrapTour(tourId, html, response.tourconfig);\n }\n pendingPromise.resolve();\n } catch (error) {\n pendingPromise.resolve();\n notification.exception(error);\n }\n};\n\nconst getPreferredResetLocation = () => {\n let location = document.querySelector('.tool_usertours-resettourcontainer');\n if (location) {\n return location;\n }\n\n location = document.querySelector('.logininfo');\n if (location) {\n return location;\n }\n\n location = document.querySelector('footer');\n if (location) {\n return location;\n }\n\n return document.body;\n};\n\n/**\n * Add a reset link to the page.\n *\n * @method addResetLink\n */\nconst addResetLink = () => {\n const pendingPromise = new Pending('admin_usertour_addResetLink');\n\n Templates.render('tool_usertours/resettour', {})\n .then(function(html, js) {\n // Append the link to the most suitable place on the page with fallback to legacy selectors and finally the body if\n // there is no better place.\n Templates.appendNodeContents(getPreferredResetLocation(), html, js);\n\n return;\n })\n .catch()\n .then(pendingPromise.resolve)\n .catch();\n};\n\n/**\n * Start the specified tour.\n *\n * @method startBootstrapTour\n * @param {Number} tourId The ID of the tour to start.\n * @param {String} template The template to use.\n * @param {Object} tourConfig The tour configuration.\n * @return {Object}\n */\nconst startBootstrapTour = (tourId, template, tourConfig) => {\n if (currentTour && currentTour.tourRunning) {\n // End the current tour.\n currentTour.endTour();\n currentTour = null;\n }\n\n document.addEventListener(eventTypes.tourEnded, markTourComplete);\n document.addEventListener(eventTypes.stepRenderer, markStepShown);\n\n // Sort out the tour name.\n tourConfig.tourName = tourConfig.name;\n delete tourConfig.name;\n\n // Add the template to the configuration.\n // This enables translations of the buttons.\n tourConfig.template = template;\n\n tourConfig.steps = tourConfig.steps.map(function(step) {\n if (typeof step.element !== 'undefined') {\n step.target = step.element;\n delete step.element;\n }\n\n if (typeof step.reflex !== 'undefined') {\n step.moveOnClick = !!step.reflex;\n delete step.reflex;\n }\n\n if (typeof step.content !== 'undefined') {\n step.body = step.content;\n delete step.content;\n }\n\n return step;\n });\n\n currentTour = new BootstrapTour(tourConfig);\n let startAt = 0;\n if (restartTourAndKeepProgress && currentStepNo) {\n startAt = currentStepNo;\n restartTourAndKeepProgress = false;\n currentStepNo = null;\n }\n return currentTour.startTour(startAt);\n};\n\n/**\n * Mark the specified step as being shownd by the user.\n *\n * @method markStepShown\n * @param {Event} e\n */\nconst markStepShown = e => {\n const tour = e.detail.tour;\n const stepConfig = tour.getStepConfig(tour.getCurrentStepNumber());\n tourRepository.markStepShown(\n stepConfig.stepid,\n tourId,\n tour.getCurrentStepNumber()\n ).catch(log.error);\n};\n\n/**\n * Mark the specified tour as being completed by the user.\n *\n * @method markTourComplete\n * @param {Event} e\n * @listens tool_usertours/stepRendered\n */\nconst markTourComplete = e => {\n document.removeEventListener(eventTypes.tourEnded, markTourComplete);\n document.removeEventListener(eventTypes.stepRenderer, markStepShown);\n\n const tour = e.detail.tour;\n const stepConfig = tour.getStepConfig(tour.getCurrentStepNumber());\n tourRepository.markTourComplete(\n stepConfig.stepid,\n tourId,\n tour.getCurrentStepNumber()\n ).catch(log.error);\n};\n\n/**\n * Reset the state, and restart the the tour on the current page.\n *\n * @method resetTourState\n * @param {Number} tourId The ID of the tour to start.\n * @returns {Promise}\n */\nexport const resetTourState = tourId => tourRepository.resetTourState(tourId)\n.then(response => {\n if (response.startTour) {\n fetchTour(response.startTour);\n }\n return;\n}).catch(notification.exception);\n"],"names":["currentTour","tourId","restartTourAndKeepProgress","currentStepNo","async","tourDetails","filters","requirements","forEach","filter","push","matchingTour","find","tour","some","filterMatches","findMatchingTour","Promise","all","startTour","fetchTour","addResetLink","document","querySelector","addEventListener","e","target","closest","preventDefault","resetTourState","window","tourRunning","clearTimeout","resizedFinished","setTimeout","getCurrentStepNumber","pendingPromise","Pending","response","tourRepository","hasOwnProperty","html","Templates","renderForPromise","tourconfig","startBootstrapTour","resolve","error","exception","render","then","js","appendNodeContents","location","body","getPreferredResetLocation","catch","template","tourConfig","endTour","eventTypes","tourEnded","markTourComplete","stepRenderer","markStepShown","tourName","name","steps","map","step","element","reflex","moveOnClick","content","BootstrapTour","startAt","detail","stepConfig","getStepConfig","stepid","log","removeEventListener","notification"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"usertours.min.js","sources":["../src/usertours.js"],"sourcesContent":["/**\n * User tour control library.\n *\n * @module tool_usertours/usertours\n * @copyright 2016 Andrew Nicols \n */\nimport BootstrapTour from './tour';\nimport Templates from 'core/templates';\nimport log from 'core/log';\nimport notification from 'core/notification';\nimport * as tourRepository from './repository';\nimport Pending from 'core/pending';\nimport {eventTypes} from './events';\n\nlet currentTour = null;\nlet tourId = null;\nlet restartTourAndKeepProgress = false;\nlet currentStepNo = null;\n\n/**\n * Find the first matching tour.\n *\n * @param {object[]} tourDetails\n * @param {object[]} filters\n * @returns {null|object}\n */\nconst findMatchingTour = (tourDetails, filters) => {\n return tourDetails.find(tour => filters.some(filter => {\n if (filter && filter.filterMatches) {\n return filter.filterMatches(tour);\n }\n\n return true;\n }));\n};\n\n/**\n * Initialise the user tour for the current page.\n *\n * @method init\n * @param {Array} tourDetails The matching tours for this page.\n * @param {Array} filters The names of all client side filters.\n */\nexport const init = async(tourDetails, filters) => {\n const requirements = [];\n filters.forEach((filter) => {\n requirements.push(import(filter));\n });\n\n const filterPlugins = await Promise.all(requirements);\n\n const matchingTour = findMatchingTour(tourDetails, filterPlugins);\n if (!matchingTour) {\n return;\n }\n\n // Only one tour per page is allowed.\n tourId = matchingTour.tourId;\n\n let startTour = matchingTour.startTour;\n if (typeof startTour === 'undefined') {\n startTour = true;\n }\n\n if (startTour) {\n // Fetch the tour configuration.\n fetchTour(tourId);\n }\n\n addResetLink();\n\n // Watch for the reset link.\n document.querySelector('body').addEventListener('click', e => {\n const resetLink = e.target.closest('#resetpagetour');\n if (resetLink) {\n e.preventDefault();\n resetTourState(tourId);\n }\n });\n\n // Watch for the resize event.\n window.addEventListener(\"resize\", () => {\n // Only listen for the running tour.\n if (currentTour && currentTour.tourRunning) {\n clearTimeout(window.resizedFinished);\n window.resizedFinished = setTimeout(() => {\n // Wait until the resize event has finished.\n currentStepNo = currentTour.getCurrentStepNumber();\n restartTourAndKeepProgress = true;\n resetTourState(tourId);\n }, 250);\n }\n });\n};\n\n/**\n * Fetch the configuration specified tour, and start the tour when it has been fetched.\n *\n * @method fetchTour\n * @param {Number} tourId The ID of the tour to start.\n */\nconst fetchTour = async tourId => {\n const pendingPromise = new Pending(`admin_usertour_fetchTour:${tourId}`);\n\n try {\n // If we don't have any tour config (because it doesn't need showing for the current user), return early.\n const response = await tourRepository.fetchTour(tourId);\n if (response.hasOwnProperty('tourconfig')) {\n const {html} = await Templates.renderForPromise('tool_usertours/tourstep', response.tourconfig);\n startBootstrapTour(tourId, html, response.tourconfig);\n }\n pendingPromise.resolve();\n } catch (error) {\n pendingPromise.resolve();\n notification.exception(error);\n }\n};\n\nconst getPreferredResetLocation = () => {\n let location = document.querySelector('.tool_usertours-resettourcontainer');\n if (location) {\n return location;\n }\n\n location = document.querySelector('.logininfo');\n if (location) {\n return location;\n }\n\n location = document.querySelector('footer');\n if (location) {\n return location;\n }\n\n return document.body;\n};\n\n/**\n * Add a reset link to the page.\n *\n * @method addResetLink\n */\nconst addResetLink = () => {\n const pendingPromise = new Pending('admin_usertour_addResetLink');\n\n Templates.render('tool_usertours/resettour', {})\n .then(function(html, js) {\n // Append the link to the most suitable place on the page with fallback to legacy selectors and finally the body if\n // there is no better place.\n Templates.appendNodeContents(getPreferredResetLocation(), html, js);\n\n return;\n })\n .catch()\n .then(pendingPromise.resolve)\n .catch();\n};\n\n/**\n * Start the specified tour.\n *\n * @method startBootstrapTour\n * @param {Number} tourId The ID of the tour to start.\n * @param {String} template The template to use.\n * @param {Object} tourConfig The tour configuration.\n * @return {Object}\n */\nconst startBootstrapTour = (tourId, template, tourConfig) => {\n if (currentTour && currentTour.tourRunning) {\n // End the current tour.\n currentTour.endTour();\n currentTour = null;\n }\n\n document.addEventListener(eventTypes.tourEnded, markTourComplete);\n document.addEventListener(eventTypes.stepRenderer, markStepShown);\n\n // Sort out the tour name.\n tourConfig.tourName = tourConfig.name;\n delete tourConfig.name;\n\n // Add the template to the configuration.\n // This enables translations of the buttons.\n tourConfig.template = template;\n\n tourConfig.steps = tourConfig.steps.map(function(step) {\n if (typeof step.element !== 'undefined') {\n step.target = step.element;\n delete step.element;\n }\n\n if (typeof step.reflex !== 'undefined') {\n step.moveOnClick = !!step.reflex;\n delete step.reflex;\n }\n\n if (typeof step.content !== 'undefined') {\n step.body = step.content;\n delete step.content;\n }\n\n return step;\n });\n\n currentTour = new BootstrapTour(tourConfig);\n let startAt = 0;\n if (restartTourAndKeepProgress && currentStepNo) {\n startAt = currentStepNo;\n restartTourAndKeepProgress = false;\n currentStepNo = null;\n }\n return currentTour.startTour(startAt);\n};\n\n/**\n * Mark the specified step as being shownd by the user.\n *\n * @method markStepShown\n * @param {Event} e\n */\nconst markStepShown = e => {\n const tour = e.detail.tour;\n const stepConfig = tour.getStepConfig(tour.getCurrentStepNumber());\n tourRepository.markStepShown(\n stepConfig.stepid,\n tourId,\n tour.getCurrentStepNumber()\n ).catch(log.error);\n};\n\n/**\n * Mark the specified tour as being completed by the user.\n *\n * @method markTourComplete\n * @param {Event} e\n * @listens tool_usertours/stepRendered\n */\nconst markTourComplete = e => {\n document.removeEventListener(eventTypes.tourEnded, markTourComplete);\n document.removeEventListener(eventTypes.stepRenderer, markStepShown);\n\n const tour = e.detail.tour;\n const stepConfig = tour.getStepConfig(tour.getCurrentStepNumber());\n tourRepository.markTourComplete(\n stepConfig.stepid,\n tourId,\n tour.getCurrentStepNumber()\n ).catch(log.error);\n};\n\n/**\n * Reset the state, and restart the the tour on the current page.\n *\n * @method resetTourState\n * @param {Number} tourId The ID of the tour to start.\n * @returns {Promise}\n */\nexport const resetTourState = tourId => tourRepository.resetTourState(tourId)\n.then(response => {\n if (response.startTour) {\n fetchTour(response.startTour);\n }\n return;\n}).catch(notification.exception);\n"],"names":["currentTour","tourId","restartTourAndKeepProgress","currentStepNo","async","tourDetails","filters","requirements","forEach","filter","push","matchingTour","find","tour","some","filterMatches","findMatchingTour","Promise","all","startTour","fetchTour","addResetLink","document","querySelector","addEventListener","e","target","closest","preventDefault","resetTourState","window","tourRunning","clearTimeout","resizedFinished","setTimeout","getCurrentStepNumber","pendingPromise","Pending","response","tourRepository","hasOwnProperty","html","Templates","renderForPromise","tourconfig","startBootstrapTour","resolve","error","exception","render","then","js","appendNodeContents","location","body","getPreferredResetLocation","catch","template","tourConfig","endTour","eventTypes","tourEnded","markTourComplete","stepRenderer","markStepShown","tourName","name","steps","map","step","element","reflex","moveOnClick","content","BootstrapTour","startAt","detail","stepConfig","getStepConfig","stepid","log","removeEventListener","notification"],"mappings":"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"} \ No newline at end of file diff --git a/admin/tool/usertours/amd/src/usertours.js b/admin/tool/usertours/amd/src/usertours.js index 8e64e8d68ea..b9e2c4ee364 100644 --- a/admin/tool/usertours/amd/src/usertours.js +++ b/admin/tool/usertours/amd/src/usertours.js @@ -43,8 +43,8 @@ const findMatchingTour = (tourDetails, filters) => { */ export const init = async(tourDetails, filters) => { const requirements = []; - filters.forEach(filter => { - requirements.push(import(`tool_usertours/filter_${filter}`)); + filters.forEach((filter) => { + requirements.push(import(filter)); }); const filterPlugins = await Promise.all(requirements); diff --git a/admin/tool/usertours/classes/helper.php b/admin/tool/usertours/classes/helper.php index 8327dbd0ed4..13ca3ddd68f 100644 --- a/admin/tool/usertours/classes/helper.php +++ b/admin/tool/usertours/classes/helper.php @@ -16,6 +16,7 @@ namespace tool_usertours; +use coding_exception; use core\output\inplace_editable; /** @@ -534,10 +535,7 @@ class helper { ]; }, $tours); - $filternames = []; - foreach ($filters as $filter) { - $filternames[] = $filter::get_filter_name(); - } + $filternames = self::get_clientside_filter_module_names($filters); $PAGE->requires->js_call_amd('tool_usertours/usertours', 'init', [ $tourdetails, @@ -547,18 +545,47 @@ class helper { } /** + * Get the JS module names for the filters. + * + * @param array $filters + * @return array + * @throws coding_exception + */ + public static function get_clientside_filter_module_names(array $filters): array { + $filternames = []; + foreach ($filters as $filter) { + if ($component = \core_component::get_component_from_classname($filter)) { + $filternames[] = sprintf( + "%s/filter_%s", + $component, + $filter::get_filter_name(), + ); + } else { + throw new \coding_exception("Could not determine component for filter class {$filter}"); + } + } + + return $filternames; + } + + /** * Get a list of all possible filters. * * @return array */ public static function get_all_filters() { - $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\filter'); - $filters = array_keys($filters); - - $filters = array_filter($filters, function ($filterclass) { - $rc = new \ReflectionClass($filterclass); - return $rc->isInstantiable(); - }); + $hook = new hook\before_serverside_filter_fetch(array_keys( + \core_component::get_component_classes_in_namespace('tool_usertours', 'local\filter') + )); + \core\di::get(\core\hook\manager::class)->dispatch($hook); + + $filters = array_filter( + $hook->get_filter_list(), + function ($filterclass) { + $rc = new \ReflectionClass($filterclass); + return $rc->isInstantiable(); + } + ); $filters = array_merge($filters, static::get_all_clientside_filters()); @@ -571,13 +598,18 @@ class helper { * @return array */ public static function get_all_clientside_filters() { - $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\clientside_filter'); - $filters = array_keys($filters); - - $filters = array_filter($filters, function ($filterclass) { - $rc = new \ReflectionClass($filterclass); - return $rc->isInstantiable(); - }); + $hook = new hook\before_clientside_filter_fetch(array_keys( + \core_component::get_component_classes_in_namespace('tool_usertours', 'local\clientside_filter') + )); + \core\di::get(\core\hook\manager::class)->dispatch($hook); + + $filters = array_filter( + $hook->get_filter_list(), + function ($filterclass) { + $rc = new \ReflectionClass($filterclass); + return $rc->isInstantiable(); + } + ); return $filters; } diff --git a/admin/tool/usertours/classes/hook/before_clientside_filter_fetch.php b/admin/tool/usertours/classes/hook/before_clientside_filter_fetch.php new file mode 100644 index 00000000000..ceea91eeb45 --- /dev/null +++ b/admin/tool/usertours/classes/hook/before_clientside_filter_fetch.php @@ -0,0 +1,74 @@ +. + +namespace tool_usertours\hook; + +/** + * Provides the ability to add and remove custom client-side filters to the user tour filter list. + * + * @package tool_usertours + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Provides the ability to add and remove custom client-side filters to the user tour filter list.')] +#[\core\attribute\tags('tool_usertours')] +class before_clientside_filter_fetch { + /** + * Create a new instance of the hook. + * + * @param array $filters + */ + public function __construct( + /** @var array The list of filters applied */ + protected array $filters, + ) { + } + + /** + * Add a filter classname to the list of filters to be processed. + * + * @param string $classname + * @return self + */ + public function add_filter_by_classname(string $classname): self { + if (!\is_a($classname, \tool_usertours\local\clientside_filter\clientside_filter::class, true)) { + throw new \coding_exception("Invalid clientside filter class {$classname}"); + } + $this->filters[] = $classname; + + return $this; + } + + /** + * Remove a filter classname from the list of filters to be processed. + * + * @param string $classname + * @return self + */ + public function remove_filter_by_classname(string $classname): self { + $this->filters = array_filter($this->filters, fn($filter) => $filter !== $classname); + return $this; + } + + /** + * Get the list of filters to be processed. + * + * @return array + */ + public function get_filter_list(): array { + return $this->filters; + } +} diff --git a/admin/tool/usertours/classes/hook/before_serverside_filter_fetch.php b/admin/tool/usertours/classes/hook/before_serverside_filter_fetch.php new file mode 100644 index 00000000000..079816e9663 --- /dev/null +++ b/admin/tool/usertours/classes/hook/before_serverside_filter_fetch.php @@ -0,0 +1,78 @@ +. + +namespace tool_usertours\hook; + +/** + * Provides the ability to add and remove custom server-side filters to the user tour filter list. + * + * @package tool_usertours + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Provides the ability to add and remove custom server-side filters to the user tour filter list.')] +#[\core\attribute\tags('tool_usertours')] +class before_serverside_filter_fetch { + /** + * Create a new instance of the hook. + * + * @param array $filters + */ + public function __construct( + /** @var array The list of filters applied */ + protected array $filters, + ) { + } + + /** + * Add a filter classname to the list of filters to be processed. + * + * @param string $classname + * @return self + */ + public function add_filter_by_classname(string $classname): self { + if (!\is_a($classname, \tool_usertours\local\filter\base::class, true)) { + throw new \coding_exception("Invalid filter class {$classname}"); + } + + if (\is_a($classname, \tool_usertours\local\clientside_filter\clientside_filter::class, true)) { + throw new \coding_exception("Invalid filter class {$classname} (client-side filter for server-side hook)"); + } + + $this->filters[] = $classname; + return $this; + } + + /** + * Remove a filter classname from the list of filters to be processed. + * + * @param string $classname + * @return self + */ + public function remove_filter_by_classname(string $classname): self { + $this->filters = array_filter($this->filters, fn($filter) => $filter !== $classname); + return $this; + } + + /** + * Get the list of filters to be processed. + * + * @return array + */ + public function get_filter_list(): array { + return $this->filters; + } +} diff --git a/admin/tool/usertours/tests/fixtures/clientside_filter_for_serverside_hook.php b/admin/tool/usertours/tests/fixtures/clientside_filter_for_serverside_hook.php new file mode 100644 index 00000000000..692eca99f8c --- /dev/null +++ b/admin/tool/usertours/tests/fixtures/clientside_filter_for_serverside_hook.php @@ -0,0 +1,45 @@ +. + +/** + * Hook fixtures for testing of hooks. + * + * @package tool_usertours + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_usertours\test\hook\clientside_filter_for_serverside_hook; + +defined('MOODLE_INTERNAL') || die(); + +final class filter_class extends \tool_usertours\local\clientside_filter\clientside_filter { +} + +final class callback { + public static function callme( + \tool_usertours\hook\before_serverside_filter_fetch $hook + ): void { + $hook->add_filter_by_classname(filter_class::class); + } +} + +$callbacks = [ + [ + 'hook' => \tool_usertours\hook\before_serverside_filter_fetch::class, + 'callback' => callback::class . '::callme', + ], +]; diff --git a/admin/tool/usertours/tests/fixtures/hook_fixtures.php b/admin/tool/usertours/tests/fixtures/hook_fixtures.php new file mode 100644 index 00000000000..ce98df86bbd --- /dev/null +++ b/admin/tool/usertours/tests/fixtures/hook_fixtures.php @@ -0,0 +1,61 @@ +. + +/** + * Hook fixtures for testing of hooks. + * + * @package tool_usertours + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_usertours\test\hook; + +defined('MOODLE_INTERNAL') || die(); + +final class serverside_filter_fixture extends \tool_usertours\local\filter\base { +} + +final class clientside_filter_fixture extends \tool_usertours\local\clientside_filter\clientside_filter { +} + +final class hook_fixtures { + public static function example_serverside_hook( + \tool_usertours\hook\before_serverside_filter_fetch $hook + ): void { + // Add a valid serverside and an invalid clientside filter. + $hook->add_filter_by_classname(\tool_usertours\test\hook\serverside_filter_fixture::class); + $hook->remove_filter_by_classname(\tool_usertours\local\filter\accessdate::class); + } + + public static function example_clientside_hook( + \tool_usertours\hook\before_clientside_filter_fetch $hook + ): void { + $hook->add_filter_by_classname(\tool_usertours\test\hook\clientside_filter_fixture::class); + $hook->remove_filter_by_classname(\tool_usertours\local\clientside_filter\cssselector::class); + } +} + +$callbacks = [ + [ + 'hook' => \tool_usertours\hook\before_serverside_filter_fetch::class, + 'callback' => \tool_usertours\test\hook\hook_fixtures::class . '::example_serverside_hook', + ], + [ + 'hook' => \tool_usertours\hook\before_clientside_filter_fetch::class, + 'callback' => \tool_usertours\test\hook\hook_fixtures::class . '::example_clientside_hook', + ], +]; diff --git a/admin/tool/usertours/tests/fixtures/invalid_clientside_hook_fixture.php b/admin/tool/usertours/tests/fixtures/invalid_clientside_hook_fixture.php new file mode 100644 index 00000000000..2d6f7684874 --- /dev/null +++ b/admin/tool/usertours/tests/fixtures/invalid_clientside_hook_fixture.php @@ -0,0 +1,43 @@ +. + +/** + * Hook fixtures for testing of hooks. + * + * @package tool_usertours + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +final class nocomponent_clientside_filter_fixture extends \tool_usertours\local\clientside_filter\clientside_filter { +} + +final class nocomponent_clientside_hook_fixtures { + public static function example_clientside_hook( + \tool_usertours\hook\before_clientside_filter_fetch $hook + ): void { + $hook->add_filter_by_classname(\nocomponent_clientside_filter_fixture::class); + } +} + +$callbacks = [ + [ + 'hook' => \tool_usertours\hook\before_clientside_filter_fetch::class, + 'callback' => \nocomponent_clientside_hook_fixtures::class . '::example_clientside_hook', + ], +]; diff --git a/admin/tool/usertours/tests/fixtures/invalid_serverside_hook_fixture.php b/admin/tool/usertours/tests/fixtures/invalid_serverside_hook_fixture.php new file mode 100644 index 00000000000..214b85a5696 --- /dev/null +++ b/admin/tool/usertours/tests/fixtures/invalid_serverside_hook_fixture.php @@ -0,0 +1,43 @@ +. + +/** + * Hook fixtures for testing of hooks. + * + * @package tool_usertours + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +final class nocomponent_serverside_filter_fixture { +} + +final class nocomponent_serverside_hook_fixtures { + public static function example_serverside_hook( + \tool_usertours\hook\before_serverside_filter_fetch $hook + ): void { + $hook->add_filter_by_classname(\nocomponent_serverside_filter_fixture::class); + } +} + +$callbacks = [ + [ + 'hook' => \tool_usertours\hook\before_serverside_filter_fetch::class, + 'callback' => \nocomponent_serverside_hook_fixtures::class . '::example_serverside_hook', + ], +]; diff --git a/admin/tool/usertours/tests/fixtures/serverside_filter_for_clientside_hook.php b/admin/tool/usertours/tests/fixtures/serverside_filter_for_clientside_hook.php new file mode 100644 index 00000000000..6fb2310f4ce --- /dev/null +++ b/admin/tool/usertours/tests/fixtures/serverside_filter_for_clientside_hook.php @@ -0,0 +1,45 @@ +. + +/** + * Hook fixtures for testing of hooks. + * + * @package tool_usertours + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_usertours\test\hook\serverside_filter_for_clientside_hook; + +defined('MOODLE_INTERNAL') || die(); + +final class filter_class extends \tool_usertours\local\filter\base { +} + +final class callback { + public static function callme( + \tool_usertours\hook\before_clientside_filter_fetch $hook + ): void { + $hook->add_filter_by_classname(filter_class::class); + } +} + +$callbacks = [ + [ + 'hook' => \tool_usertours\hook\before_clientside_filter_fetch::class, + 'callback' => callback::class . '::callme', + ], +]; diff --git a/admin/tool/usertours/tests/helper_test.php b/admin/tool/usertours/tests/helper_test.php index 3a2f1cdad26..2b118af6899 100644 --- a/admin/tool/usertours/tests/helper_test.php +++ b/admin/tool/usertours/tests/helper_test.php @@ -16,17 +16,18 @@ namespace tool_usertours; -use advanced_testcase; - /** * Tests for helper. * * @package tool_usertours + * @category test * @copyright 2022 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @covers \tool_usertours\helper + * @covers \tool_usertours\helper + * @covers \tool_usertours\hook\before_serverside_filter_fetch + * @covers \tool_usertours\hook\before_clientside_filter_fetch */ -class helper_test extends advanced_testcase { +final class helper_test extends \advanced_testcase { /** * Data Provider for get_string_from_input. * @@ -71,4 +72,117 @@ class helper_test extends advanced_testcase { public function test_get_string_from_input($string, $expected): void { $this->assertEquals($expected, helper::get_string_from_input($string)); } + + public function test_get_all_filters(): void { + $filters = helper::get_all_filters(); + $this->assertIsArray($filters); + + array_map( + function ($filter) { + $this->assertIsString($filter); + $this->assertTrue(class_exists($filter)); + $this->assertTrue(is_a($filter, \tool_usertours\local\filter\base::class, true)); + $rc = new \ReflectionClass($filter); + $this->assertTrue($rc->isInstantiable()); + }, + $filters, + ); + + $this->assertNotContains(\tool_usertours\test\hook\serverside_filter_fixture::class, $filters); + $this->assertNotContains(\tool_usertours\test\hook\clientside_filter_fixture::class, $filters); + $this->assertContains(\tool_usertours\local\filter\accessdate::class, $filters); + $this->assertContains(\tool_usertours\local\clientside_filter\cssselector::class, $filters); + + $filters = helper::get_all_clientside_filters(); + array_map( + function ($filter) { + $this->assertIsString($filter); + }, + $filters, + ); + } + + public function test_get_invalid_server_filter(): void { + \core\di::set( + \core\hook\manager::class, + \core\hook\manager::phpunit_get_instance([ + 'test_plugin1' => __DIR__ . '/fixtures/invalid_serverside_hook_fixture.php', + ]), + ); + + $this->expectException(\coding_exception::class); + helper::get_all_filters(); + } + + public function test_clientside_filter_for_serverside_hook(): void { + \core\di::set( + \core\hook\manager::class, + \core\hook\manager::phpunit_get_instance([ + 'test_plugin1' => __DIR__ . '/fixtures/clientside_filter_for_serverside_hook.php', + ]), + ); + + $this->expectException(\coding_exception::class); + helper::get_all_filters(); + } + + public function test_serverside_filter_for_clientside_hook(): void { + \core\di::set( + \core\hook\manager::class, + \core\hook\manager::phpunit_get_instance([ + 'test_plugin1' => __DIR__ . '/fixtures/serverside_filter_for_clientside_hook.php', + ]), + ); + + $this->expectException(\coding_exception::class); + helper::get_all_clientside_filters(); + } + + public function test_filter_hooks(): void { + \core\di::set( + \core\hook\manager::class, + \core\hook\manager::phpunit_get_instance([ + 'test_plugin1' => __DIR__ . '/fixtures/hook_fixtures.php', + ]), + ); + + $filters = helper::get_all_filters(); + $this->assertIsArray($filters); + + // Check the modifications from the serverside hook. + $this->assertContains(\tool_usertours\test\hook\serverside_filter_fixture::class, $filters); + $this->assertNotContains(\tool_usertours\test\hook\another_clientside_filter_fixture::class, $filters); + $this->assertNotContains(\tool_usertours\local\filter\accessdate::class, $filters); + + // Check the modifications from the clientside hook. + $this->assertContains(\tool_usertours\test\hook\clientside_filter_fixture::class, $filters); + $this->assertNotContains(\tool_usertours\test\hook\another_serverside_filter_fixture::class, $filters); + $this->assertNotContains(\tool_usertours\local\clientside_filter\cssselector::class, $filters); + + array_map( + function ($filter) { + $this->assertIsString($filter); + $this->assertTrue(class_exists($filter)); + $this->assertTrue(is_a($filter, \tool_usertours\local\filter\base::class, true)); + $rc = new \ReflectionClass($filter); + $this->assertTrue($rc->isInstantiable()); + }, + $filters, + ); + } + + public function test_get_clientside_filter_module_names(): void { + \core\di::set( + \core\hook\manager::class, + \core\hook\manager::phpunit_get_instance([ + 'test_plugin1' => __DIR__ . '/fixtures/invalid_clientside_hook_fixture.php', + ]), + ); + + $filters = helper::get_all_clientside_filters(); + + $this->expectException(\coding_exception::class); + $this->expectExceptionMessageMatches('/Could not determine component/'); + helper::get_clientside_filter_module_names($filters); + } } diff --git a/admin/tool/usertours/upgrade.txt b/admin/tool/usertours/upgrade.txt index 78c6749faad..f1c9f7445ce 100644 --- a/admin/tool/usertours/upgrade.txt +++ b/admin/tool/usertours/upgrade.txt @@ -1,5 +1,12 @@ This files describes API changes in the tool_usertours code. +=== 4.4 === +* New hooks have been provided to allow plugins to define their own server-side, and client-side user tour filters. + The new hooks are named: + - \tool_usertours\hook\before_serverside_filter_fetch + - \tool_usertours\hook\before_clientside_filter_fetch + These hooks allow addition, and removal, of filters. + === 4.0 === * The `tourconfig` property returned by the `tool_usertours_fetch_and_start_tour` external method has also an `endtourlabel` property that contains the label to be used -- 2.11.4.GIT