From ee7dbad82f820ca24fa77d563eb3f3f48f0086cc Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Wed, 25 Jun 2025 20:23:43 +0200 Subject: [PATCH] MDL-83893 mod_forum: Add WS calls to support toggle --- .../amd/build/forum_overview_toggle.min.js | 10 + .../build/forum_overview_toggle.min.js.map | 1 + public/mod/forum/amd/build/repository.min.js | 2 +- .../mod/forum/amd/build/repository.min.js.map | 2 +- .../forum/amd/src/forum_overview_toggle.js | 123 +++++++++ public/mod/forum/amd/src/repository.js | 38 +++ .../external/set_forum_subscription.php | 122 +++++++++ .../classes/external/set_forum_tracking.php | 116 +++++++++ .../forum/classes/local/exporters/forum.php | 7 +- public/mod/forum/db/services.php | 17 ++ public/mod/forum/lib.php | 10 +- .../mod/forum/tests/exporters_forum_test.php | 33 ++- .../external/set_forum_subscription_test.php | 237 ++++++++++++++++++ .../external/set_forum_tracking_test.php | 210 ++++++++++++++++ public/mod/forum/tests/externallib_test.php | 6 + public/mod/forum/version.php | 2 +- 16 files changed, 928 insertions(+), 8 deletions(-) create mode 100644 public/mod/forum/amd/build/forum_overview_toggle.min.js create mode 100644 public/mod/forum/amd/build/forum_overview_toggle.min.js.map create mode 100644 public/mod/forum/amd/src/forum_overview_toggle.js create mode 100644 public/mod/forum/classes/external/set_forum_subscription.php create mode 100644 public/mod/forum/classes/external/set_forum_tracking.php create mode 100644 public/mod/forum/tests/external/set_forum_subscription_test.php create mode 100644 public/mod/forum/tests/external/set_forum_tracking_test.php diff --git a/public/mod/forum/amd/build/forum_overview_toggle.min.js b/public/mod/forum/amd/build/forum_overview_toggle.min.js new file mode 100644 index 00000000000..ab44a373170 --- /dev/null +++ b/public/mod/forum/amd/build/forum_overview_toggle.min.js @@ -0,0 +1,10 @@ +define("mod_forum/forum_overview_toggle",["exports","core/notification","core/str","mod_forum/repository"],(function(_exports,_notification,_str,_repository){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +/** + * Handle forum subscription/tracking toggling. + * + * @module mod_forum/forum_overview_toggle + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */function registerEventListeners(toggleElement){toggleElement.addEventListener("change",(()=>{"forum-subscription-toggle"===toggleElement.dataset.type&&async function(toggleElement){const forumId=toggleElement.dataset.forumid,newState=toggleElement.dataset.targetstate;if(!forumId||!newState)return;try{const newTargetState=!!(await _repository.default.setForumSubscriptionState(forumId,newState)).userstate.subscribed;updateSwitchState(toggleElement,newTargetState,newTargetState?"subscribe":"unsubscribe")}catch(error){_notification.default.exception(error)}}(toggleElement),"forum-track-toggle"===toggleElement.dataset.type&&async function(toggleElement){const forumId=toggleElement.dataset.forumid,newState=toggleElement.dataset.targetstate;if(!forumId||!newState)return;try{const newTargetState=!!(await _repository.default.setForumTrackingState(forumId,newState)).userstate.tracked;updateSwitchState(toggleElement,newTargetState,newTargetState?"trackingon":"trackingoff")}catch(error){_notification.default.exception(error)}}(toggleElement)}))}async function updateSwitchState(toggleElement,newTargetState,stringKey){toggleElement.dataset.targetstate=newTargetState?0:1;const string=await(0,_str.getString)(stringKey,"mod_forum");toggleElement.closest("td").querySelector('label[for="'.concat(toggleElement.id,'"] span')).textContent=string}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_notification=_interopRequireDefault(_notification),_repository=_interopRequireDefault(_repository);_exports.init=toggleSelector=>{const toggleElement=document.querySelector(toggleSelector);toggleElement&®isterEventListeners(toggleElement)}})); + +//# sourceMappingURL=forum_overview_toggle.min.js.map \ No newline at end of file diff --git a/public/mod/forum/amd/build/forum_overview_toggle.min.js.map b/public/mod/forum/amd/build/forum_overview_toggle.min.js.map new file mode 100644 index 00000000000..9c518ecb86b --- /dev/null +++ b/public/mod/forum/amd/build/forum_overview_toggle.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"forum_overview_toggle.min.js","sources":["../src/forum_overview_toggle.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 * Handle forum subscription/tracking toggling.\n *\n * @module mod_forum/forum_overview_toggle\n * @copyright 2025 Sara Arjona \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Notification from 'core/notification';\nimport {getString} from 'core/str';\nimport Repository from 'mod_forum/repository';\n\n/**\n * Register event listeners for the subscription/tracking toggles in the overview.\n * @param {HTMLElement} toggleElement The toggle root element\n */\nfunction registerEventListeners(toggleElement) {\n toggleElement.addEventListener('change', () => {\n if (toggleElement.dataset.type === 'forum-subscription-toggle') {\n subscriptionToggleClickHandler(toggleElement);\n }\n if (toggleElement.dataset.type === 'forum-track-toggle') {\n trackToggleClickHanldler(toggleElement);\n }\n });\n}\n\n/**\n * Toggle subscription element click handler.\n *\n * @param {HTMLElement} toggleElement The toggle element that was clicked\n * @return {Promise}\n */\nasync function subscriptionToggleClickHandler(toggleElement) {\n const forumId = toggleElement.dataset.forumid;\n const newState = toggleElement.dataset.targetstate;\n if (!forumId || !newState) {\n return;\n }\n try {\n const context = await Repository.setForumSubscriptionState(forumId, newState);\n const newTargetState = !!context.userstate.subscribed;\n\n updateSwitchState(\n toggleElement,\n newTargetState,\n newTargetState ? 'subscribe' : 'unsubscribe',\n );\n } catch (error) {\n Notification.exception(error);\n }\n}\n\n/**\n * Toggle track element click handler.\n *\n * @param {HTMLElement} toggleElement The toggle element that was clicked\n * @return {Promise}\n */\nasync function trackToggleClickHanldler(toggleElement) {\n const forumId = toggleElement.dataset.forumid;\n const newState = toggleElement.dataset.targetstate;\n if (!forumId || !newState) {\n return;\n }\n try {\n const context = await Repository.setForumTrackingState(forumId, newState);\n const newTargetState = !!context.userstate.tracked;\n\n updateSwitchState(\n toggleElement,\n newTargetState,\n newTargetState ? 'trackingon' : 'trackingoff',\n );\n } catch (error) {\n Notification.exception(error);\n }\n}\n\n/**\n * Update the switch state of the toggle element.\n *\n * @param {HTMLElement} toggleElement The toggle element to update\n * @param {Boolean} newTargetState The new target state to set (true for subscribed, false for unsubscribed)\n * @param {string} stringKey The string key to retrieve the label text\n * @return {Promise}\n */\nasync function updateSwitchState(toggleElement, newTargetState, stringKey) {\n toggleElement.dataset.targetstate = newTargetState ? 0 : 1;\n const string = await getString(stringKey, 'mod_forum');\n const label = toggleElement.closest('td').querySelector(`label[for=\"${toggleElement.id}\"] span`);\n label.textContent = string;\n}\n\n/**\n * Initialize the forum overview toggle functionality.\n *\n * @param {string} toggleSelector The CSS selector for the toggle element to initialize\n * @throws {Error} If no elements are found with the provided selector\n */\nexport const init = (toggleSelector) => {\n const toggleElement = document.querySelector(toggleSelector);\n if (!toggleElement) {\n // If the user cannot track/subscribe to any course forum, the toggle will not be present.\n return;\n }\n registerEventListeners(toggleElement);\n};\n"],"names":["registerEventListeners","toggleElement","addEventListener","dataset","type","forumId","forumid","newState","targetstate","newTargetState","Repository","setForumSubscriptionState","userstate","subscribed","updateSwitchState","error","exception","subscriptionToggleClickHandler","setForumTrackingState","tracked","trackToggleClickHanldler","stringKey","string","closest","querySelector","id","textContent","toggleSelector","document"],"mappings":";;;;;;;cA+BSA,uBAAuBC,eAC5BA,cAAcC,iBAAiB,UAAU,KACF,8BAA/BD,cAAcE,QAAQC,qBAeYH,qBACpCI,QAAUJ,cAAcE,QAAQG,QAChCC,SAAWN,cAAcE,QAAQK,gBAClCH,UAAYE,0BAKPE,wBADgBC,oBAAWC,0BAA0BN,QAASE,WACnCK,UAAUC,WAE3CC,kBACIb,cACAQ,eACAA,eAAiB,YAAc,eAErC,MAAOM,6BACQC,UAAUD,QA9BnBE,CAA+BhB,eAEA,uBAA/BA,cAAcE,QAAQC,qBAsCMH,qBAC9BI,QAAUJ,cAAcE,QAAQG,QAChCC,SAAWN,cAAcE,QAAQK,gBAClCH,UAAYE,0BAKPE,wBADgBC,oBAAWQ,sBAAsBb,QAASE,WAC/BK,UAAUO,QAE3CL,kBACIb,cACAQ,eACAA,eAAiB,aAAe,eAEtC,MAAOM,6BACQC,UAAUD,QArDnBK,CAAyBnB,iCAiEtBa,kBAAkBb,cAAeQ,eAAgBY,WAC5DpB,cAAcE,QAAQK,YAAcC,eAAiB,EAAI,QACnDa,aAAe,kBAAUD,UAAW,aAC5BpB,cAAcsB,QAAQ,MAAMC,mCAA4BvB,cAAcwB,eAC9EC,YAAcJ,sMASHK,uBACX1B,cAAgB2B,SAASJ,cAAcG,gBACxC1B,eAILD,uBAAuBC"} \ No newline at end of file diff --git a/public/mod/forum/amd/build/repository.min.js b/public/mod/forum/amd/build/repository.min.js index 2f93f6d747c..318c0447514 100644 --- a/public/mod/forum/amd/build/repository.min.js +++ b/public/mod/forum/amd/build/repository.min.js @@ -6,6 +6,6 @@ * @copyright 2019 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define("mod_forum/repository",["core/ajax"],(function(Ajax){return{setDiscussionSubscriptionState:function(forumId,discussionId,targetState){var request={methodname:"mod_forum_set_subscription_state",args:{forumid:forumId,discussionid:discussionId,targetstate:targetState}};return Ajax.call([request])[0]},addDiscussionPost:function(postid,subject,message,messageformat,isprivatereply,topreferredformat){var request={methodname:"mod_forum_add_discussion_post",args:{postid:postid,message:message,messageformat:messageformat,subject:subject,options:[{name:"private",value:isprivatereply},{name:"topreferredformat",value:topreferredformat}]}};return Ajax.call([request])[0]},setDiscussionLockState:function(forumId,discussionId,targetState){var request={methodname:"mod_forum_set_lock_state",args:{forumid:forumId,discussionid:discussionId,targetstate:targetState}};return Ajax.call([request])[0]},setFavouriteDiscussionState:function(forumId,discussionId,targetState){var request={methodname:"mod_forum_toggle_favourite_state",args:{discussionid:discussionId,targetstate:targetState}};return Ajax.call([request])[0]},setPinDiscussionState:function(forumid,discussionid,targetstate){var request={methodname:"mod_forum_set_pin_state",args:{discussionid:discussionid,targetstate:targetstate}};return Ajax.call([request])[0]},getDiscussionByUserID:function(userid,cmid){let sortby=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"modified",sortdirection=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"DESC";var request={methodname:"mod_forum_get_discussion_posts_by_userid",args:{userid:userid,cmid:cmid,sortby:sortby,sortdirection:sortdirection}};return Ajax.call([request])[0]},getDiscussionPosts:function(discussionId){let sortby=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"created",sortdirection=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"ASC";var request={methodname:"mod_forum_get_discussion_posts",args:{discussionid:discussionId,sortby:sortby,sortdirection:sortdirection}};return Ajax.call([request])[0]}}})); +define("mod_forum/repository",["core/ajax"],(function(Ajax){return{setForumSubscriptionState:function(forumId,targetState){const request={methodname:"mod_forum_set_forum_subscription",args:{forumid:forumId,targetstate:targetState}};return Ajax.call([request])[0]},setForumTrackingState:function(forumId,targetState){const request={methodname:"mod_forum_set_forum_tracking",args:{forumid:forumId,targetstate:targetState}};return Ajax.call([request])[0]},setDiscussionSubscriptionState:function(forumId,discussionId,targetState){var request={methodname:"mod_forum_set_subscription_state",args:{forumid:forumId,discussionid:discussionId,targetstate:targetState}};return Ajax.call([request])[0]},addDiscussionPost:function(postid,subject,message,messageformat,isprivatereply,topreferredformat){var request={methodname:"mod_forum_add_discussion_post",args:{postid:postid,message:message,messageformat:messageformat,subject:subject,options:[{name:"private",value:isprivatereply},{name:"topreferredformat",value:topreferredformat}]}};return Ajax.call([request])[0]},setDiscussionLockState:function(forumId,discussionId,targetState){var request={methodname:"mod_forum_set_lock_state",args:{forumid:forumId,discussionid:discussionId,targetstate:targetState}};return Ajax.call([request])[0]},setFavouriteDiscussionState:function(forumId,discussionId,targetState){var request={methodname:"mod_forum_toggle_favourite_state",args:{discussionid:discussionId,targetstate:targetState}};return Ajax.call([request])[0]},setPinDiscussionState:function(forumid,discussionid,targetstate){var request={methodname:"mod_forum_set_pin_state",args:{discussionid:discussionid,targetstate:targetstate}};return Ajax.call([request])[0]},getDiscussionByUserID:function(userid,cmid){let sortby=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"modified",sortdirection=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"DESC";var request={methodname:"mod_forum_get_discussion_posts_by_userid",args:{userid:userid,cmid:cmid,sortby:sortby,sortdirection:sortdirection}};return Ajax.call([request])[0]},getDiscussionPosts:function(discussionId){let sortby=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"created",sortdirection=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"ASC";var request={methodname:"mod_forum_get_discussion_posts",args:{discussionid:discussionId,sortby:sortby,sortdirection:sortdirection}};return Ajax.call([request])[0]}}})); //# sourceMappingURL=repository.min.js.map \ No newline at end of file diff --git a/public/mod/forum/amd/build/repository.min.js.map b/public/mod/forum/amd/build/repository.min.js.map index ffe8436ff4c..65fd9080ef1 100644 --- a/public/mod/forum/amd/build/repository.min.js.map +++ b/public/mod/forum/amd/build/repository.min.js.map @@ -1 +1 @@ -{"version":3,"file":"repository.min.js","sources":["../src/repository.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 * Forum repository class to encapsulate all of the AJAX requests that subscribe or unsubscribe\n * can be sent for forum.\n *\n * @module mod_forum/repository\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['core/ajax'], function(Ajax) {\n /**\n * Set the subscription state for a discussion in a forum.\n *\n * @param {number} forumId ID of the forum the discussion belongs to\n * @param {number} discussionId ID of the discussion with the subscription state\n * @param {boolean} targetState Set the subscribed state. True == subscribed; false == unsubscribed.\n * @return {object} jQuery promise\n */\n var setDiscussionSubscriptionState = function(forumId, discussionId, targetState) {\n var request = {\n methodname: 'mod_forum_set_subscription_state',\n args: {\n forumid: forumId,\n discussionid: discussionId,\n targetstate: targetState\n }\n };\n return Ajax.call([request])[0];\n };\n\n var addDiscussionPost = function(postid, subject, message, messageformat, isprivatereply, topreferredformat) {\n var request = {\n methodname: 'mod_forum_add_discussion_post',\n args: {\n postid: postid,\n message: message,\n messageformat: messageformat,\n subject: subject,\n options: [{\n name: \"private\",\n value: isprivatereply,\n }, {\n name: \"topreferredformat\",\n value: topreferredformat,\n }]\n }\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Set the favourite state for a discussion in a forum.\n *\n * @param {number} forumId ID of the forum the discussion belongs to\n * @param {number} discussionId ID of the discussion with the subscription state\n * @param {null|date} targetState Set the favourite state. True == favourited; false == unfavourited.\n * @return {object} jQuery promise\n */\n var setFavouriteDiscussionState = function(forumId, discussionId, targetState) {\n var request = {\n methodname: 'mod_forum_toggle_favourite_state',\n args: {\n discussionid: discussionId,\n targetstate: targetState\n }\n };\n return Ajax.call([request])[0];\n };\n\n var setDiscussionLockState = function(forumId, discussionId, targetState) {\n var request = {\n methodname: 'mod_forum_set_lock_state',\n args: {\n forumid: forumId,\n discussionid: discussionId,\n targetstate: targetState}\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Set the pinned state for the discussion provided.\n *\n * @param {number} forumid\n * @param {number} discussionid\n * @param {boolean} targetstate\n * @return {*|Promise}\n */\n var setPinDiscussionState = function(forumid, discussionid, targetstate) {\n var request = {\n methodname: 'mod_forum_set_pin_state',\n args: {\n discussionid: discussionid,\n targetstate: targetstate\n }\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Get the discussions for the user and cmid provided.\n *\n * @param {number} userid\n * @param {number} cmid\n * @param {string} sortby\n * @param {string} sortdirection\n * @return {*|Promise}\n */\n var getDiscussionByUserID = function(userid, cmid, sortby = 'modified', sortdirection = 'DESC') {\n var request = {\n methodname: 'mod_forum_get_discussion_posts_by_userid',\n args: {\n userid: userid,\n cmid: cmid,\n sortby: sortby,\n sortdirection: sortdirection,\n },\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Get the posts for the discussion ID provided.\n *\n * @param {number} discussionId\n * @param {String} sortby\n * @param {String} sortdirection\n * @return {*|Promise}\n */\n var getDiscussionPosts = function(discussionId, sortby = 'created', sortdirection = 'ASC') {\n var request = {\n methodname: 'mod_forum_get_discussion_posts',\n args: {\n discussionid: discussionId,\n sortby: sortby,\n sortdirection: sortdirection,\n },\n };\n return Ajax.call([request])[0];\n };\n\n return {\n setDiscussionSubscriptionState: setDiscussionSubscriptionState,\n addDiscussionPost: addDiscussionPost,\n setDiscussionLockState: setDiscussionLockState,\n setFavouriteDiscussionState: setFavouriteDiscussionState,\n setPinDiscussionState: setPinDiscussionState,\n getDiscussionByUserID: getDiscussionByUserID,\n getDiscussionPosts: getDiscussionPosts,\n };\n});\n"],"names":["define","Ajax","setDiscussionSubscriptionState","forumId","discussionId","targetState","request","methodname","args","forumid","discussionid","targetstate","call","addDiscussionPost","postid","subject","message","messageformat","isprivatereply","topreferredformat","options","name","value","setDiscussionLockState","setFavouriteDiscussionState","setPinDiscussionState","getDiscussionByUserID","userid","cmid","sortby","sortdirection","getDiscussionPosts"],"mappings":";;;;;;;;AAuBAA,8BAAO,CAAC,cAAc,SAASC,YAoIpB,CACHC,+BA5HiC,SAASC,QAASC,aAAcC,iBAC7DC,QAAU,CACVC,WAAY,mCACZC,KAAM,CACFC,QAASN,QACTO,aAAcN,aACdO,YAAaN,qBAGdJ,KAAKW,KAAK,CAACN,UAAU,IAoH5BO,kBAjHoB,SAASC,OAAQC,QAASC,QAASC,cAAeC,eAAgBC,uBAClFb,QAAU,CACVC,WAAY,gCACZC,KAAM,CACFM,OAAQA,OACRE,QAASA,QACTC,cAAeA,cACfF,QAASA,QACTK,QAAS,CAAC,CACNC,KAAM,UACNC,MAAOJ,gBACR,CACCG,KAAM,oBACNC,MAAOH,6BAIZlB,KAAKW,KAAK,CAACN,UAAU,IAiG5BiB,uBA3EyB,SAASpB,QAASC,aAAcC,iBACrDC,QAAU,CACVC,WAAY,2BACZC,KAAM,CACFC,QAASN,QACTO,aAAcN,aACdO,YAAaN,qBAEdJ,KAAKW,KAAK,CAACN,UAAU,IAoE5BkB,4BAvF8B,SAASrB,QAASC,aAAcC,iBAC1DC,QAAU,CACVC,WAAY,mCACZC,KAAM,CACFE,aAAcN,aACdO,YAAaN,qBAGdJ,KAAKW,KAAK,CAACN,UAAU,IAgF5BmB,sBA1DwB,SAAShB,QAASC,aAAcC,iBACpDL,QAAU,CACVC,WAAY,0BACZC,KAAM,CACFE,aAAcA,aACdC,YAAaA,qBAGdV,KAAKW,KAAK,CAACN,UAAU,IAmD5BoB,sBAvCwB,SAASC,OAAQC,UAAMC,8DAAS,WAAYC,qEAAgB,WAChFxB,QAAU,CACVC,WAAY,2CACZC,KAAM,CACFmB,OAAQA,OACRC,KAAMA,KACNC,OAAQA,OACRC,cAAeA,uBAGhB7B,KAAKW,KAAK,CAACN,UAAU,IA8B5ByB,mBAnBqB,SAAS3B,kBAAcyB,8DAAS,UAAWC,qEAAgB,UAC5ExB,QAAU,CACVC,WAAY,iCACZC,KAAM,CACFE,aAAcN,aACdyB,OAAQA,OACRC,cAAeA,uBAGhB7B,KAAKW,KAAK,CAACN,UAAU"} \ No newline at end of file +{"version":3,"file":"repository.min.js","sources":["../src/repository.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 * Forum repository class to encapsulate all of the AJAX requests that subscribe or unsubscribe\n * can be sent for forum.\n *\n * @module mod_forum/repository\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['core/ajax'], function(Ajax) {\n /**\n * Set the subscription state for forum.\n *\n * @param {number} forumId ID of the forum to set the subscription state for\n * @param {boolean} targetState Set the subscribed state. True == subscribed; false == unsubscribed.\n * @return {object} jQuery promise\n */\n const setForumSubscriptionState = function(forumId, targetState) {\n const request = {\n methodname: 'mod_forum_set_forum_subscription',\n args: {\n forumid: forumId,\n targetstate: targetState\n }\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Set the tracking state for forum.\n *\n * @param {number} forumId ID of the forum to set the tracking state for\n * @param {boolean} targetState Set the tracking state for unread messages. True == track; false == untrack.\n * @return {object} jQuery promise\n */\n const setForumTrackingState = function(forumId, targetState) {\n const request = {\n methodname: 'mod_forum_set_forum_tracking',\n args: {\n forumid: forumId,\n targetstate: targetState\n }\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Set the subscription state for a discussion in a forum.\n *\n * @param {number} forumId ID of the forum the discussion belongs to\n * @param {number} discussionId ID of the discussion with the subscription state\n * @param {boolean} targetState Set the subscribed state. True == subscribed; false == unsubscribed.\n * @return {object} jQuery promise\n */\n var setDiscussionSubscriptionState = function(forumId, discussionId, targetState) {\n var request = {\n methodname: 'mod_forum_set_subscription_state',\n args: {\n forumid: forumId,\n discussionid: discussionId,\n targetstate: targetState\n }\n };\n return Ajax.call([request])[0];\n };\n\n var addDiscussionPost = function(postid, subject, message, messageformat, isprivatereply, topreferredformat) {\n var request = {\n methodname: 'mod_forum_add_discussion_post',\n args: {\n postid: postid,\n message: message,\n messageformat: messageformat,\n subject: subject,\n options: [{\n name: \"private\",\n value: isprivatereply,\n }, {\n name: \"topreferredformat\",\n value: topreferredformat,\n }]\n }\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Set the favourite state for a discussion in a forum.\n *\n * @param {number} forumId ID of the forum the discussion belongs to\n * @param {number} discussionId ID of the discussion with the subscription state\n * @param {null|date} targetState Set the favourite state. True == favourited; false == unfavourited.\n * @return {object} jQuery promise\n */\n var setFavouriteDiscussionState = function(forumId, discussionId, targetState) {\n var request = {\n methodname: 'mod_forum_toggle_favourite_state',\n args: {\n discussionid: discussionId,\n targetstate: targetState\n }\n };\n return Ajax.call([request])[0];\n };\n\n var setDiscussionLockState = function(forumId, discussionId, targetState) {\n var request = {\n methodname: 'mod_forum_set_lock_state',\n args: {\n forumid: forumId,\n discussionid: discussionId,\n targetstate: targetState}\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Set the pinned state for the discussion provided.\n *\n * @param {number} forumid\n * @param {number} discussionid\n * @param {boolean} targetstate\n * @return {*|Promise}\n */\n var setPinDiscussionState = function(forumid, discussionid, targetstate) {\n var request = {\n methodname: 'mod_forum_set_pin_state',\n args: {\n discussionid: discussionid,\n targetstate: targetstate\n }\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Get the discussions for the user and cmid provided.\n *\n * @param {number} userid\n * @param {number} cmid\n * @param {string} sortby\n * @param {string} sortdirection\n * @return {*|Promise}\n */\n var getDiscussionByUserID = function(userid, cmid, sortby = 'modified', sortdirection = 'DESC') {\n var request = {\n methodname: 'mod_forum_get_discussion_posts_by_userid',\n args: {\n userid: userid,\n cmid: cmid,\n sortby: sortby,\n sortdirection: sortdirection,\n },\n };\n return Ajax.call([request])[0];\n };\n\n /**\n * Get the posts for the discussion ID provided.\n *\n * @param {number} discussionId\n * @param {String} sortby\n * @param {String} sortdirection\n * @return {*|Promise}\n */\n var getDiscussionPosts = function(discussionId, sortby = 'created', sortdirection = 'ASC') {\n var request = {\n methodname: 'mod_forum_get_discussion_posts',\n args: {\n discussionid: discussionId,\n sortby: sortby,\n sortdirection: sortdirection,\n },\n };\n return Ajax.call([request])[0];\n };\n\n return {\n setForumSubscriptionState: setForumSubscriptionState,\n setForumTrackingState: setForumTrackingState,\n setDiscussionSubscriptionState: setDiscussionSubscriptionState,\n addDiscussionPost: addDiscussionPost,\n setDiscussionLockState: setDiscussionLockState,\n setFavouriteDiscussionState: setFavouriteDiscussionState,\n setPinDiscussionState: setPinDiscussionState,\n getDiscussionByUserID: getDiscussionByUserID,\n getDiscussionPosts: getDiscussionPosts,\n };\n});\n"],"names":["define","Ajax","setForumSubscriptionState","forumId","targetState","request","methodname","args","forumid","targetstate","call","setForumTrackingState","setDiscussionSubscriptionState","discussionId","discussionid","addDiscussionPost","postid","subject","message","messageformat","isprivatereply","topreferredformat","options","name","value","setDiscussionLockState","setFavouriteDiscussionState","setPinDiscussionState","getDiscussionByUserID","userid","cmid","sortby","sortdirection","getDiscussionPosts"],"mappings":";;;;;;;;AAuBAA,8BAAO,CAAC,cAAc,SAASC,YAwKpB,CACHC,0BAjK8B,SAASC,QAASC,mBAC1CC,QAAU,CACZC,WAAY,mCACZC,KAAM,CACFC,QAASL,QACTM,YAAaL,qBAGdH,KAAKS,KAAK,CAACL,UAAU,IA0J5BM,sBAhJ0B,SAASR,QAASC,mBACtCC,QAAU,CACZC,WAAY,+BACZC,KAAM,CACFC,QAASL,QACTM,YAAaL,qBAGdH,KAAKS,KAAK,CAACL,UAAU,IAyI5BO,+BA9HiC,SAAST,QAASU,aAAcT,iBAC7DC,QAAU,CACVC,WAAY,mCACZC,KAAM,CACFC,QAASL,QACTW,aAAcD,aACdJ,YAAaL,qBAGdH,KAAKS,KAAK,CAACL,UAAU,IAsH5BU,kBAnHoB,SAASC,OAAQC,QAASC,QAASC,cAAeC,eAAgBC,uBAClFhB,QAAU,CACVC,WAAY,gCACZC,KAAM,CACFS,OAAQA,OACRE,QAASA,QACTC,cAAeA,cACfF,QAASA,QACTK,QAAS,CAAC,CACNC,KAAM,UACNC,MAAOJ,gBACR,CACCG,KAAM,oBACNC,MAAOH,6BAIZpB,KAAKS,KAAK,CAACL,UAAU,IAmG5BoB,uBA7EyB,SAAStB,QAASU,aAAcT,iBACrDC,QAAU,CACVC,WAAY,2BACZC,KAAM,CACFC,QAASL,QACTW,aAAcD,aACdJ,YAAaL,qBAEdH,KAAKS,KAAK,CAACL,UAAU,IAsE5BqB,4BAzF8B,SAASvB,QAASU,aAAcT,iBAC1DC,QAAU,CACVC,WAAY,mCACZC,KAAM,CACFO,aAAcD,aACdJ,YAAaL,qBAGdH,KAAKS,KAAK,CAACL,UAAU,IAkF5BsB,sBA5DwB,SAASnB,QAASM,aAAcL,iBACpDJ,QAAU,CACVC,WAAY,0BACZC,KAAM,CACFO,aAAcA,aACdL,YAAaA,qBAGdR,KAAKS,KAAK,CAACL,UAAU,IAqD5BuB,sBAzCwB,SAASC,OAAQC,UAAMC,8DAAS,WAAYC,qEAAgB,WAChF3B,QAAU,CACVC,WAAY,2CACZC,KAAM,CACFsB,OAAQA,OACRC,KAAMA,KACNC,OAAQA,OACRC,cAAeA,uBAGhB/B,KAAKS,KAAK,CAACL,UAAU,IAgC5B4B,mBArBqB,SAASpB,kBAAckB,8DAAS,UAAWC,qEAAgB,UAC5E3B,QAAU,CACVC,WAAY,iCACZC,KAAM,CACFO,aAAcD,aACdkB,OAAQA,OACRC,cAAeA,uBAGhB/B,KAAKS,KAAK,CAACL,UAAU"} \ No newline at end of file diff --git a/public/mod/forum/amd/src/forum_overview_toggle.js b/public/mod/forum/amd/src/forum_overview_toggle.js new file mode 100644 index 00000000000..c1c10826671 --- /dev/null +++ b/public/mod/forum/amd/src/forum_overview_toggle.js @@ -0,0 +1,123 @@ +// 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 . + +/** + * Handle forum subscription/tracking toggling. + * + * @module mod_forum/forum_overview_toggle + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Notification from 'core/notification'; +import {getString} from 'core/str'; +import Repository from 'mod_forum/repository'; + +/** + * Register event listeners for the subscription/tracking toggles in the overview. + * @param {HTMLElement} toggleElement The toggle root element + */ +function registerEventListeners(toggleElement) { + toggleElement.addEventListener('change', () => { + if (toggleElement.dataset.type === 'forum-subscription-toggle') { + subscriptionToggleClickHandler(toggleElement); + } + if (toggleElement.dataset.type === 'forum-track-toggle') { + trackToggleClickHanldler(toggleElement); + } + }); +} + +/** + * Toggle subscription element click handler. + * + * @param {HTMLElement} toggleElement The toggle element that was clicked + * @return {Promise} + */ +async function subscriptionToggleClickHandler(toggleElement) { + const forumId = toggleElement.dataset.forumid; + const newState = toggleElement.dataset.targetstate; + if (!forumId || !newState) { + return; + } + try { + const context = await Repository.setForumSubscriptionState(forumId, newState); + const newTargetState = !!context.userstate.subscribed; + + updateSwitchState( + toggleElement, + newTargetState, + newTargetState ? 'subscribe' : 'unsubscribe', + ); + } catch (error) { + Notification.exception(error); + } +} + +/** + * Toggle track element click handler. + * + * @param {HTMLElement} toggleElement The toggle element that was clicked + * @return {Promise} + */ +async function trackToggleClickHanldler(toggleElement) { + const forumId = toggleElement.dataset.forumid; + const newState = toggleElement.dataset.targetstate; + if (!forumId || !newState) { + return; + } + try { + const context = await Repository.setForumTrackingState(forumId, newState); + const newTargetState = !!context.userstate.tracked; + + updateSwitchState( + toggleElement, + newTargetState, + newTargetState ? 'trackingon' : 'trackingoff', + ); + } catch (error) { + Notification.exception(error); + } +} + +/** + * Update the switch state of the toggle element. + * + * @param {HTMLElement} toggleElement The toggle element to update + * @param {Boolean} newTargetState The new target state to set (true for subscribed, false for unsubscribed) + * @param {string} stringKey The string key to retrieve the label text + * @return {Promise} + */ +async function updateSwitchState(toggleElement, newTargetState, stringKey) { + toggleElement.dataset.targetstate = newTargetState ? 0 : 1; + const string = await getString(stringKey, 'mod_forum'); + const label = toggleElement.closest('td').querySelector(`label[for="${toggleElement.id}"] span`); + label.textContent = string; +} + +/** + * Initialize the forum overview toggle functionality. + * + * @param {string} toggleSelector The CSS selector for the toggle element to initialize + * @throws {Error} If no elements are found with the provided selector + */ +export const init = (toggleSelector) => { + const toggleElement = document.querySelector(toggleSelector); + if (!toggleElement) { + // If the user cannot track/subscribe to any course forum, the toggle will not be present. + return; + } + registerEventListeners(toggleElement); +}; diff --git a/public/mod/forum/amd/src/repository.js b/public/mod/forum/amd/src/repository.js index 1da63cda19b..75a8a6322bf 100644 --- a/public/mod/forum/amd/src/repository.js +++ b/public/mod/forum/amd/src/repository.js @@ -22,6 +22,42 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/ajax'], function(Ajax) { + /** + * Set the subscription state for forum. + * + * @param {number} forumId ID of the forum to set the subscription state for + * @param {boolean} targetState Set the subscribed state. True == subscribed; false == unsubscribed. + * @return {object} jQuery promise + */ + const setForumSubscriptionState = function(forumId, targetState) { + const request = { + methodname: 'mod_forum_set_forum_subscription', + args: { + forumid: forumId, + targetstate: targetState + } + }; + return Ajax.call([request])[0]; + }; + + /** + * Set the tracking state for forum. + * + * @param {number} forumId ID of the forum to set the tracking state for + * @param {boolean} targetState Set the tracking state for unread messages. True == track; false == untrack. + * @return {object} jQuery promise + */ + const setForumTrackingState = function(forumId, targetState) { + const request = { + methodname: 'mod_forum_set_forum_tracking', + args: { + forumid: forumId, + targetstate: targetState + } + }; + return Ajax.call([request])[0]; + }; + /** * Set the subscription state for a discussion in a forum. * @@ -154,6 +190,8 @@ define(['core/ajax'], function(Ajax) { }; return { + setForumSubscriptionState: setForumSubscriptionState, + setForumTrackingState: setForumTrackingState, setDiscussionSubscriptionState: setDiscussionSubscriptionState, addDiscussionPost: addDiscussionPost, setDiscussionLockState: setDiscussionLockState, diff --git a/public/mod/forum/classes/external/set_forum_subscription.php b/public/mod/forum/classes/external/set_forum_subscription.php new file mode 100644 index 00000000000..97e12f93f1a --- /dev/null +++ b/public/mod/forum/classes/external/set_forum_subscription.php @@ -0,0 +1,122 @@ +. + +namespace mod_forum\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_forum\local\exporters\forum as forum_exporter; + +/** + * Web Service to control the state of a forum subscription + * + * @package mod_forum + * @category external + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class set_forum_subscription extends external_api { + /** + * Returns description of method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters( + [ + 'forumid' => new external_value(PARAM_INT, 'Forum that the user wants to subscribe or unsubscribe from'), + 'targetstate' => new external_value(PARAM_BOOL, 'The target state'), + ] + ); + } + + /** + * Set the forum subscription state. + * + * @param int $forumid The forum identifier. + * @param bool $targetstate Whether to subscribe or unsubscribe the user to the forum. + * @return \stdClass + */ + public static function execute( + int $forumid, + bool $targetstate, + ): \stdClass { + global $PAGE, $USER; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'forumid' => $forumid, + 'targetstate' => $targetstate, + ]); + + $vaultfactory = \mod_forum\local\container::get_vault_factory(); + $forumvault = $vaultfactory->get_forum_vault(); + $forum = $forumvault->get_from_id($params['forumid']); + if (!$forum) { + throw new \moodle_exception('invalidforumid', 'mod_forum', '', $params['forumid']); + } + $coursemodule = $forum->get_course_module_record(); + $context = $forum->get_context(); + + self::validate_context($context); + + $legacydatamapperfactory = \mod_forum\local\container::get_legacy_data_mapper_factory(); + $forumrecord = $legacydatamapperfactory->get_forum_data_mapper()->to_legacy_object($forum); + if ( + !\mod_forum\subscriptions::is_subscribable($forumrecord) + && !has_capability('mod/forum:managesubscriptions', $context) + ) { + // Nothing to do. We won't actually output any content here though. + throw new \moodle_exception('cannotsubscribe', 'mod_forum'); + } + + $issubscribed = \mod_forum\subscriptions::is_subscribed( + $USER->id, + $forumrecord, + null, + $coursemodule + ); + + // If the current state doesn't equal the desired state then update the current + // state to the desired state. + if ($issubscribed != (bool) $params['targetstate']) { + if ($params['targetstate']) { + \mod_forum\subscriptions::subscribe_user($USER->id, $forumrecord, $context, true); + } else { + \mod_forum\subscriptions::unsubscribe_user($USER->id, $forumrecord, $context, true); + } + } + + /** @var \mod_forum\local\factories\exporter $exporterfactory */ + $exporterfactory = \mod_forum\local\container::get_exporter_factory(); + $exporter = $exporterfactory->get_forum_exporter( + user: $USER, + forum: $forum, + currentgroup: null, + ); + return $exporter->export($PAGE->get_renderer('mod_forum')); + } + + /** + * Describe the return structure of the external service. + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return forum_exporter::get_read_structure(); + } +} diff --git a/public/mod/forum/classes/external/set_forum_tracking.php b/public/mod/forum/classes/external/set_forum_tracking.php new file mode 100644 index 00000000000..a4c8cb3de84 --- /dev/null +++ b/public/mod/forum/classes/external/set_forum_tracking.php @@ -0,0 +1,116 @@ +. + +namespace mod_forum\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_forum\local\exporters\forum as forum_exporter; + +/** + * Web Service to control the state of a forum tracking. + * + * @package mod_forum + * @category external + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class set_forum_tracking extends external_api { + /** + * Returns description of method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters( + [ + 'forumid' => new external_value(PARAM_INT, 'Forum that the user wants tracking for'), + 'targetstate' => new external_value(PARAM_BOOL, 'The target state'), + ] + ); + } + + /** + * Set the forum tracking state. + * + * @param int $forumid The forum identifier. + * @param bool $targetstate Whether to track or not the unread posts in the forum. + * @return \stdClass + */ + public static function execute( + int $forumid, + bool $targetstate, + ): \stdClass { + global $PAGE, $USER; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'forumid' => $forumid, + 'targetstate' => $targetstate, + ]); + + $vaultfactory = \mod_forum\local\container::get_vault_factory(); + $forumvault = $vaultfactory->get_forum_vault(); + $forum = $forumvault->get_from_id($params['forumid']); + if (!$forum) { + throw new \moodle_exception('invalidforumid', 'mod_forum', '', $params['forumid']); + } + $context = $forum->get_context(); + + self::validate_context($context); + + $legacydatamapperfactory = \mod_forum\local\container::get_legacy_data_mapper_factory(); + $forumrecord = $legacydatamapperfactory->get_forum_data_mapper()->to_legacy_object($forum); + + $usetracking = forum_tp_can_track_forums($forum); + if (!$usetracking) { + // Nothing to do. We won't actually output any content here though. + throw new \moodle_exception('cannottrack', 'mod_forum'); + } + + $istracked = forum_tp_is_tracked($forumrecord); + // If the current state doesn't equal the desired state then update the current + // state to the desired state. + if ($istracked != (bool) $params['targetstate']) { + if ($params['targetstate']) { + forum_tp_start_tracking($forumrecord->id); + } else { + forum_tp_stop_tracking($forumrecord->id); + } + $cache = \cache::make('mod_forum', 'forum_is_tracked'); + $cache->purge(); + } + + /** @var \mod_forum\local\factories\exporter $exporterfactory */ + $exporterfactory = \mod_forum\local\container::get_exporter_factory(); + $exporter = $exporterfactory->get_forum_exporter( + user: $USER, + forum: $forum, + currentgroup: null, + ); + return $exporter->export($PAGE->get_renderer('mod_forum')); + } + + /** + * Describe the return structure of the external service. + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return forum_exporter::get_read_structure(); + } +} diff --git a/public/mod/forum/classes/local/exporters/forum.php b/public/mod/forum/classes/local/exporters/forum.php index ef8e547cf67..90e9af3f353 100644 --- a/public/mod/forum/classes/local/exporters/forum.php +++ b/public/mod/forum/classes/local/exporters/forum.php @@ -71,6 +71,7 @@ class forum extends exporter { 'userstate' => [ 'type' => [ 'tracked' => ['type' => PARAM_INT], + 'subscribed' => ['type' => PARAM_INT], ], ], 'capabilities' => [ @@ -125,7 +126,11 @@ class forum extends exporter { 'gradingenabled' => $this->forum->is_grading_enabled() ], 'userstate' => [ - 'tracked' => forum_tp_is_tracked($this->get_forum_record(), $this->related['user']), + 'tracked' => (int) forum_tp_is_tracked($this->get_forum_record(), $this->related['user']), + 'subscribed' => (int) \mod_forum\subscriptions::is_subscribed( + $this->related['user']->id, + $this->get_forum_record(), + ), ], 'capabilities' => [ 'viewdiscussions' => $capabilitymanager->can_view_discussions($user), diff --git a/public/mod/forum/db/services.php b/public/mod/forum/db/services.php index 4977e5a46f7..986cf7413ef 100644 --- a/public/mod/forum/db/services.php +++ b/public/mod/forum/db/services.php @@ -198,4 +198,21 @@ $functions = array( 'type' => 'write', 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE) ), + 'mod_forum_set_forum_subscription' => [ + 'classname' => 'mod_forum\external\set_forum_subscription', + 'methodname' => 'execute', + 'description' => 'Subscribe or unsubscribe the user to a forum.', + 'type' => 'write', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], + + 'mod_forum_set_forum_tracking' => [ + 'classname' => 'mod_forum\external\set_forum_tracking', + 'methodname' => 'execute', + 'description' => 'Track or not unread messages in a forum for the user.', + 'type' => 'write', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], ); diff --git a/public/mod/forum/lib.php b/public/mod/forum/lib.php index 6deb23aead6..09e6688bd91 100644 --- a/public/mod/forum/lib.php +++ b/public/mod/forum/lib.php @@ -4660,8 +4660,14 @@ function forum_tp_can_track_forums($forum=false, $user=false) { $forum = $DB->get_record('forum', array('id' => $forum), '', 'id,trackingtype'); } - $forumallows = ($forum->trackingtype == FORUM_TRACKING_OPTIONAL); - $forumforced = ($forum->trackingtype == FORUM_TRACKING_FORCED); + if (method_exists($forum, 'get_tracking_type')) { + $trackingtype = $forum->get_tracking_type(); + } else { + $trackingtype = $forum->trackingtype; + } + + $forumallows = ($trackingtype == FORUM_TRACKING_OPTIONAL); + $forumforced = ($trackingtype == FORUM_TRACKING_FORCED); if ($CFG->forum_allowforcedreadtracking) { // If we allow forcing, then forced forums takes procidence over user setting. diff --git a/public/mod/forum/tests/exporters_forum_test.php b/public/mod/forum/tests/exporters_forum_test.php index 1b806a5a9e6..100e7dc569d 100644 --- a/public/mod/forum/tests/exporters_forum_test.php +++ b/public/mod/forum/tests/exporters_forum_test.php @@ -16,7 +16,11 @@ namespace mod_forum; -use mod_forum\local\entities\forum as forum_entity; +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/mod/forum/lib.php'); + use mod_forum\local\exporters\forum as forum_exporter; /** @@ -27,6 +31,23 @@ use mod_forum\local\exporters\forum as forum_exporter; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class exporters_forum_test extends \advanced_testcase { + + #[\Override] + public function setUp(): void { + parent::setUp(); + // We must clear the subscription caches. + // This has to be done both before each test, and after in case of other tests using these functions. + subscriptions::reset_forum_cache(); + } + + #[\Override] + public function tearDown(): void { + // We must clear the subscription caches. + // // This has to be done both before each test, and after in case of other tests using these functions. + subscriptions::reset_forum_cache(); + parent::tearDown(); + } + /** * Test the export function returns expected values. */ @@ -40,7 +61,8 @@ final class exporters_forum_test extends \advanced_testcase { $course = $datagenerator->create_course(); $forum = $datagenerator->create_module('forum', [ 'course' => $course->id, - 'groupmode' => VISIBLEGROUPS + 'groupmode' => VISIBLEGROUPS, + 'forcesubscribe' => FORUM_FORCESUBSCRIBE, ]); $coursemodule = get_coursemodule_from_instance('forum', $forum->id); $context = \context_module::instance($coursemodule->id); @@ -61,10 +83,17 @@ final class exporters_forum_test extends \advanced_testcase { $this->assertEquals($forum->get_id(), $exportedforum->id); $this->assertEquals(VISIBLEGROUPS, $exportedforum->state['groupmode']); $this->assertEquals(false, $exportedforum->userstate['tracked']); + $this->assertEquals(false, $exportedforum->userstate['subscribed']); $this->assertEquals(false, $exportedforum->capabilities['viewdiscussions']); $this->assertEquals(false, $exportedforum->capabilities['create']); $this->assertEquals(false, $exportedforum->capabilities['subscribe']); $this->assertNotEquals(null, $exportedforum->urls['create']); $this->assertNotEquals(null, $exportedforum->urls['markasread']); + + // Enrol the user in the course and check the capabilities and user state. + $datagenerator->enrol_user($user->id, $course->id); + $exportedforum = $exporter->export($renderer); + $this->assertEquals(true, $exportedforum->userstate['subscribed']); + $this->assertEquals(true, $exportedforum->capabilities['viewdiscussions']); } } diff --git a/public/mod/forum/tests/external/set_forum_subscription_test.php b/public/mod/forum/tests/external/set_forum_subscription_test.php new file mode 100644 index 00000000000..f30cd1815e3 --- /dev/null +++ b/public/mod/forum/tests/external/set_forum_subscription_test.php @@ -0,0 +1,237 @@ +. + +namespace mod_forum\external; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); +require_once($CFG->dirroot . '/mod/forum/lib.php'); + +use core_external\external_api; +use mod_forum\external\set_forum_subscription; +use mod_forum\subscriptions; + +/** + * Tests for the set_forum_subscription external function. + * + * @package mod_forum + * @category test + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_forum\external\set_forum_subscription + */ +final class set_forum_subscription_test extends \externallib_advanced_testcase { + + #[\Override] + public function setUp(): void { + parent::setUp(); + // We must clear the subscription caches. + // This has to be done both before each test, and after in case of other tests using these functions. + subscriptions::reset_forum_cache(); + } + + #[\Override] + public function tearDown(): void { + // We must clear the subscription caches. + // This has to be done both before each test, and after in case of other tests using these functions. + subscriptions::reset_forum_cache(); + parent::tearDown(); + } + + /** + * Test execute method. + * + * @dataProvider execute_provider + * @covers ::execute + * + * @param bool|null $initialstate Initialise subscription state, null means no initial state. + * @param bool $targetstate Expected target state of the subscription. + * @param int $subscriptionmode Subscription mode for the forum. + * @param bool $expectedexception Whether an exception is expected. + */ + public function test_execute( + ?bool $initialstate, + bool $targetstate, + int $subscriptionmode = FORUM_CHOOSESUBSCRIBE, + bool $expectedexception = false, + ): void { + + $this->resetAfterTest(); + + $user = self::getDataGenerator()->create_user(); + $course = self::getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $this->setUser($user); + + $forum = self::getDataGenerator()->create_module( + 'forum', + [ + 'course' => $course->id, + 'forcesubscribe' => $subscriptionmode, + ] + ); + + if ($expectedexception) { + $this->expectException(\moodle_exception::class); + } else if ($initialstate !== null) { + // Set the initial state of the subscription. + if ($initialstate) { + subscriptions::subscribe_user($user->id, $forum); + } else { + subscriptions::unsubscribe_user($user->id, $forum); + } + $this->assertEquals($initialstate, subscriptions::is_subscribed($user->id, $forum)); + } + + $return = external_api::clean_returnvalue( + set_forum_subscription::execute_returns(), + set_forum_subscription::execute($forum->id, $targetstate), + ); + $this->assertEquals($targetstate, $return['userstate']['subscribed']); + $this->assertEquals($targetstate, subscriptions::is_subscribed($user->id, $forum)); + } + + /** + * Data provider for test_execute. + * + * @return array The data provider array. + */ + public static function execute_provider(): array { + return [ + 'Subscription initially false, set to true' => [ + 'initialstate' => false, + 'targetstate' => true, + ], + 'Subscription initially true, set to false' => [ + 'initialstate' => true, + 'targetstate' => false, + ], + 'Subscription initially false, set to false' => [ + 'initialstate' => false, + 'targetstate' => false, + ], + 'Subscription initially true, set to true' => [ + 'initialstate' => true, + 'targetstate' => true, + ], + 'Subscription forced on' => [ + 'initialstate' => null, + 'targetstate' => true, + 'subscriptionmode' => FORUM_FORCESUBSCRIBE, + 'expectedexception' => true, + ], + 'Subscription forced off' => [ + 'initialstate' => null, + 'targetstate' => false, + 'subscriptionmode' => FORUM_DISALLOWSUBSCRIBE, + 'expectedexception' => true, + ], + 'Subscription initial on' => [ + 'initialstate' => true, + 'targetstate' => false, + 'subscriptionmode' => FORUM_INITIALSUBSCRIBE, + ], + ]; + } + + /** + * Test execute method when forum is not subscribable. + * + * @covers ::execute + */ + public function test_execute_not_subscribable(): void { + + $this->resetAfterTest(); + + $admin = get_admin(); + $teacher = self::getDataGenerator()->create_user(); + $student = self::getDataGenerator()->create_user(); + $course = self::getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher'); + $this->getDataGenerator()->enrol_user($student->id, $course->id); + + $forum = self::getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + 'forcesubscribe' => FORUM_DISALLOWSUBSCRIBE, + ]); + + // Admin user can subscribe to a forum that does not allow subscriptions. + $this->setAdminUser(); + $return = external_api::clean_returnvalue( + set_forum_subscription::execute_returns(), + set_forum_subscription::execute($forum->id, true), + ); + $this->assertEquals(true, $return['userstate']['subscribed']); + $this->assertEquals(true, subscriptions::is_subscribed($admin->id, $forum)); + + // Teacher user can subscribe to a forum that does not allow subscriptions because they have the capability. + $this->setUser($teacher); + $return = external_api::clean_returnvalue( + set_forum_subscription::execute_returns(), + set_forum_subscription::execute($forum->id, true), + ); + $this->assertEquals(true, $return['userstate']['subscribed']); + $this->assertEquals(true, subscriptions::is_subscribed($teacher->id, $forum)); + + // Attempt to subscribe to a forum that does not allow subscriptions without the required capability. + $this->setUser($student); + $this->expectException(\moodle_exception::class); + external_api::clean_returnvalue( + set_forum_subscription::execute_returns(), + set_forum_subscription::execute($forum->id, true), + ); + } + + /** + * Test execute method when forum does not exist. + * + * @covers ::execute + */ + public function test_execute_unexisting_forum(): void { + + $this->resetAfterTest(); + + $this->setAdminUser(); + $this->expectException(\moodle_exception::class); + external_api::clean_returnvalue( + set_forum_subscription::execute_returns(), + set_forum_subscription::execute(9999, true), + ); + } + + /** + * Test execute method when user is not enrolled in the course of the forum. + * + * @covers ::execute + */ + public function test_execute_unenrolled_user(): void { + + $this->resetAfterTest(); + + $user = self::getDataGenerator()->create_user(); + $course = self::getDataGenerator()->create_course(); + $this->setUser($user); + $forum = self::getDataGenerator()->create_module('forum', ['course' => $course->id]); + + $this->expectException(\moodle_exception::class); + external_api::clean_returnvalue( + set_forum_subscription::execute_returns(), + set_forum_subscription::execute($forum->id, true), + ); + } +} diff --git a/public/mod/forum/tests/external/set_forum_tracking_test.php b/public/mod/forum/tests/external/set_forum_tracking_test.php new file mode 100644 index 00000000000..f4665a177cd --- /dev/null +++ b/public/mod/forum/tests/external/set_forum_tracking_test.php @@ -0,0 +1,210 @@ +. + +namespace mod_forum\external; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); +require_once($CFG->dirroot . '/mod/forum/lib.php'); + +use core_external\external_api; +use mod_forum\external\set_forum_tracking; +use mod_forum\subscriptions; + +/** + * Tests for the set_forum_tracking external function. + * + * @package mod_forum + * @category test + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_forum\external\set_forum_tracking + */ +final class set_forum_tracking_test extends \externallib_advanced_testcase { + + #[\Override] + public function setUp(): void { + parent::setUp(); + // We must clear the subscription caches. + // This has to be done both before each test, and after in case of other tests using these functions. + subscriptions::reset_forum_cache(); + } + + #[\Override] + public function tearDown(): void { + // We must clear the subscription caches. + // This has to be done both before each test, and after in case of other tests using these functions. + subscriptions::reset_forum_cache(); + parent::tearDown(); + } + + /** + * Test execute method. + * + * @dataProvider execute_provider + * @covers ::execute + * + * @param bool|null $initialstate Initialise tracking state, null means no initial state. + * @param bool $targetstate Expected target state of the tracking. + * @param int $forumtype Tracking mode for the forum. + * @param bool $expectedexception Whether an exception is expected. + */ + public function test_execute( + ?bool $initialstate, + bool $targetstate, + int $forumtype = FORUM_TRACKING_OPTIONAL, + bool $expectedexception = false, + ): void { + + global $CFG; + + $this->resetAfterTest(); + + // Allow force. + $CFG->forum_allowforcedreadtracking = 1; + + $user = self::getDataGenerator()->create_user(['trackforums' => 1]); + $course = self::getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $forum = self::getDataGenerator()->create_module( + 'forum', + [ + 'course' => $course->id, + 'trackingtype' => $forumtype, + ], + ); + + $this->setUser($user); + + if ($expectedexception) { + $this->expectException(\moodle_exception::class); + } else if ($initialstate !== null) { + // Set the initial state of the subscription. + if ($initialstate) { + forum_tp_start_tracking($forum->id); + } else { + forum_tp_stop_tracking($forum->id); + } + $this->assertEquals($initialstate, forum_tp_is_tracked($forum)); + } + $return = external_api::clean_returnvalue( + set_forum_tracking::execute_returns(), + set_forum_tracking::execute($forum->id, $targetstate), + ); + $this->assertEquals($targetstate, $return['userstate']['tracked']); + $this->assertEquals($targetstate, forum_tp_is_tracked($forum)); + } + + /** + * Data provider for test_execute. + * + * @return array The data provider array. + */ + public static function execute_provider(): array { + return [ + 'Initially false, set to true' => [ + 'initialstate' => false, + 'targetstate' => true, + ], + 'Initially false, set to false' => [ + 'initialstate' => false, + 'targetstate' => false, + ], + 'Initially true, set to false' => [ + 'initialstate' => true, + 'targetstate' => false, + ], + 'Initially true, set to true' => [ + 'initialstate' => true, + 'targetstate' => true, + ], + 'Forced off' => [ + 'initialstate' => null, + 'targetstate' => false, + 'forumtype' => FORUM_TRACKING_OFF, + 'expectedexception' => true, + ], + 'Forced on' => [ + 'initialstate' => null, + 'targetstate' => true, + 'forumtype' => FORUM_TRACKING_FORCED, + ], + ]; + } + + /** + * Test execute method when tracking is not enabled for the user. + * + * @covers ::execute + */ + public function test_execute_no_tracking(): void { + + $this->resetAfterTest(); + + $user = self::getDataGenerator()->create_user(['trackforums' => 0]); + $course = self::getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $forum = self::getDataGenerator()->create_module('forum', [ 'course' => $course->id]); + + $this->setUser($user); + + $this->expectException(\moodle_exception::class); + external_api::clean_returnvalue( + set_forum_tracking::execute_returns(), + set_forum_tracking::execute($forum->id, true), + ); + } + + /** + * Test execute method when forum does not exist. + * + * @covers ::execute + */ + public function test_execute_unexisting_forum(): void { + + $this->resetAfterTest(); + + $this->setAdminUser(); + $this->expectException(\moodle_exception::class); + external_api::clean_returnvalue( + set_forum_tracking::execute_returns(), + set_forum_tracking::execute(9999, true), + ); + } + + /** + * Test execute method when user is not enrolled in the course of the forum. + * + * @covers ::execute + */ + public function test_execute_unenrolled_user(): void { + + $this->resetAfterTest(); + + $user = self::getDataGenerator()->create_user(); + $course = self::getDataGenerator()->create_course(); + $this->setUser($user); + $forum = self::getDataGenerator()->create_module('forum', ['course' => $course->id]); + + $this->expectException(\moodle_exception::class); + external_api::clean_returnvalue( + set_forum_tracking::execute_returns(), + set_forum_tracking::execute($forum->id, true), + ); + } +} diff --git a/public/mod/forum/tests/externallib_test.php b/public/mod/forum/tests/externallib_test.php index b87443a5394..1a4b69c1a22 100644 --- a/public/mod/forum/tests/externallib_test.php +++ b/public/mod/forum/tests/externallib_test.php @@ -195,6 +195,12 @@ final class externallib_test extends externallib_advanced_testcase { $expectedforums[$forum1->id] = (array) $forum1; $expectedforums[$forum2->id] = (array) $forum2; + // Reset static cache. + $forum1cm = get_coursemodule_from_id('forum', $forum1->cmid); + forum_tp_count_forum_unread_posts($forum1cm, $course1, true); + $forum2cm = get_coursemodule_from_id('forum', $forum2->cmid); + forum_tp_count_forum_unread_posts($forum2cm, $course2, true); + // Call the external function passing course ids. $forums = mod_forum_external::get_forums_by_courses(array($course1->id, $course2->id)); $forums = external_api::clean_returnvalue(mod_forum_external::get_forums_by_courses_returns(), $forums); diff --git a/public/mod/forum/version.php b/public/mod/forum/version.php index 572226a93ea..c0faeca9f18 100644 --- a/public/mod/forum/version.php +++ b/public/mod/forum/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025041401; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2025041402; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2025040800; // Requires this Moodle version. $plugin->component = 'mod_forum'; // Full name of the plugin (used for diagnostics)