From dc2a09d59a01de805fa3fa082df01c3703aba4fe Mon Sep 17 00:00:00 2001 From: Mark Johnson Date: Tue, 21 May 2024 10:48:45 +0100 Subject: [PATCH] MDL-81514 groups: Add participationonly option to activity group menu groups_print_activity_menu() and groups_get_activity_group() now include an additional $participationonly parameter, which is true by default. This can be set false when we want the user to be able to select a non-participation group within an activity, for example if a teacher wants to filter assignment submissions by non-participation groups. It should never be used when the menu is displayed to students, as this may allow them to participate using non-participation groups. groups_sort_menu_options() now has a $splitparticipation parameter, which will split non-participation groups out into their own optgroup at the end of the menu. --- .upgradenotes/MDL-81514-2024061009100437.yml | 13 ++ .../output/actionbar/group_selector.php | 18 ++- .../amd/build/comboboxsearch/group.min.js | 2 +- .../amd/build/comboboxsearch/group.min.js.map | 2 +- public/group/amd/src/comboboxsearch/group.js | 1 + .../external/get_groups_for_selector.php | 8 +- .../comboboxsearch/resultitem.mustache | 5 + .../comboboxsearch/resultset.mustache | 12 +- public/lang/en/group.php | 1 + public/lib/grouplib.php | 97 ++++++++++++--- public/lib/tests/grouplib_test.php | 82 ++++++++++++- public/theme/boost/scss/moodle/dropdown.scss | 5 +- public/theme/boost/style/moodle.css | 112 +++++++++++++++--- public/theme/classic/style/moodle.css | 112 +++++++++++++++--- 14 files changed, 405 insertions(+), 65 deletions(-) create mode 100644 .upgradenotes/MDL-81514-2024061009100437.yml diff --git a/.upgradenotes/MDL-81514-2024061009100437.yml b/.upgradenotes/MDL-81514-2024061009100437.yml new file mode 100644 index 00000000000..adbcd912c01 --- /dev/null +++ b/.upgradenotes/MDL-81514-2024061009100437.yml @@ -0,0 +1,13 @@ +issueNumber: MDL-81514 +notes: + core_group: + - message: > + `groups_print_activity_menu()` and `groups_get_activity_group()` now + include an additional `$participationonly` parameter, which is true by + default. This can be set false when we want the user to be able to + select a non-participation group within an activity, for example if a + teacher wants to filter assignment submissions by non-participation + groups. It should never be used when the menu is displayed to students, + as this may allow them to participate using non-participation groups. + Non-participation groups are labeled as such. + type: improved diff --git a/public/course/classes/output/actionbar/group_selector.php b/public/course/classes/output/actionbar/group_selector.php index df0b35697ce..a439497c46b 100644 --- a/public/course/classes/output/actionbar/group_selector.php +++ b/public/course/classes/output/actionbar/group_selector.php @@ -35,8 +35,18 @@ class group_selector extends comboboxsearch { * The class constructor. * * @param stdClass $context The context object. + * @param bool $participationonly Only include participation groups? */ - public function __construct(private stdClass $context) { + public function __construct( + /** + * @var stdClass The context object. + */ + private stdClass $context, + /** + * @var bool Only include participation groups? + */ + protected bool $participationonly = true, + ) { $this->activegroup = $this->get_active_group(); $this->label = $this->get_label(); @@ -100,22 +110,20 @@ class group_selector extends comboboxsearch { if ($this->context->contextlevel === CONTEXT_MODULE) { $cm = get_coursemodule_from_id(false, $this->context->instanceid); $groupingid = $cm->groupingid; - $participationonly = true; } else { $cm = null; $groupingid = $course->defaultgroupingid; - $participationonly = false; } $allowedgroups = groups_get_all_groups( courseid: $course->id, userid: $userid, groupingid: $groupingid, - participationonly: $participationonly + participationonly: $this->participationonly, ); if ($cm) { - return groups_get_activity_group($cm, true, $allowedgroups); + return groups_get_activity_group($cm, true, $allowedgroups, $this->participationonly); } return groups_get_course_group($course, true, $allowedgroups); } diff --git a/public/group/amd/build/comboboxsearch/group.min.js b/public/group/amd/build/comboboxsearch/group.min.js index 522cab10779..28e7d1f34c7 100644 --- a/public/group/amd/build/comboboxsearch/group.min.js +++ b/public/group/amd/build/comboboxsearch/group.min.js @@ -1,3 +1,3 @@ -define("core_group/comboboxsearch/group",["exports","core/comboboxsearch/search_combobox","core_group/comboboxsearch/repository","core/templates","core/utils","core/notification"],(function(_exports,_search_combobox,_repository,_templates,_utils,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=_interopRequireDefault(_search_combobox),_notification=_interopRequireDefault(_notification);class GroupSearch extends _search_combobox.default{constructor(){let cmid=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null;super(),_defineProperty(this,"courseID",void 0),_defineProperty(this,"cmID",void 0),_defineProperty(this,"bannedFilterFields",["id","link","groupimageurl"]),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',placeholder:'.groupsearchdropdown [data-region="searchplaceholder"]'};const component=document.querySelector(this.componentSelector());this.courseID=component.querySelector(this.selectors.courseid).dataset.courseid,this.instance=component.querySelector(this.selectors.instance).dataset.instance,this.cmID=cmid;const searchValueElement=this.component.querySelector("#".concat(this.searchInput.dataset.inputElement));searchValueElement.addEventListener("change",(()=>{this.toggleDropdown();const valueElement=this.component.querySelector("#".concat(this.combobox.dataset.inputElement));valueElement.value!==searchValueElement.value&&(valueElement.value=searchValueElement.value,valueElement.dispatchEvent(new Event("change",{bubbles:!0}))),searchValueElement.value=""})),this.component.addEventListener("hide.bs.dropdown",(()=>{this.searchInput.removeAttribute("aria-activedescendant");const listbox=document.querySelector("#".concat(this.searchInput.getAttribute("aria-controls"),'[role="listbox"]'));listbox.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")})),listbox.scrollTop=0,setTimeout((()=>{""!==this.searchInput.value&&(this.searchInput.value="",this.searchInput.dispatchEvent(new Event("input",{bubbles:!0})))}))})),this.renderDefault().catch(_notification.default.exception)}static init(){return new GroupSearch(arguments.length>0&&void 0!==arguments[0]?arguments[0]:null)}componentSelector(){return".group-search"}dropdownSelector(){return".groupsearchdropdown"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_group/comboboxsearch/resultset",{groups:this.getMatchedResults(),hasresults:this.getMatchedResults().length>0,instance:this.instance,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.selectors.placeholder,html,js),this.searchInput.removeAttribute("aria-activedescendant")}async renderDefault(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown(),this.updateNodes()}async fetchDataset(){return await(0,_repository.groupFetch)(this.courseID,this.cmID).then((r=>r.groups))}async filterDataset(filterableData){return""===this.getPreppedSearchTerm()?filterableData:filterableData.filter((group=>Object.keys(group).some((key=>""!==group[key]&&!this.bannedFilterFields.includes(key)&&group[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((group=>({id:group.id,name:group.name,groupimageurl:group.groupimageurl}))))}async clickHandler(e){e.target.closest(this.selectors.clearSearch)&&(e.stopPropagation(),this.searchInput.value="",this.setSearchTerms(this.searchInput.value),this.searchInput.focus(),this.clearSearchButton.classList.add("d-none"),await this.filterrenderpipe())}changeHandler(e){window.location=this.selectOneLink(e.target.value)}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm()?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none"),await this.filterrenderpipe()}),300))}selectOneLink(groupID){throw new Error("selectOneLink(".concat(groupID,") must be implemented in ").concat(this.constructor.name))}}return _exports.default=GroupSearch,_exports.default})); +define("core_group/comboboxsearch/group",["exports","core/comboboxsearch/search_combobox","core_group/comboboxsearch/repository","core/templates","core/utils","core/notification"],(function(_exports,_search_combobox,_repository,_templates,_utils,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=_interopRequireDefault(_search_combobox),_notification=_interopRequireDefault(_notification);class GroupSearch extends _search_combobox.default{constructor(){let cmid=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null;super(),_defineProperty(this,"courseID",void 0),_defineProperty(this,"cmID",void 0),_defineProperty(this,"bannedFilterFields",["id","link","groupimageurl"]),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',placeholder:'.groupsearchdropdown [data-region="searchplaceholder"]'};const component=document.querySelector(this.componentSelector());this.courseID=component.querySelector(this.selectors.courseid).dataset.courseid,this.instance=component.querySelector(this.selectors.instance).dataset.instance,this.cmID=cmid;const searchValueElement=this.component.querySelector("#".concat(this.searchInput.dataset.inputElement));searchValueElement.addEventListener("change",(()=>{this.toggleDropdown();const valueElement=this.component.querySelector("#".concat(this.combobox.dataset.inputElement));valueElement.value!==searchValueElement.value&&(valueElement.value=searchValueElement.value,valueElement.dispatchEvent(new Event("change",{bubbles:!0}))),searchValueElement.value=""})),this.component.addEventListener("hide.bs.dropdown",(()=>{this.searchInput.removeAttribute("aria-activedescendant");const listbox=document.querySelector("#".concat(this.searchInput.getAttribute("aria-controls"),'[role="listbox"]'));listbox.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")})),listbox.scrollTop=0,setTimeout((()=>{""!==this.searchInput.value&&(this.searchInput.value="",this.searchInput.dispatchEvent(new Event("input",{bubbles:!0})))}))})),this.renderDefault().catch(_notification.default.exception)}static init(){return new GroupSearch(arguments.length>0&&void 0!==arguments[0]?arguments[0]:null)}componentSelector(){return".group-search"}dropdownSelector(){return".groupsearchdropdown"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_group/comboboxsearch/resultset",{groups:this.getMatchedResults(),hasresults:this.getMatchedResults().length>0,instance:this.instance,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.selectors.placeholder,html,js),this.searchInput.removeAttribute("aria-activedescendant")}async renderDefault(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown(),this.updateNodes()}async fetchDataset(){return await(0,_repository.groupFetch)(this.courseID,this.cmID).then((r=>r.groups))}async filterDataset(filterableData){return""===this.getPreppedSearchTerm()?filterableData:filterableData.filter((group=>Object.keys(group).some((key=>""!==group[key]&&!this.bannedFilterFields.includes(key)&&group[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((group=>({id:group.id,name:group.name,groupimageurl:group.groupimageurl,participation:group.participation}))))}async clickHandler(e){e.target.closest(this.selectors.clearSearch)&&(e.stopPropagation(),this.searchInput.value="",this.setSearchTerms(this.searchInput.value),this.searchInput.focus(),this.clearSearchButton.classList.add("d-none"),await this.filterrenderpipe())}changeHandler(e){window.location=this.selectOneLink(e.target.value)}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm()?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none"),await this.filterrenderpipe()}),300))}selectOneLink(groupID){throw new Error("selectOneLink(".concat(groupID,") must be implemented in ").concat(this.constructor.name))}}return _exports.default=GroupSearch,_exports.default})); //# sourceMappingURL=group.min.js.map \ No newline at end of file diff --git a/public/group/amd/build/comboboxsearch/group.min.js.map b/public/group/amd/build/comboboxsearch/group.min.js.map index 6d43dfa720a..601bd76bef3 100644 --- a/public/group/amd/build/comboboxsearch/group.min.js.map +++ b/public/group/amd/build/comboboxsearch/group.min.js.map @@ -1 +1 @@ -{"version":3,"file":"group.min.js","sources":["../../src/comboboxsearch/group.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 * Allow the user to search for groups.\n *\n * @module core_group/comboboxsearch/group\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport {groupFetch} from 'core_group/comboboxsearch/repository';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport {debounce} from 'core/utils';\nimport Notification from 'core/notification';\n\nexport default class GroupSearch extends search_combobox {\n\n courseID;\n cmID;\n bannedFilterFields = ['id', 'link', 'groupimageurl'];\n\n /**\n * Construct the class.\n *\n * @param {int|null} cmid ID of the course module initiating the group search (optional).\n */\n constructor(cmid = null) {\n super();\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n placeholder: '.groupsearchdropdown [data-region=\"searchplaceholder\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n // Override the instance since the body is built outside the constructor for the combobox.\n this.instance = component.querySelector(this.selectors.instance).dataset.instance;\n this.cmID = cmid;\n\n const searchValueElement = this.component.querySelector(`#${this.searchInput.dataset.inputElement}`);\n searchValueElement.addEventListener('change', () => {\n this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.\n\n const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);\n if (valueElement.value !== searchValueElement.value) {\n valueElement.value = searchValueElement.value;\n valueElement.dispatchEvent(new Event('change', {bubbles: true}));\n }\n\n searchValueElement.value = '';\n });\n\n this.component.addEventListener('hide.bs.dropdown', () => {\n this.searchInput.removeAttribute('aria-activedescendant');\n\n const listbox = document.querySelector(`#${this.searchInput.getAttribute('aria-controls')}[role=\"listbox\"]`);\n listbox.querySelectorAll('.active[role=\"option\"]').forEach(option => {\n option.classList.remove('active');\n });\n listbox.scrollTop = 0;\n\n // Use setTimeout to make sure the following code is executed after the click event is handled.\n setTimeout(() => {\n if (this.searchInput.value !== '') {\n this.searchInput.value = '';\n this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));\n }\n });\n });\n\n this.renderDefault().catch(Notification.exception);\n }\n\n /**\n * Initialise an instance of the class.\n *\n * @param {int|null} cmid ID of the course module initiating the group search (optional).\n */\n static init(cmid = null) {\n return new GroupSearch(cmid);\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.group-search';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.groupsearchdropdown';\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_group/comboboxsearch/resultset', {\n groups: this.getMatchedResults(),\n hasresults: this.getMatchedResults().length > 0,\n instance: this.instance,\n searchterm: this.getSearchTerm(),\n });\n replaceNodeContents(this.selectors.placeholder, html, js);\n // Remove aria-activedescendant when the available options change.\n this.searchInput.removeAttribute('aria-activedescendant');\n }\n\n /**\n * Build the content then replace the node by default we want our form to exist.\n */\n async renderDefault() {\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n\n await this.renderDropdown();\n\n this.updateNodes();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n async fetchDataset() {\n return await groupFetch(this.courseID, this.cmID).then((r) => r.groups);\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} The users that match the given criteria.\n */\n async filterDataset(filterableData) {\n // Sometimes we just want to show everything.\n if (this.getPreppedSearchTerm() === '') {\n return filterableData;\n }\n return filterableData.filter((group) => Object.keys(group).some((key) => {\n if (group[key] === \"\" || this.bannedFilterFields.includes(key)) {\n return false;\n }\n return group[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());\n }));\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n */\n filterMatchDataset() {\n this.setMatchedResults(\n this.getMatchedResults().map((group) => {\n return {\n id: group.id,\n name: group.name,\n groupimageurl: group.groupimageurl,\n };\n })\n );\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n if (e.target.closest(this.selectors.clearSearch)) {\n e.stopPropagation();\n // Clear the entered search query in the search bar.\n this.searchInput.value = '';\n this.setSearchTerms(this.searchInput.value);\n this.searchInput.focus();\n this.clearSearchButton.classList.add('d-none');\n // Display results.\n await this.filterrenderpipe();\n }\n }\n\n /**\n * The handler for when a user changes the value of the component (selects an option from the dropdown).\n *\n * @param {Event} e The change event.\n */\n changeHandler(e) {\n window.location = this.selectOneLink(e.target.value);\n }\n\n /**\n * Override the input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n // User has given something for us to filter against.\n await this.filterrenderpipe();\n }, 300));\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n * We will call this function when a user interacts with the combobox to redirect them to show their results in the page.\n *\n * @param {Number} groupID The ID of the group selected.\n */\n selectOneLink(groupID) {\n throw new Error(`selectOneLink(${groupID}) must be implemented in ${this.constructor.name}`);\n }\n}\n"],"names":["GroupSearch","search_combobox","constructor","cmid","selectors","this","courseid","placeholder","component","document","querySelector","componentSelector","courseID","dataset","instance","cmID","searchValueElement","searchInput","inputElement","addEventListener","toggleDropdown","valueElement","combobox","value","dispatchEvent","Event","bubbles","removeAttribute","listbox","getAttribute","querySelectorAll","forEach","option","classList","remove","scrollTop","setTimeout","renderDefault","catch","Notification","exception","dropdownSelector","html","js","groups","getMatchedResults","hasresults","length","searchterm","getSearchTerm","setMatchedResults","filterDataset","getDataset","filterMatchDataset","renderDropdown","updateNodes","then","r","filterableData","getPreppedSearchTerm","filter","group","Object","keys","some","key","bannedFilterFields","includes","toString","toLowerCase","map","id","name","groupimageurl","e","target","closest","clearSearch","stopPropagation","setSearchTerms","focus","clearSearchButton","add","filterrenderpipe","changeHandler","window","location","selectOneLink","registerInputHandlers","async","groupID","Error"],"mappings":"+rBA4BqBA,oBAAoBC,yBAWrCC,kBAAYC,4DAAO,mIAPE,CAAC,KAAM,OAAQ,uBAS3BC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,2BACVC,YAAa,gEAEXC,UAAYC,SAASC,cAAcL,KAAKM,0BACzCC,SAAWJ,UAAUE,cAAcL,KAAKD,UAAUE,UAAUO,QAAQP,cAEpEQ,SAAWN,UAAUE,cAAcL,KAAKD,UAAUU,UAAUD,QAAQC,cACpEC,KAAOZ,WAENa,mBAAqBX,KAAKG,UAAUE,yBAAkBL,KAAKY,YAAYJ,QAAQK,eACrFF,mBAAmBG,iBAAiB,UAAU,UACrCC,uBAECC,aAAehB,KAAKG,UAAUE,yBAAkBL,KAAKiB,SAAST,QAAQK,eACxEG,aAAaE,QAAUP,mBAAmBO,QAC1CF,aAAaE,MAAQP,mBAAmBO,MACxCF,aAAaG,cAAc,IAAIC,MAAM,SAAU,CAACC,SAAS,MAG7DV,mBAAmBO,MAAQ,WAG1Bf,UAAUW,iBAAiB,oBAAoB,UAC3CF,YAAYU,gBAAgB,+BAE3BC,QAAUnB,SAASC,yBAAkBL,KAAKY,YAAYY,aAAa,sCACzED,QAAQE,iBAAiB,0BAA0BC,SAAQC,SACvDA,OAAOC,UAAUC,OAAO,aAE5BN,QAAQO,UAAY,EAGpBC,YAAW,KACwB,KAA3B/B,KAAKY,YAAYM,aACZN,YAAYM,MAAQ,QACpBN,YAAYO,cAAc,IAAIC,MAAM,QAAS,CAACC,SAAS,iBAKnEW,gBAAgBC,MAAMC,sBAAaC,gCASjC,IAAIxC,mEADI,MASnBW,0BACW,gBAQX8B,yBACW,oDAODC,KAACA,KAADC,GAAOA,UAAY,+BAAiB,sCAAuC,CAC7EC,OAAQvC,KAAKwC,oBACbC,WAAYzC,KAAKwC,oBAAoBE,OAAS,EAC9CjC,SAAUT,KAAKS,SACfkC,WAAY3C,KAAK4C,qDAED5C,KAAKD,UAAUG,YAAamC,KAAMC,SAEjD1B,YAAYU,gBAAgB,oDAO5BuB,wBAAwB7C,KAAK8C,oBAAoB9C,KAAK+C,oBACtDC,2BAEChD,KAAKiD,sBAENC,gDASQ,0BAAWlD,KAAKO,SAAUP,KAAKU,MAAMyC,MAAMC,GAAMA,EAAEb,6BAShDc,sBAEoB,KAAhCrD,KAAKsD,uBACED,eAEJA,eAAeE,QAAQC,OAAUC,OAAOC,KAAKF,OAAOG,MAAMC,KAC1C,KAAfJ,MAAMI,OAAe5D,KAAK6D,mBAAmBC,SAASF,MAGnDJ,MAAMI,KAAKG,WAAWC,cAAcF,SAAS9D,KAAKsD,4BAOjEN,0BACSH,kBACD7C,KAAKwC,oBAAoByB,KAAKT,QACnB,CACHU,GAAIV,MAAMU,GACVC,KAAMX,MAAMW,KACZC,cAAeZ,MAAMY,sCAWlBC,GACXA,EAAEC,OAAOC,QAAQvE,KAAKD,UAAUyE,eAChCH,EAAEI,uBAEG7D,YAAYM,MAAQ,QACpBwD,eAAe1E,KAAKY,YAAYM,YAChCN,YAAY+D,aACZC,kBAAkBhD,UAAUiD,IAAI,gBAE/B7E,KAAK8E,oBASnBC,cAAcV,GACVW,OAAOC,SAAWjF,KAAKkF,cAAcb,EAAEC,OAAOpD,OAMlDiE,6BAESvE,YAAYE,iBAAiB,SAAS,oBAASsE,eAC3CV,eAAe1E,KAAKY,YAAYM,OAER,KAAzBlB,KAAK4C,qBAEAgC,kBAAkBhD,UAAUiD,IAAI,eAGhCD,kBAAkBhD,UAAUC,OAAO,gBAGtC7B,KAAK8E,qBACZ,MASPI,cAAcG,eACJ,IAAIC,8BAAuBD,4CAAmCrF,KAAKH,YAAYsE"} \ No newline at end of file +{"version":3,"file":"group.min.js","sources":["../../src/comboboxsearch/group.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 * Allow the user to search for groups.\n *\n * @module core_group/comboboxsearch/group\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport {groupFetch} from 'core_group/comboboxsearch/repository';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport {debounce} from 'core/utils';\nimport Notification from 'core/notification';\n\nexport default class GroupSearch extends search_combobox {\n\n courseID;\n cmID;\n bannedFilterFields = ['id', 'link', 'groupimageurl'];\n\n /**\n * Construct the class.\n *\n * @param {int|null} cmid ID of the course module initiating the group search (optional).\n */\n constructor(cmid = null) {\n super();\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n placeholder: '.groupsearchdropdown [data-region=\"searchplaceholder\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n // Override the instance since the body is built outside the constructor for the combobox.\n this.instance = component.querySelector(this.selectors.instance).dataset.instance;\n this.cmID = cmid;\n\n const searchValueElement = this.component.querySelector(`#${this.searchInput.dataset.inputElement}`);\n searchValueElement.addEventListener('change', () => {\n this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.\n\n const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);\n if (valueElement.value !== searchValueElement.value) {\n valueElement.value = searchValueElement.value;\n valueElement.dispatchEvent(new Event('change', {bubbles: true}));\n }\n\n searchValueElement.value = '';\n });\n\n this.component.addEventListener('hide.bs.dropdown', () => {\n this.searchInput.removeAttribute('aria-activedescendant');\n\n const listbox = document.querySelector(`#${this.searchInput.getAttribute('aria-controls')}[role=\"listbox\"]`);\n listbox.querySelectorAll('.active[role=\"option\"]').forEach(option => {\n option.classList.remove('active');\n });\n listbox.scrollTop = 0;\n\n // Use setTimeout to make sure the following code is executed after the click event is handled.\n setTimeout(() => {\n if (this.searchInput.value !== '') {\n this.searchInput.value = '';\n this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));\n }\n });\n });\n\n this.renderDefault().catch(Notification.exception);\n }\n\n /**\n * Initialise an instance of the class.\n *\n * @param {int|null} cmid ID of the course module initiating the group search (optional).\n */\n static init(cmid = null) {\n return new GroupSearch(cmid);\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.group-search';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.groupsearchdropdown';\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_group/comboboxsearch/resultset', {\n groups: this.getMatchedResults(),\n hasresults: this.getMatchedResults().length > 0,\n instance: this.instance,\n searchterm: this.getSearchTerm(),\n });\n replaceNodeContents(this.selectors.placeholder, html, js);\n // Remove aria-activedescendant when the available options change.\n this.searchInput.removeAttribute('aria-activedescendant');\n }\n\n /**\n * Build the content then replace the node by default we want our form to exist.\n */\n async renderDefault() {\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n\n await this.renderDropdown();\n\n this.updateNodes();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n async fetchDataset() {\n return await groupFetch(this.courseID, this.cmID).then((r) => r.groups);\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} The users that match the given criteria.\n */\n async filterDataset(filterableData) {\n // Sometimes we just want to show everything.\n if (this.getPreppedSearchTerm() === '') {\n return filterableData;\n }\n return filterableData.filter((group) => Object.keys(group).some((key) => {\n if (group[key] === \"\" || this.bannedFilterFields.includes(key)) {\n return false;\n }\n return group[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());\n }));\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n */\n filterMatchDataset() {\n this.setMatchedResults(\n this.getMatchedResults().map((group) => {\n return {\n id: group.id,\n name: group.name,\n groupimageurl: group.groupimageurl,\n participation: group.participation,\n };\n })\n );\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n if (e.target.closest(this.selectors.clearSearch)) {\n e.stopPropagation();\n // Clear the entered search query in the search bar.\n this.searchInput.value = '';\n this.setSearchTerms(this.searchInput.value);\n this.searchInput.focus();\n this.clearSearchButton.classList.add('d-none');\n // Display results.\n await this.filterrenderpipe();\n }\n }\n\n /**\n * The handler for when a user changes the value of the component (selects an option from the dropdown).\n *\n * @param {Event} e The change event.\n */\n changeHandler(e) {\n window.location = this.selectOneLink(e.target.value);\n }\n\n /**\n * Override the input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n // User has given something for us to filter against.\n await this.filterrenderpipe();\n }, 300));\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n * We will call this function when a user interacts with the combobox to redirect them to show their results in the page.\n *\n * @param {Number} groupID The ID of the group selected.\n */\n selectOneLink(groupID) {\n throw new Error(`selectOneLink(${groupID}) must be implemented in ${this.constructor.name}`);\n }\n}\n"],"names":["GroupSearch","search_combobox","constructor","cmid","selectors","this","courseid","placeholder","component","document","querySelector","componentSelector","courseID","dataset","instance","cmID","searchValueElement","searchInput","inputElement","addEventListener","toggleDropdown","valueElement","combobox","value","dispatchEvent","Event","bubbles","removeAttribute","listbox","getAttribute","querySelectorAll","forEach","option","classList","remove","scrollTop","setTimeout","renderDefault","catch","Notification","exception","dropdownSelector","html","js","groups","getMatchedResults","hasresults","length","searchterm","getSearchTerm","setMatchedResults","filterDataset","getDataset","filterMatchDataset","renderDropdown","updateNodes","then","r","filterableData","getPreppedSearchTerm","filter","group","Object","keys","some","key","bannedFilterFields","includes","toString","toLowerCase","map","id","name","groupimageurl","participation","e","target","closest","clearSearch","stopPropagation","setSearchTerms","focus","clearSearchButton","add","filterrenderpipe","changeHandler","window","location","selectOneLink","registerInputHandlers","async","groupID","Error"],"mappings":"+rBA4BqBA,oBAAoBC,yBAWrCC,kBAAYC,4DAAO,mIAPE,CAAC,KAAM,OAAQ,uBAS3BC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,2BACVC,YAAa,gEAEXC,UAAYC,SAASC,cAAcL,KAAKM,0BACzCC,SAAWJ,UAAUE,cAAcL,KAAKD,UAAUE,UAAUO,QAAQP,cAEpEQ,SAAWN,UAAUE,cAAcL,KAAKD,UAAUU,UAAUD,QAAQC,cACpEC,KAAOZ,WAENa,mBAAqBX,KAAKG,UAAUE,yBAAkBL,KAAKY,YAAYJ,QAAQK,eACrFF,mBAAmBG,iBAAiB,UAAU,UACrCC,uBAECC,aAAehB,KAAKG,UAAUE,yBAAkBL,KAAKiB,SAAST,QAAQK,eACxEG,aAAaE,QAAUP,mBAAmBO,QAC1CF,aAAaE,MAAQP,mBAAmBO,MACxCF,aAAaG,cAAc,IAAIC,MAAM,SAAU,CAACC,SAAS,MAG7DV,mBAAmBO,MAAQ,WAG1Bf,UAAUW,iBAAiB,oBAAoB,UAC3CF,YAAYU,gBAAgB,+BAE3BC,QAAUnB,SAASC,yBAAkBL,KAAKY,YAAYY,aAAa,sCACzED,QAAQE,iBAAiB,0BAA0BC,SAAQC,SACvDA,OAAOC,UAAUC,OAAO,aAE5BN,QAAQO,UAAY,EAGpBC,YAAW,KACwB,KAA3B/B,KAAKY,YAAYM,aACZN,YAAYM,MAAQ,QACpBN,YAAYO,cAAc,IAAIC,MAAM,QAAS,CAACC,SAAS,iBAKnEW,gBAAgBC,MAAMC,sBAAaC,gCASjC,IAAIxC,mEADI,MASnBW,0BACW,gBAQX8B,yBACW,oDAODC,KAACA,KAADC,GAAOA,UAAY,+BAAiB,sCAAuC,CAC7EC,OAAQvC,KAAKwC,oBACbC,WAAYzC,KAAKwC,oBAAoBE,OAAS,EAC9CjC,SAAUT,KAAKS,SACfkC,WAAY3C,KAAK4C,qDAED5C,KAAKD,UAAUG,YAAamC,KAAMC,SAEjD1B,YAAYU,gBAAgB,oDAO5BuB,wBAAwB7C,KAAK8C,oBAAoB9C,KAAK+C,oBACtDC,2BAEChD,KAAKiD,sBAENC,gDASQ,0BAAWlD,KAAKO,SAAUP,KAAKU,MAAMyC,MAAMC,GAAMA,EAAEb,6BAShDc,sBAEoB,KAAhCrD,KAAKsD,uBACED,eAEJA,eAAeE,QAAQC,OAAUC,OAAOC,KAAKF,OAAOG,MAAMC,KAC1C,KAAfJ,MAAMI,OAAe5D,KAAK6D,mBAAmBC,SAASF,MAGnDJ,MAAMI,KAAKG,WAAWC,cAAcF,SAAS9D,KAAKsD,4BAOjEN,0BACSH,kBACD7C,KAAKwC,oBAAoByB,KAAKT,QACnB,CACHU,GAAIV,MAAMU,GACVC,KAAMX,MAAMW,KACZC,cAAeZ,MAAMY,cACrBC,cAAeb,MAAMa,sCAWlBC,GACXA,EAAEC,OAAOC,QAAQxE,KAAKD,UAAU0E,eAChCH,EAAEI,uBAEG9D,YAAYM,MAAQ,QACpByD,eAAe3E,KAAKY,YAAYM,YAChCN,YAAYgE,aACZC,kBAAkBjD,UAAUkD,IAAI,gBAE/B9E,KAAK+E,oBASnBC,cAAcV,GACVW,OAAOC,SAAWlF,KAAKmF,cAAcb,EAAEC,OAAOrD,OAMlDkE,6BAESxE,YAAYE,iBAAiB,SAAS,oBAASuE,eAC3CV,eAAe3E,KAAKY,YAAYM,OAER,KAAzBlB,KAAK4C,qBAEAiC,kBAAkBjD,UAAUkD,IAAI,eAGhCD,kBAAkBjD,UAAUC,OAAO,gBAGtC7B,KAAK+E,qBACZ,MASPI,cAAcG,eACJ,IAAIC,8BAAuBD,4CAAmCtF,KAAKH,YAAYsE"} \ No newline at end of file diff --git a/public/group/amd/src/comboboxsearch/group.js b/public/group/amd/src/comboboxsearch/group.js index d21cd94c8d5..4626cd49e42 100644 --- a/public/group/amd/src/comboboxsearch/group.js +++ b/public/group/amd/src/comboboxsearch/group.js @@ -175,6 +175,7 @@ export default class GroupSearch extends search_combobox { id: group.id, name: group.name, groupimageurl: group.groupimageurl, + participation: group.participation, }; }) ); diff --git a/public/group/classes/external/get_groups_for_selector.php b/public/group/classes/external/get_groups_for_selector.php index cb2a9ee40c2..3e12a820192 100644 --- a/public/group/classes/external/get_groups_for_selector.php +++ b/public/group/classes/external/get_groups_for_selector.php @@ -85,7 +85,7 @@ class get_groups_for_selector extends external_api { $cm = get_coursemodule_from_id('', $params['cmid']); $groupmode = groups_get_activity_groupmode($cm, $course); $groupingid = $cm->groupingid; - $participationonly = true; + $participationonly = false; } else { $context = context_course::instance($params['courseid']); $groupmode = $course->groupmode; @@ -130,19 +130,22 @@ class get_groups_for_selector extends external_api { ]); } - $mappedgroups = array_map(function($group) use ($context, $OUTPUT) { + $mappedgroups = array_map(function ($group) use ($context, $OUTPUT) { if ($group->id) { // Particular group. Get the group picture if it exists, otherwise return a generic image. $picture = get_group_picture_url($group, $group->courseid, true) ?? moodle_url::make_pluginfile_url($context->get_course_context()->id, 'group', 'generated', $group->id, '/', 'group.svg'); + $participation = $group->participation; } else { // All participants. $picture = $OUTPUT->image_url('g/g1'); + $participation = true; } return (object) [ 'id' => $group->id, 'name' => format_string($group->name, true, ['context' => $context]), 'groupimageurl' => $picture->out(false), + 'participation' => $participation, ]; }, $groupsmenu); } @@ -175,6 +178,7 @@ class get_groups_for_selector extends external_api { 'id' => new external_value(PARAM_ALPHANUM, 'An ID for the group', VALUE_REQUIRED), 'name' => new external_value(PARAM_TEXT, 'The full name of the group', VALUE_REQUIRED), 'groupimageurl' => new external_value(PARAM_URL, 'Group image URL', VALUE_OPTIONAL), + 'participation' => new external_value(PARAM_BOOL, 'Is this a participation group?', VALUE_REQUIRED), ]; return new external_single_structure($groupfields); } diff --git a/public/group/templates/comboboxsearch/resultitem.mustache b/public/group/templates/comboboxsearch/resultitem.mustache index 2dd40d997d2..d9eb80a00a8 100644 --- a/public/group/templates/comboboxsearch/resultitem.mustache +++ b/public/group/templates/comboboxsearch/resultitem.mustache @@ -37,6 +37,11 @@ {{name}} + {{^participation}} + + {{#str}} nonparticipation, group {{/str}} + + {{/participation}} {{/content}} {{/core/local/comboboxsearch/resultitem}} diff --git a/public/group/templates/comboboxsearch/resultset.mustache b/public/group/templates/comboboxsearch/resultset.mustache index fe077b9841d..b87b90c9981 100644 --- a/public/group/templates/comboboxsearch/resultset.mustache +++ b/public/group/templates/comboboxsearch/resultset.mustache @@ -28,12 +28,20 @@ { "id": 2, "name": "Foo bar", - "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2" + "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2", + "participation": true }, { "id": 3, "name": "Bar Foo", - "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3" + "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3", + "participation": true + }, + { + "id": 4, + "name": "Baz Bar Foo", + "link": "http://foo.bar/grade/report/grader/index.php?id=43&userid=2", + "participation": false } ], "instance": 25, diff --git a/public/lang/en/group.php b/public/lang/en/group.php index fa9d4ee5522..8e46a50fcb3 100644 --- a/public/lang/en/group.php +++ b/public/lang/en/group.php @@ -178,6 +178,7 @@ $string['manageactions'] = 'Manage'; $string['messagingdisabled'] = 'Successfully disabled messaging in {$a} group(s)'; $string['messagingenabled'] = 'Successfully enabled messaging in {$a} group(s)'; $string['mygroups'] = 'My groups'; +$string['nonparticipation'] = 'Non-participation'; $string['othergroups'] = 'Other groups'; $string['overview'] = 'Overview'; $string['participation'] = 'Show group in dropdown menu for activities in group mode'; diff --git a/public/lib/grouplib.php b/public/lib/grouplib.php index 01e60f4553e..cecbb97ebef 100644 --- a/public/lib/grouplib.php +++ b/public/lib/grouplib.php @@ -833,11 +833,17 @@ function groups_list_to_menu($groups) { * Own groups are removed from allowed groups * @param array $allowedgroups All groups user is allowed to see * @param array $usergroups Groups user belongs to + * @param bool $splitparticipation If true, split each optgroup into "Participation" and "Non-participation" optgroups. * @return array */ -function groups_sort_menu_options($allowedgroups, $usergroups) { - $useroptions = array(); +function groups_sort_menu_options($allowedgroups, $usergroups, bool $splitparticipation = false) { + $nonparticipationgroups = []; + $useroptions = []; if ($usergroups) { + if ($splitparticipation) { + [$usergroups, $usernonparticipation] = groups_split_participation_groups($usergroups); + $nonparticipationgroups = array_merge($nonparticipationgroups, $usernonparticipation); + } $useroptions = groups_list_to_menu($usergroups); // Remove user groups from other groups list. @@ -846,21 +852,54 @@ function groups_sort_menu_options($allowedgroups, $usergroups) { } } - $allowedoptions = array(); + $allowedoptions = []; if ($allowedgroups) { + if ($splitparticipation) { + [$allowedgroups, $allowednonparticipation] = groups_split_participation_groups($allowedgroups); + $nonparticipationgroups = array_merge($nonparticipationgroups, $allowednonparticipation); + } $allowedoptions = groups_list_to_menu($allowedgroups); } if ($useroptions && $allowedoptions) { - return array( - 1 => array(get_string('mygroups', 'group') => $useroptions), - 2 => array(get_string('othergroups', 'group') => $allowedoptions) - ); + $options = [ + 1 => [ + get_string('mygroups', 'group') => $useroptions, + ], + 2 => [ + get_string('othergroups', 'group') => $allowedoptions, + ], + ]; } else if ($useroptions) { - return $useroptions; + $options = $useroptions; } else { - return $allowedoptions; + $options = $allowedoptions; } + if ($nonparticipationgroups) { + $options[3] = [ + get_string('nonparticipation', 'group') => groups_list_to_menu($nonparticipationgroups), + ]; + } + return $options; +} + +/** + * Split the list of groups into participation and non-participation groups. + * + * @param array $groups List of group records + * @return array[] Menu options for the records, split into "Participation" and "Non-participation" optgroups. + */ +function groups_split_participation_groups(array $groups): array { + $participation = []; + $nonparticipation = []; + foreach ($groups as $group) { + if ($group->participation) { + $participation[] = $group; + } else { + $nonparticipation[] = $group; + } + } + return [$participation, $nonparticipation]; } /** @@ -934,9 +973,20 @@ function groups_allgroups_course_menu($course, $urlroot, $update = false, $activ * selecting this option does not prevent groups_get_activity_group from * returning 0; it will still do that if the user has chosen 'all participants' * in another activity, or not chosen anything.) + * @param bool $participationonly By default, this menu will only contain groups with the "participation" + * flag set true. Setting this argument to false will return all groups that the user is allowed to see. + * This should only be used for cases such as a teacher wanting to filter submissions by group, not for + * students choosing a group to submit their work under, otherwise it negates the point of the participation + * flag. * @return mixed void or string depending on $return param */ -function groups_print_activity_menu($cm, $urlroot, $return=false, $hideallparticipants=false) { +function groups_print_activity_menu( + $cm, + $urlroot, + $return = false, + $hideallparticipants = false, + bool $participationonly = true, +) { global $USER, $OUTPUT; if ($urlroot instanceof moodle_url) { @@ -966,22 +1016,23 @@ function groups_print_activity_menu($cm, $urlroot, $return=false, $hideallpartic $usergroups = array(); if ($groupmode == VISIBLEGROUPS or $aag) { - $allowedgroups = groups_get_all_groups($cm->course, 0, $cm->groupingid, 'g.*', false, true); // Any group in grouping. + // Any group in grouping. + $allowedgroups = groups_get_all_groups($cm->course, 0, $cm->groupingid, 'g.*', false, $participationonly); // Get user's own groups and put to the top. - $usergroups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid, 'g.*', false, true); + $usergroups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid, 'g.*', false, $participationonly); } else { // Only assigned groups. - $allowedgroups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid, 'g.*', false, true); + $allowedgroups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid, 'g.*', false, $participationonly); } - $activegroup = groups_get_activity_group($cm, true, $allowedgroups); + $activegroup = groups_get_activity_group($cm, true, $allowedgroups, $participationonly); $groupsmenu = array(); if ((!$allowedgroups or $groupmode == VISIBLEGROUPS or $aag) and !$hideallparticipants) { $groupsmenu[0] = get_string('allparticipants'); } - $groupsmenu += groups_sort_menu_options($allowedgroups, $usergroups); + $groupsmenu += groups_sort_menu_options($allowedgroups, $usergroups, !$participationonly); if ($groupmode == VISIBLEGROUPS) { $grouplabel = get_string('groupsvisible'); @@ -1068,13 +1119,21 @@ function groups_get_course_group($course, $update=false, $allowedgroups=null) { /** * Returns group active in activity, changes the group by default if 'group' page param present * - * @category group * @param stdClass|cm_info $cm course module object * @param bool $update change active group if group param submitted * @param array $allowedgroups list of groups user may access (INTERNAL, to be used only from groups_print_activity_menu()) + * @param bool $participationonly By default, only allow groups with the "participation" + * flag set true. Setting this argument to false will allow setting a non-participation group as the active group. + * This should be used with care, and only for users who should be able to see non-participation groups within an activity. * @return mixed false if groups not used, int if groups used, 0 means all groups (access must be verified in SEPARATE mode) + * @category group */ -function groups_get_activity_group($cm, $update=false, $allowedgroups=null) { +function groups_get_activity_group( + $cm, + $update = false, + $allowedgroups = null, + bool $participationonly = true, +) { global $USER, $SESSION; if (!$groupmode = groups_get_activity_groupmode($cm)) { @@ -1089,9 +1148,9 @@ function groups_get_activity_group($cm, $update=false, $allowedgroups=null) { if (!is_array($allowedgroups)) { if ($groupmode == VISIBLEGROUPS or $groupmode === 'aag') { - $allowedgroups = groups_get_all_groups($cm->course, 0, $cm->groupingid, 'g.*', false, true); + $allowedgroups = groups_get_all_groups($cm->course, 0, $cm->groupingid, 'g.*', false, $participationonly); } else { - $allowedgroups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid, 'g.*', false, true); + $allowedgroups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid, 'g.*', false, $participationonly); } } diff --git a/public/lib/tests/grouplib_test.php b/public/lib/tests/grouplib_test.php index e34d6ce74e3..35a93226a08 100644 --- a/public/lib/tests/grouplib_test.php +++ b/public/lib/tests/grouplib_test.php @@ -1236,14 +1236,18 @@ final class grouplib_test extends \advanced_testcase { /** * Create dummy groups array for use in menu tests * @param int $number + * @param bool $alternateparticipation If true, set participation = 1 for even groups, and 0 for odd groups. * @return array */ - protected function make_group_list($number) { + protected function make_group_list($number, $alternateparticipation = false) { $testgroups = array(); for ($a = 0; $a < $number; $a++) { $grp = new \stdClass(); $grp->id = 100 + $a; $grp->name = 'test group ' . $grp->id; + if ($alternateparticipation) { + $grp->participation = ($a % 2 == 0); + } $testgroups[$grp->id] = $grp; } return $testgroups; @@ -1302,6 +1306,82 @@ final class grouplib_test extends \advanced_testcase { ), groups_sort_menu_options($this->make_group_list(13), $this->make_group_list(2))); } + /** + * Splitting allowed groups by participation returns an optgroup for non-participation groups. + * + * @covers ::groups_sort_menu_options() + * @return void + */ + public function test_groups_split_participation_allowed_groups_only(): void { + $this->assertEquals( + [ + 100 => 'test group 100', + 3 => [ + get_string('nonparticipation', 'group') => [ + 101 => 'test group 101', + ], + ], + ], + groups_sort_menu_options($this->make_group_list(2, true), [], true), + ); + } + + /** + * Splitting user groups by participation returns an optgroup for non-participation groups. + * + * @covers ::groups_sort_menu_options() + * @return void + */ + public function test_groups_split_participation_options_user_groups_only(): void { + $this->assertEquals( + [ + 100 => 'test group 100', + 3 => [ + get_string('nonparticipation', 'group') => [ + 101 => 'test group 101', + ], + ], + ], + groups_sort_menu_options([], $this->make_group_list(2, true), true), + ); + } + + /** + * Splitting allowed and user groups by participation returns optgroups. + * + * One optgroup for user participation groups, one for other participation groups, and one for non-participation groups. + * + * @covers ::groups_sort_menu_options() + * @return void + */ + public function test_groups_split_participation_options_user_both(): void { + $this->assertEquals( + [ + 1 => [ + get_string('mygroups', 'group') => [ + 100 => 'test group 100', + ], + ], + 2 => [ + get_string('othergroups', 'group') => [ + 102 => 'test group 102', + ], + ], + 3 => [ + get_string('nonparticipation', 'group') => [ + 101 => 'test group 101', + 103 => 'test group 103', + ], + ], + ], + groups_sort_menu_options( + $this->make_group_list(4, true), + $this->make_group_list(2, true), + true + ), + ); + } + /** * Tests for groups_user_groups_visible. */ diff --git a/public/theme/boost/scss/moodle/dropdown.scss b/public/theme/boost/scss/moodle/dropdown.scss index f5cdd70719f..7c662634572 100644 --- a/public/theme/boost/scss/moodle/dropdown.scss +++ b/public/theme/boost/scss/moodle/dropdown.scss @@ -44,12 +44,13 @@ } // Add dropdown menu items styles for each theme color mantainning default hover colour for contrast. -@each $color, $value in $theme-colors { +@each $color, $value in map-merge($theme-colors, $utilities-text-emphasis-colors) { .dropdown-item { &:hover, &:focus { &.text-#{$color}, - a.text-#{$color} { + a.text-#{$color}, + span.text-#{$color} { color: $dropdown-link-hover-color !important; /* stylelint-disable-line declaration-no-important */ } } diff --git a/public/theme/boost/style/moodle.css b/public/theme/boost/style/moodle.css index 69199b99ee8..27fa8f3b40d 100644 --- a/public/theme/boost/style/moodle.css +++ b/public/theme/boost/style/moodle.css @@ -41031,50 +41031,130 @@ div.editor_atto_toolbar button .icon { } .dropdown-item:hover.text-primary, -.dropdown-item:hover a.text-primary, .dropdown-item:focus.text-primary, -.dropdown-item:focus a.text-primary { +.dropdown-item:hover a.text-primary, +.dropdown-item:hover span.text-primary, .dropdown-item:focus.text-primary, +.dropdown-item:focus a.text-primary, +.dropdown-item:focus span.text-primary { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-secondary, -.dropdown-item:hover a.text-secondary, .dropdown-item:focus.text-secondary, -.dropdown-item:focus a.text-secondary { +.dropdown-item:hover a.text-secondary, +.dropdown-item:hover span.text-secondary, .dropdown-item:focus.text-secondary, +.dropdown-item:focus a.text-secondary, +.dropdown-item:focus span.text-secondary { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-success, -.dropdown-item:hover a.text-success, .dropdown-item:focus.text-success, -.dropdown-item:focus a.text-success { +.dropdown-item:hover a.text-success, +.dropdown-item:hover span.text-success, .dropdown-item:focus.text-success, +.dropdown-item:focus a.text-success, +.dropdown-item:focus span.text-success { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-info, -.dropdown-item:hover a.text-info, .dropdown-item:focus.text-info, -.dropdown-item:focus a.text-info { +.dropdown-item:hover a.text-info, +.dropdown-item:hover span.text-info, .dropdown-item:focus.text-info, +.dropdown-item:focus a.text-info, +.dropdown-item:focus span.text-info { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-warning, -.dropdown-item:hover a.text-warning, .dropdown-item:focus.text-warning, -.dropdown-item:focus a.text-warning { +.dropdown-item:hover a.text-warning, +.dropdown-item:hover span.text-warning, .dropdown-item:focus.text-warning, +.dropdown-item:focus a.text-warning, +.dropdown-item:focus span.text-warning { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-danger, -.dropdown-item:hover a.text-danger, .dropdown-item:focus.text-danger, -.dropdown-item:focus a.text-danger { +.dropdown-item:hover a.text-danger, +.dropdown-item:hover span.text-danger, .dropdown-item:focus.text-danger, +.dropdown-item:focus a.text-danger, +.dropdown-item:focus span.text-danger { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-light, -.dropdown-item:hover a.text-light, .dropdown-item:focus.text-light, -.dropdown-item:focus a.text-light { +.dropdown-item:hover a.text-light, +.dropdown-item:hover span.text-light, .dropdown-item:focus.text-light, +.dropdown-item:focus a.text-light, +.dropdown-item:focus span.text-light { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-dark, -.dropdown-item:hover a.text-dark, .dropdown-item:focus.text-dark, -.dropdown-item:focus a.text-dark { +.dropdown-item:hover a.text-dark, +.dropdown-item:hover span.text-dark, .dropdown-item:focus.text-dark, +.dropdown-item:focus a.text-dark, +.dropdown-item:focus span.text-dark { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-primary-emphasis, +.dropdown-item:hover a.text-primary-emphasis, +.dropdown-item:hover span.text-primary-emphasis, .dropdown-item:focus.text-primary-emphasis, +.dropdown-item:focus a.text-primary-emphasis, +.dropdown-item:focus span.text-primary-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-secondary-emphasis, +.dropdown-item:hover a.text-secondary-emphasis, +.dropdown-item:hover span.text-secondary-emphasis, .dropdown-item:focus.text-secondary-emphasis, +.dropdown-item:focus a.text-secondary-emphasis, +.dropdown-item:focus span.text-secondary-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-success-emphasis, +.dropdown-item:hover a.text-success-emphasis, +.dropdown-item:hover span.text-success-emphasis, .dropdown-item:focus.text-success-emphasis, +.dropdown-item:focus a.text-success-emphasis, +.dropdown-item:focus span.text-success-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-info-emphasis, +.dropdown-item:hover a.text-info-emphasis, +.dropdown-item:hover span.text-info-emphasis, .dropdown-item:focus.text-info-emphasis, +.dropdown-item:focus a.text-info-emphasis, +.dropdown-item:focus span.text-info-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-warning-emphasis, +.dropdown-item:hover a.text-warning-emphasis, +.dropdown-item:hover span.text-warning-emphasis, .dropdown-item:focus.text-warning-emphasis, +.dropdown-item:focus a.text-warning-emphasis, +.dropdown-item:focus span.text-warning-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-danger-emphasis, +.dropdown-item:hover a.text-danger-emphasis, +.dropdown-item:hover span.text-danger-emphasis, .dropdown-item:focus.text-danger-emphasis, +.dropdown-item:focus a.text-danger-emphasis, +.dropdown-item:focus span.text-danger-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-light-emphasis, +.dropdown-item:hover a.text-light-emphasis, +.dropdown-item:hover span.text-light-emphasis, .dropdown-item:focus.text-light-emphasis, +.dropdown-item:focus a.text-light-emphasis, +.dropdown-item:focus span.text-light-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-dark-emphasis, +.dropdown-item:hover a.text-dark-emphasis, +.dropdown-item:hover span.text-dark-emphasis, .dropdown-item:focus.text-dark-emphasis, +.dropdown-item:focus a.text-dark-emphasis, +.dropdown-item:focus span.text-dark-emphasis { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } diff --git a/public/theme/classic/style/moodle.css b/public/theme/classic/style/moodle.css index bfa76246eaa..7d501cfd7c7 100644 --- a/public/theme/classic/style/moodle.css +++ b/public/theme/classic/style/moodle.css @@ -40965,50 +40965,130 @@ div.editor_atto_toolbar button .icon { } .dropdown-item:hover.text-primary, -.dropdown-item:hover a.text-primary, .dropdown-item:focus.text-primary, -.dropdown-item:focus a.text-primary { +.dropdown-item:hover a.text-primary, +.dropdown-item:hover span.text-primary, .dropdown-item:focus.text-primary, +.dropdown-item:focus a.text-primary, +.dropdown-item:focus span.text-primary { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-secondary, -.dropdown-item:hover a.text-secondary, .dropdown-item:focus.text-secondary, -.dropdown-item:focus a.text-secondary { +.dropdown-item:hover a.text-secondary, +.dropdown-item:hover span.text-secondary, .dropdown-item:focus.text-secondary, +.dropdown-item:focus a.text-secondary, +.dropdown-item:focus span.text-secondary { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-success, -.dropdown-item:hover a.text-success, .dropdown-item:focus.text-success, -.dropdown-item:focus a.text-success { +.dropdown-item:hover a.text-success, +.dropdown-item:hover span.text-success, .dropdown-item:focus.text-success, +.dropdown-item:focus a.text-success, +.dropdown-item:focus span.text-success { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-info, -.dropdown-item:hover a.text-info, .dropdown-item:focus.text-info, -.dropdown-item:focus a.text-info { +.dropdown-item:hover a.text-info, +.dropdown-item:hover span.text-info, .dropdown-item:focus.text-info, +.dropdown-item:focus a.text-info, +.dropdown-item:focus span.text-info { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-warning, -.dropdown-item:hover a.text-warning, .dropdown-item:focus.text-warning, -.dropdown-item:focus a.text-warning { +.dropdown-item:hover a.text-warning, +.dropdown-item:hover span.text-warning, .dropdown-item:focus.text-warning, +.dropdown-item:focus a.text-warning, +.dropdown-item:focus span.text-warning { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-danger, -.dropdown-item:hover a.text-danger, .dropdown-item:focus.text-danger, -.dropdown-item:focus a.text-danger { +.dropdown-item:hover a.text-danger, +.dropdown-item:hover span.text-danger, .dropdown-item:focus.text-danger, +.dropdown-item:focus a.text-danger, +.dropdown-item:focus span.text-danger { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-light, -.dropdown-item:hover a.text-light, .dropdown-item:focus.text-light, -.dropdown-item:focus a.text-light { +.dropdown-item:hover a.text-light, +.dropdown-item:hover span.text-light, .dropdown-item:focus.text-light, +.dropdown-item:focus a.text-light, +.dropdown-item:focus span.text-light { color: #fff !important; /* stylelint-disable-line declaration-no-important */ } .dropdown-item:hover.text-dark, -.dropdown-item:hover a.text-dark, .dropdown-item:focus.text-dark, -.dropdown-item:focus a.text-dark { +.dropdown-item:hover a.text-dark, +.dropdown-item:hover span.text-dark, .dropdown-item:focus.text-dark, +.dropdown-item:focus a.text-dark, +.dropdown-item:focus span.text-dark { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-primary-emphasis, +.dropdown-item:hover a.text-primary-emphasis, +.dropdown-item:hover span.text-primary-emphasis, .dropdown-item:focus.text-primary-emphasis, +.dropdown-item:focus a.text-primary-emphasis, +.dropdown-item:focus span.text-primary-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-secondary-emphasis, +.dropdown-item:hover a.text-secondary-emphasis, +.dropdown-item:hover span.text-secondary-emphasis, .dropdown-item:focus.text-secondary-emphasis, +.dropdown-item:focus a.text-secondary-emphasis, +.dropdown-item:focus span.text-secondary-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-success-emphasis, +.dropdown-item:hover a.text-success-emphasis, +.dropdown-item:hover span.text-success-emphasis, .dropdown-item:focus.text-success-emphasis, +.dropdown-item:focus a.text-success-emphasis, +.dropdown-item:focus span.text-success-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-info-emphasis, +.dropdown-item:hover a.text-info-emphasis, +.dropdown-item:hover span.text-info-emphasis, .dropdown-item:focus.text-info-emphasis, +.dropdown-item:focus a.text-info-emphasis, +.dropdown-item:focus span.text-info-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-warning-emphasis, +.dropdown-item:hover a.text-warning-emphasis, +.dropdown-item:hover span.text-warning-emphasis, .dropdown-item:focus.text-warning-emphasis, +.dropdown-item:focus a.text-warning-emphasis, +.dropdown-item:focus span.text-warning-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-danger-emphasis, +.dropdown-item:hover a.text-danger-emphasis, +.dropdown-item:hover span.text-danger-emphasis, .dropdown-item:focus.text-danger-emphasis, +.dropdown-item:focus a.text-danger-emphasis, +.dropdown-item:focus span.text-danger-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-light-emphasis, +.dropdown-item:hover a.text-light-emphasis, +.dropdown-item:hover span.text-light-emphasis, .dropdown-item:focus.text-light-emphasis, +.dropdown-item:focus a.text-light-emphasis, +.dropdown-item:focus span.text-light-emphasis { + color: #fff !important; /* stylelint-disable-line declaration-no-important */ +} + +.dropdown-item:hover.text-dark-emphasis, +.dropdown-item:hover a.text-dark-emphasis, +.dropdown-item:hover span.text-dark-emphasis, .dropdown-item:focus.text-dark-emphasis, +.dropdown-item:focus a.text-dark-emphasis, +.dropdown-item:focus span.text-dark-emphasis { color: #fff !important; /* stylelint-disable-line declaration-no-important */ }