Merge branch 'MDL-85533-main-v03' of https://github.com/ferranrecio/moodle
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
issueNumber: MDL-85533
|
||||
notes:
|
||||
core_course:
|
||||
- message: >-
|
||||
The activitychoosertabmode setting has been removed. Consider
|
||||
implementing your own setting in your theme if needed.
|
||||
type: removed
|
||||
@@ -190,8 +190,6 @@ class helper {
|
||||
static::add_item($presetid, 'showdataretentionsummary', '0', 'tool_dataprivacy');
|
||||
static::add_item($presetid, 'forum_maxattachments', '3');
|
||||
static::add_item($presetid, 'guestloginbutton', '0');
|
||||
// Set Activity chooser tabs to "Starred, Recommended, All".
|
||||
static::add_item($presetid, 'activitychoosertabmode', '4');
|
||||
|
||||
// Modules: Hide database, external tool (lti), IMS content package (imscp), lesson, SCORM, wiki, workshop.
|
||||
static::add_plugin($presetid, 'mod', 'data', false);
|
||||
@@ -295,8 +293,6 @@ class helper {
|
||||
static::add_item($presetid, 'showdataretentionsummary', '1', 'tool_dataprivacy');
|
||||
static::add_item($presetid, 'forum_maxattachments', '9');
|
||||
static::add_item($presetid, 'guestloginbutton', '1');
|
||||
// Set Activity chooser tabs to the default value ("Starred, Recommended, All, Activities, Resources").
|
||||
static::add_item($presetid, 'activitychoosertabmode', '3');
|
||||
|
||||
// Modules: Enable database, external tool (lti), IMS content package (imscp), lesson, SCORM, wiki, workshop.
|
||||
static::add_plugin($presetid, 'mod', 'data', true);
|
||||
|
||||
@@ -294,23 +294,6 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
|
||||
);
|
||||
|
||||
$temp = new admin_settingpage('activitychoosersettings', new lang_string('activitychoosersettings', 'course'));
|
||||
// Tab mode for the activity chooser.
|
||||
$temp->add(
|
||||
new admin_setting_configselect(
|
||||
'activitychoosertabmode',
|
||||
new lang_string('activitychoosertabmode', 'course'),
|
||||
new lang_string('activitychoosertabmode_desc', 'course'),
|
||||
3,
|
||||
[
|
||||
3 => new lang_string('activitychoosertabmodefour', 'course'),
|
||||
4 => new lang_string('activitychoosertabmodefive', 'course'),
|
||||
5 => new lang_string('activitychoosertabmodesix', 'course'),
|
||||
0 => new lang_string('activitychoosertabmodeone', 'course'),
|
||||
1 => new lang_string('activitychoosertabmodetwo', 'course'),
|
||||
2 => new lang_string('activitychoosertabmodethree', 'course'),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
// Add to the 'Groups' category.
|
||||
$ADMIN->add(
|
||||
@@ -334,23 +317,6 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
|
||||
|
||||
// Add to the 'Activity Chooser' category.
|
||||
$temp = new admin_settingpage('activitychoosersettings', new lang_string('activitychoosersettings', 'course'));
|
||||
// Tab mode for the activity chooser.
|
||||
$temp->add(
|
||||
new admin_setting_configselect(
|
||||
'activitychoosertabmode',
|
||||
new lang_string('activitychoosertabmode', 'course'),
|
||||
new lang_string('activitychoosertabmode_desc', 'course'),
|
||||
3,
|
||||
[
|
||||
3 => new lang_string('activitychoosertabmodefour', 'course'),
|
||||
4 => new lang_string('activitychoosertabmodefive', 'course'),
|
||||
5 => new lang_string('activitychoosertabmodesix', 'course'),
|
||||
0 => new lang_string('activitychoosertabmodeone', 'course'),
|
||||
1 => new lang_string('activitychoosertabmodetwo', 'course'),
|
||||
2 => new lang_string('activitychoosertabmodethree', 'course'),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
// Build a list of plugins that use the footer callback.
|
||||
$pluginswithfunction = get_plugins_with_function('custom_chooser_footer', 'lib.php');
|
||||
|
||||
@@ -28,7 +28,6 @@ Feature: I can apply presets
|
||||
And I should see "Setting changes"
|
||||
# Checking all the settings to be applied for the Starter (if will help to identify possible regressions).
|
||||
And I should see "Activities" in the "Setting changes" "table"
|
||||
And I should see "Activity chooser tabs" in the "Setting changes" "table"
|
||||
And I should see "Adaptive mode (no penalties)" in the "Setting changes" "table"
|
||||
And I should see "Blog menu" in the "Setting changes" "table"
|
||||
And I should see "Blog tags" in the "Setting changes" "table"
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
define("core_course/local/activitychooser/exporter",["exports","core/str"],(function(_exports,_str){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;
|
||||
/**
|
||||
* Module to generate template data for the activity chooser.
|
||||
*
|
||||
* @module core_course/local/activitychooser/exporter
|
||||
* @copyright 2025 Ferran Recio <ferran@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
const activityCategories=["activities","resources"];let allStrings=null;loadNecessaryStrings();async function loadNecessaryStrings(){if(null!==allStrings)return allStrings;allStrings={};const stringToLoad=[{key:"all",component:"core"},{key:"favourites",component:"core"},{key:"recommended",component:"core"},{key:"recommended_help",component:"core_course"},...activityCategories.map((key=>({key:key,component:"core"}))),...activityCategories.map((key=>({key:key+"_help",component:"core"})))],loadedStrings=await(0,_str.getStrings)(stringToLoad);return stringToLoad.forEach(((_ref,index)=>{let{key:key}=_ref;allStrings[key]=loadedStrings[index]})),allStrings}return _exports.default=class{getTabData(tabId,filteredModules,tabLabel){let tabHelp=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null;const result={tabId:tabId,active:arguments.length>4&&void 0!==arguments[4]&&arguments[4],items:filteredModules,displayed:filteredModules.length>0,tabLabel:tabLabel};return tabHelp&&(result.tabHelp=tabHelp),result}normaliseModulesData(modulesData){if(modulesData instanceof Map)modulesData=Array.from(modulesData.values());else if(!Array.isArray(modulesData))throw new Error("Invalid modules data format. Expected an array or a Map.");return modulesData}async getModChooserTemplateData(modulesData){modulesData=this.normaliseModulesData(modulesData);const allStrings=await loadNecessaryStrings(),favouriteTab=await this.getFavouriteTabData(modulesData),tabs=[{...this.getTabData("all",modulesData,allStrings.all,null,!favouriteTab.displayed),hasSearchResults:!0},favouriteTab,{...this.getTabData("recommended",modulesData.filter((mod=>!0===mod.recommended)),allStrings.recommended,allStrings.recommended_help),separator:!0}];return activityCategories.forEach(((category,index)=>{const categoryModules=modulesData.filter((mod=>mod.archetype==index));0!==categoryModules.length&&tabs.push(this.getTabData(category,categoryModules,allStrings[category],allStrings[category+"_help"]))})),{modules:modulesData,tabs:tabs}}async getFavouriteTabData(modulesData){modulesData=this.normaliseModulesData(modulesData);const allStrings=await loadNecessaryStrings(),favouriteModules=modulesData.filter((mod=>!0===mod.favourite));return this.getTabData("favourites",favouriteModules,allStrings.favourites,null,favouriteModules.length>0)}getSearchResultData(resultsModulesData){return{searchresultsnumber:(resultsModulesData=this.normaliseModulesData(resultsModulesData)).length,searchresults:resultsModulesData}}countTabItems(tabData){var _tabData$items$length,_tabData$items;return null!==(_tabData$items$length=null===(_tabData$items=tabData.items)||void 0===_tabData$items?void 0:_tabData$items.length)&&void 0!==_tabData$items$length?_tabData$items$length:0}},_exports.default}));
|
||||
|
||||
//# sourceMappingURL=exporter.min.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,6 @@ define("core_course/local/activitychooser/repository",["exports","core/ajax"],(f
|
||||
* @module core_course/local/activitychooser/repository
|
||||
* @copyright 2019 Mathew May <mathew.solutions>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.unfavouriteModule=_exports.fetchFooterData=_exports.favouriteModule=_exports.activityModules=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};_exports.activityModules=(courseid,sectionnum)=>{const request={methodname:"core_course_get_course_content_items",args:{courseid:courseid,sectionnum:sectionnum}};return _ajax.default.call([request])[0]};_exports.favouriteModule=(modName,modID)=>{const request={methodname:"core_course_add_content_item_to_user_favourites",args:{componentname:modName,contentitemid:modID}};return _ajax.default.call([request])[0]};_exports.unfavouriteModule=(modName,modID)=>{const request={methodname:"core_course_remove_content_item_from_user_favourites",args:{componentname:modName,contentitemid:modID}};return _ajax.default.call([request])[0]};_exports.fetchFooterData=(courseid,sectionid)=>{const request={methodname:"core_course_get_activity_chooser_footer",args:{courseid:courseid,sectionid:sectionid}};return _ajax.default.call([request])[0]}}));
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.fetchFooterData=_exports.favouriteModule=_exports.activityModules=void 0,_exports.getModalFooterData=async function(courseId,sectionNum){const cacheKey="".concat(courseId,"-").concat(sectionNum);if(sectionsFooterPromises.has(cacheKey))return sectionsFooterPromises.get(cacheKey);return sectionsFooterPromises.set(cacheKey,new Promise((resolve=>{resolve(fetchFooterData(courseId,sectionNum))}))),sectionsFooterPromises.get(cacheKey)},_exports.getModulesData=async function(courseId,sectionNum,sectionReturnNum,beforeMod){const cacheKey="".concat(courseId,"-").concat(sectionNum);sectionsModulesPromises.has(cacheKey)||sectionsModulesPromises.set(cacheKey,new Promise((resolve=>{resolve(activityModules(courseId,sectionNum))})));const moduleData=await sectionsModulesPromises.get(cacheKey);if(!moduleData)throw new Error("Cannot fetch module data");return function(webServiceData,sectionNum,sectionReturnNum,beforeMod){const newData=JSON.parse(JSON.stringify(webServiceData));return newData.content_items.forEach((module=>{module.link+="§ion="+sectionNum+"&beforemod="+(null!=beforeMod?beforeMod:0),sectionReturnNum&&(module.link+="&sr="+sectionReturnNum)})),newData.content_items}(moduleData,sectionNum,sectionReturnNum,beforeMod)},_exports.unfavouriteModule=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};const sectionsModulesPromises=new Map,sectionsFooterPromises=new Map,activityModules=(courseid,sectionnum)=>{const request={methodname:"core_course_get_course_content_items",args:{courseid:courseid,sectionnum:sectionnum}};return _ajax.default.call([request])[0]};_exports.activityModules=activityModules;_exports.favouriteModule=(modName,modID)=>{const request={methodname:"core_course_add_content_item_to_user_favourites",args:{componentname:modName,contentitemid:modID}};return _ajax.default.call([request])[0].then((()=>(sectionsModulesPromises.clear(),!0)))};_exports.unfavouriteModule=(modName,modID)=>{const request={methodname:"core_course_remove_content_item_from_user_favourites",args:{componentname:modName,contentitemid:modID}};return _ajax.default.call([request])[0].then((()=>(sectionsModulesPromises.clear(),!0)))};const fetchFooterData=(courseid,sectionid)=>{const request={methodname:"core_course_get_activity_chooser_footer",args:{courseid:courseid,sectionid:sectionid}};return _ajax.default.call([request])[0]};_exports.fetchFooterData=fetchFooterData}));
|
||||
|
||||
//# sourceMappingURL=repository.min.js.map
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -6,6 +6,6 @@ define("core_course/local/activitychooser/selectors",["exports"],(function(_expo
|
||||
* @copyright 2019 Mathew May <mathew.solutions>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
const getDataSelector=(name,value)=>"[data-".concat(name,'="').concat(value,'"]');var _default={regions:{chooser:getDataSelector("region","chooser-container"),getSectionChooserOptions:containerid=>"".concat(containerid," ").concat(getDataSelector("region","chooser-options-container")),chooserOption:{container:getDataSelector("region","chooser-option-container"),actions:getDataSelector("region","chooser-option-actions-container"),info:getDataSelector("region","chooser-option-info-container")},chooserSummary:{container:getDataSelector("region","chooser-option-summary-container"),content:getDataSelector("region","chooser-option-summary-content-container"),header:getDataSelector("region","summary-header"),actions:getDataSelector("region","chooser-option-summary-actions-container")},carousel:getDataSelector("region","carousel"),help:getDataSelector("region","help"),modules:getDataSelector("region","modules"),favouriteTabNav:getDataSelector("region","favourite-tab-nav"),defaultTabNav:getDataSelector("region","default-tab-nav"),activityTabNav:getDataSelector("region","activity-tab-nav"),favouriteTab:getDataSelector("region","favourites"),recommendedTab:getDataSelector("region","recommended"),defaultTab:getDataSelector("region","default"),activityTab:getDataSelector("region","activity"),resourceTab:getDataSelector("region","resources"),getModuleSelector:modname=>'[role="menuitem"][data-modname="'.concat(modname,'"]'),searchResults:getDataSelector("region","search-results-container"),searchResultItems:getDataSelector("region","search-result-items-container")},actions:{optionActions:{showSummary:getDataSelector("action","show-option-summary"),manageFavourite:getDataSelector("action","manage-module-favourite")},addChooser:getDataSelector("action","add-chooser-option"),closeOption:getDataSelector("action","close-chooser-option-summary"),hide:getDataSelector("action","hide"),search:getDataSelector("action","search"),clearSearch:getDataSelector("action","clearsearch")},render:{favourites:getDataSelector("render","favourites-area")},elements:{section:".section",sectionmodchooser:"button.section-modchooser-link",sitemenu:".block_site_main_menu",sitetopic:"div.sitetopic",tab:'a[data-bs-toggle="tab"]',activetab:'a[data-bs-toggle="tab"][aria-selected="true"]',visibletabs:'a[data-bs-toggle="tab"]:not(.d-none)'}};return _exports.default=_default,_exports.default}));
|
||||
const getDataSelector=(name,value)=>"[data-".concat(name,'="').concat(value,'"]');var _default={regions:{categoryContent:getDataSelector("region","category-content"),getSectionChooserOptions:containerid=>"".concat(containerid," ").concat(getDataSelector("region","chooser-options-container")),chooserOptions:getDataSelector("region","chooser-options-container"),chooserOption:{container:getDataSelector("region","chooser-option-container"),actions:getDataSelector("region","chooser-option-actions-container"),info:getDataSelector("region","chooser-option-info-container")},chooserSummary:{container:getDataSelector("region","chooser-option-summary-container"),content:getDataSelector("region","chooser-option-summary-content-container"),header:getDataSelector("region","summary-header"),actions:getDataSelector("region","chooser-option-summary-actions-container")},carousel:getDataSelector("region","carousel"),help:getDataSelector("region","help"),tabContent:getDataSelector("region","tabcontent"),favouriteTabNav:getDataSelector("region","favourites-tab-nav"),allTabNav:getDataSelector("region","all-tab-nav"),favouriteTab:getDataSelector("region","favourites"),getModuleSelector:modname=>'[role="menuitem"][data-modname="'.concat(modname,'"]'),searchResults:getDataSelector("region","search-results-container")},actions:{optionActions:{showSummary:getDataSelector("action","show-option-summary"),manageFavourite:getDataSelector("action","manage-module-favourite")},addChooser:getDataSelector("action","add-chooser-option"),closeOption:getDataSelector("action","close-chooser-option-summary"),hide:getDataSelector("action","hide"),search:getDataSelector("action","search"),clearSearch:getDataSelector("action","clearsearch")},elements:{section:".section",sectionmodchooser:"button.section-modchooser-link",tab:'a[data-bs-toggle="tab"]',activetab:'a[data-bs-toggle="tab"][aria-selected="true"]',moduleItem:item=>'[data-internal="'.concat(item,'"]'),favouriteIconActive:'[data-icon="favourite-active"]',favouriteIconInactive:'[data-icon="favourite-inactive"]'}};return _exports.default=_default,_exports.default}));
|
||||
|
||||
//# sourceMappingURL=selectors.min.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -25,25 +25,8 @@ import * as ChooserDialogue from 'core_course/local/activitychooser/dialogue';
|
||||
import * as Repository from 'core_course/local/activitychooser/repository';
|
||||
import selectors from 'core_course/local/activitychooser/selectors';
|
||||
import CustomEvents from 'core/custom_interaction_events';
|
||||
import * as Templates from 'core/templates';
|
||||
import {getString} from 'core/str';
|
||||
import Modal from 'core/modal';
|
||||
import Pending from 'core/pending';
|
||||
|
||||
// Set up some JS module wide constants that can be added to in the future.
|
||||
|
||||
// Tab config options.
|
||||
const ALLACTIVITIESRESOURCES = 0;
|
||||
const ACTIVITIESRESOURCES = 2;
|
||||
const ALLACTIVITIESRESOURCESREC = 3;
|
||||
const ONLYALLREC = 4;
|
||||
const ACTIVITIESRESOURCESREC = 5;
|
||||
|
||||
|
||||
// Module types.
|
||||
const ACTIVITY = 0;
|
||||
const RESOURCE = 1;
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
@@ -56,7 +39,12 @@ let initialized = false;
|
||||
export const init = (courseId, chooserConfig) => {
|
||||
const pendingPromise = new Pending();
|
||||
|
||||
registerListenerEvents(courseId, chooserConfig);
|
||||
// TODO: Remove the chooserConfig in Moodle 6.0 (MDL-85655)
|
||||
if (chooserConfig.tabmode !== undefined) {
|
||||
window.console.warn('The tabmode config option has been deprecated and will be ignored.');
|
||||
}
|
||||
|
||||
registerListenerEvents(courseId);
|
||||
|
||||
pendingPromise.resolve();
|
||||
};
|
||||
@@ -66,371 +54,84 @@ export const init = (courseId, chooserConfig) => {
|
||||
*
|
||||
* @method registerListenerEvents
|
||||
* @param {Number} courseId
|
||||
* @param {Object} chooserConfig Any PHP config settings that we may need to reference
|
||||
*/
|
||||
const registerListenerEvents = (courseId, chooserConfig) => {
|
||||
|
||||
// Ensure we only add our listeners once.
|
||||
const registerListenerEvents = (courseId) => {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
const events = [
|
||||
const eventsToHandle = [
|
||||
'click',
|
||||
CustomEvents.events.activate,
|
||||
CustomEvents.events.keyboardActivate
|
||||
];
|
||||
|
||||
const fetchModuleData = (() => {
|
||||
let innerPromises = new Map();
|
||||
|
||||
return (sectionNum) => {
|
||||
if (innerPromises.has(sectionNum)) {
|
||||
return innerPromises.get(sectionNum);
|
||||
}
|
||||
|
||||
innerPromises.set(
|
||||
sectionNum,
|
||||
new Promise((resolve) => {
|
||||
resolve(Repository.activityModules(courseId, sectionNum));
|
||||
})
|
||||
);
|
||||
return innerPromises.get(sectionNum);
|
||||
};
|
||||
})();
|
||||
|
||||
const fetchFooterData = (() => {
|
||||
let footerInnerPromise = null;
|
||||
|
||||
return (sectionNum) => {
|
||||
if (!footerInnerPromise) {
|
||||
footerInnerPromise = new Promise((resolve) => {
|
||||
resolve(Repository.fetchFooterData(courseId, sectionNum));
|
||||
});
|
||||
}
|
||||
|
||||
return footerInnerPromise;
|
||||
};
|
||||
})();
|
||||
|
||||
CustomEvents.define(document, events);
|
||||
CustomEvents.define(document, eventsToHandle);
|
||||
|
||||
// Display module chooser event listeners.
|
||||
events.forEach((event) => {
|
||||
document.addEventListener(event, async(e) => {
|
||||
if (e.target.closest(selectors.elements.sectionmodchooser)) {
|
||||
let caller;
|
||||
let sectionnum;
|
||||
// We need to know who called this.
|
||||
// Standard courses use the ID in the main section info.
|
||||
const sectionDiv = e.target.closest(selectors.elements.section);
|
||||
// Front page courses need some special handling.
|
||||
const button = e.target.closest(selectors.elements.sectionmodchooser);
|
||||
|
||||
// If we don't have a section number use the fallback ID.
|
||||
// We always want the sectionDiv caller first as it keeps track of section number's after DnD changes.
|
||||
// The button attribute is always just a fallback for us as the section div is not always available.
|
||||
// A YUI change could be done maybe to only update the button attribute but we are going for minimal change here.
|
||||
if (sectionDiv !== null && sectionDiv.hasAttribute('data-number')) {
|
||||
// We check for attributes just in case of outdated contrib course formats.
|
||||
caller = sectionDiv;
|
||||
sectionnum = sectionDiv.getAttribute('data-number');
|
||||
} else {
|
||||
caller = button;
|
||||
|
||||
if (caller.hasAttribute('data-sectionid')) {
|
||||
window.console.warn(
|
||||
'The data-sectionid attribute has been deprecated. ' +
|
||||
'Please update your code to use data-sectionnum instead.'
|
||||
);
|
||||
caller.setAttribute('data-sectionnum', caller.dataset.sectionid);
|
||||
}
|
||||
sectionnum = caller.dataset.sectionnum;
|
||||
}
|
||||
|
||||
// We want to show the modal instantly but loading whilst waiting for our data.
|
||||
let bodyPromiseResolver;
|
||||
const bodyPromise = new Promise(resolve => {
|
||||
bodyPromiseResolver = resolve;
|
||||
});
|
||||
|
||||
const footerData = await fetchFooterData(sectionnum);
|
||||
const sectionModal = buildModal(bodyPromise, footerData);
|
||||
|
||||
// Now we have a modal we should start fetching data.
|
||||
// If an error occurs while fetching the data, display the error within the modal.
|
||||
const data = await fetchModuleData(sectionnum).catch(async(e) => {
|
||||
const errorTemplateData = {
|
||||
'errormessage': e.message
|
||||
};
|
||||
bodyPromiseResolver(await Templates.render('core_course/local/activitychooser/error', errorTemplateData));
|
||||
});
|
||||
|
||||
// Early return if there is no module data.
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the section num to all the module instance links.
|
||||
const builtModuleData = sectionMapper(
|
||||
data,
|
||||
sectionnum,
|
||||
caller.dataset.sectionreturnnum,
|
||||
caller.dataset.beforemod
|
||||
);
|
||||
|
||||
ChooserDialogue.displayChooser(
|
||||
sectionModal,
|
||||
builtModuleData,
|
||||
partiallyAppliedFavouriteManager(data, sectionnum),
|
||||
footerData,
|
||||
);
|
||||
|
||||
bodyPromiseResolver(await Templates.render(
|
||||
'core_course/activitychooser',
|
||||
templateDataBuilder(builtModuleData, chooserConfig)
|
||||
));
|
||||
eventsToHandle.forEach((eventToHandle) => {
|
||||
document.addEventListener(eventToHandle, async(event) => {
|
||||
if (!event.target.closest(selectors.elements.sectionmodchooser)) {
|
||||
return;
|
||||
}
|
||||
const position = getCoursePositionFromTarget(event.target);
|
||||
|
||||
const footerDataPromise = Repository.getModalFooterData(courseId, position.sectionNum);
|
||||
|
||||
const modulesDataPromise = Repository.getModulesData(
|
||||
courseId,
|
||||
position.sectionNum,
|
||||
position.sectionReturnNum,
|
||||
position.beforeMod,
|
||||
);
|
||||
|
||||
ChooserDialogue.displayActivityChooserModal(footerDataPromise, modulesDataPromise);
|
||||
});
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given the web service data and an ID we want to make a deep copy
|
||||
* of the WS data then add on the section num to the addoption URL
|
||||
* Return the course position of a target add activity element.
|
||||
*
|
||||
* @method sectionMapper
|
||||
* @param {Object} webServiceData Our original data from the Web service call
|
||||
* @param {Number} num The number of the section we need to append to the links
|
||||
* @param {Number|null} sectionreturnnum The number of the section return we need to append to the links
|
||||
* @param {Number|null} beforemod The ID of the cm we need to append to the links
|
||||
* @return {Array} [modules] with URL's built
|
||||
* @param {HTMLElement} target The target element.
|
||||
* @return {Object} The course position of the target.
|
||||
* @property {Number} sectionNum The section number.
|
||||
* @property {Number|null} sectionReturnNum The section return number.
|
||||
* @property {Number|null} beforeMod The ID of the cm to add the modules before.
|
||||
*/
|
||||
const sectionMapper = (webServiceData, num, sectionreturnnum, beforemod) => {
|
||||
// We need to take a fresh deep copy of the original data as an object is a reference type.
|
||||
const newData = JSON.parse(JSON.stringify(webServiceData));
|
||||
newData.content_items.forEach((module) => {
|
||||
module.link += '§ion=' + num + '&beforemod=' + (beforemod ?? 0);
|
||||
if (sectionreturnnum) {
|
||||
module.link += '&sr=' + sectionreturnnum;
|
||||
}
|
||||
});
|
||||
return newData.content_items;
|
||||
};
|
||||
function getCoursePositionFromTarget(target) {
|
||||
let caller;
|
||||
let sectionNum;
|
||||
// We need to know who called this.
|
||||
// Standard courses use the ID in the main section info.
|
||||
const sectionDiv = target.closest(selectors.elements.section);
|
||||
// Front page courses need some special handling.
|
||||
const button = target.closest(selectors.elements.sectionmodchooser);
|
||||
|
||||
/**
|
||||
* Given an array of modules we want to figure out where & how to place them into our template object
|
||||
*
|
||||
* @method templateDataBuilder
|
||||
* @param {Array} data our modules to manipulate into a Templatable object
|
||||
* @param {Object} chooserConfig Any PHP config settings that we may need to reference
|
||||
* @return {Object} Our built object ready to render out
|
||||
*/
|
||||
const templateDataBuilder = (data, chooserConfig) => {
|
||||
// Setup of various bits and pieces we need to mutate before throwing it to the wolves.
|
||||
let activities = [];
|
||||
let resources = [];
|
||||
let showAll = true;
|
||||
let showActivities = false;
|
||||
let showResources = false;
|
||||
// If we don't have a section number use the fallback ID.
|
||||
// We always want the sectionDiv caller first as it keeps track of section number's after DnD changes.
|
||||
// The button attribute is always just a fallback for us as the section div is not always available.
|
||||
// A YUI change could be done maybe to only update the button attribute but we are going for minimal change here.
|
||||
if (sectionDiv !== null && sectionDiv.hasAttribute('data-number')) {
|
||||
// We check for attributes just in case of outdated contrib course formats.
|
||||
caller = sectionDiv;
|
||||
sectionNum = sectionDiv.getAttribute('data-number');
|
||||
} else {
|
||||
caller = button;
|
||||
|
||||
// Tab mode can be the following [All, Resources & Activities, All & Activities & Resources].
|
||||
const tabMode = parseInt(chooserConfig.tabmode);
|
||||
|
||||
// Filter the incoming data to find favourite & recommended modules.
|
||||
const favourites = data.filter(mod => mod.favourite === true);
|
||||
const recommended = data.filter(mod => mod.recommended === true);
|
||||
|
||||
// Whether the activities and resources tabs should be displayed or not.
|
||||
const showActivitiesAndResources = (tabMode) => {
|
||||
const acceptableModes = [
|
||||
ALLACTIVITIESRESOURCES,
|
||||
ALLACTIVITIESRESOURCESREC,
|
||||
ACTIVITIESRESOURCES,
|
||||
ACTIVITIESRESOURCESREC,
|
||||
];
|
||||
|
||||
return acceptableModes.indexOf(tabMode) !== -1;
|
||||
};
|
||||
|
||||
// These modes need Activity & Resource tabs.
|
||||
if (showActivitiesAndResources(tabMode)) {
|
||||
// Filter the incoming data to find activities then resources.
|
||||
activities = data.filter(mod => mod.archetype === ACTIVITY);
|
||||
resources = data.filter(mod => mod.archetype === RESOURCE);
|
||||
showActivities = true;
|
||||
showResources = true;
|
||||
|
||||
// We want all of the previous information but no 'All' tab.
|
||||
if (tabMode === ACTIVITIESRESOURCES || tabMode === ACTIVITIESRESOURCESREC) {
|
||||
showAll = false;
|
||||
if (caller.hasAttribute('data-sectionid')) {
|
||||
window.console.warn(
|
||||
'The data-sectionid attribute has been deprecated. ' +
|
||||
'Please update your code to use data-sectionnum instead.'
|
||||
);
|
||||
caller.setAttribute('data-sectionnum', caller.dataset.sectionid);
|
||||
}
|
||||
sectionNum = caller.dataset.sectionnum;
|
||||
}
|
||||
|
||||
const recommendedBeforeTabs = [
|
||||
ALLACTIVITIESRESOURCESREC,
|
||||
ONLYALLREC,
|
||||
ACTIVITIESRESOURCESREC,
|
||||
];
|
||||
// Whether the recommended tab should be displayed before the All/Activities/Resources tabs.
|
||||
const recommendedBeginning = recommendedBeforeTabs.indexOf(tabMode) !== -1;
|
||||
|
||||
// Given the results of the above filters lets figure out what tab to set active.
|
||||
// We have some favourites.
|
||||
const favouritesFirst = !!favourites.length;
|
||||
const recommendedFirst = favouritesFirst === false && recommendedBeginning === true && !!recommended.length;
|
||||
// We are in tabMode 2 without any favourites.
|
||||
const activitiesFirst = showAll === false && favouritesFirst === false && recommendedFirst === false;
|
||||
// We have nothing fallback to show all modules.
|
||||
const fallback = showAll === true && favouritesFirst === false && recommendedFirst === false;
|
||||
|
||||
return {
|
||||
'default': data,
|
||||
showAll: showAll,
|
||||
activities: activities,
|
||||
showActivities: showActivities,
|
||||
activitiesFirst: activitiesFirst,
|
||||
resources: resources,
|
||||
showResources: showResources,
|
||||
favourites: favourites,
|
||||
recommended: recommended,
|
||||
recommendedFirst: recommendedFirst,
|
||||
recommendedBeginning: recommendedBeginning,
|
||||
favouritesFirst: favouritesFirst,
|
||||
fallback: fallback,
|
||||
sectionNum,
|
||||
sectionReturnNum: caller.dataset?.sectionreturnnum ?? null,
|
||||
beforeMod: caller.dataset?.beforemod ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an object we want to build a modal ready to show
|
||||
*
|
||||
* @method buildModal
|
||||
* @param {Promise} body
|
||||
* @param {String|Boolean} footer Either a footer to add or nothing
|
||||
* @return {Object} The modal ready to display immediately and render body in later.
|
||||
*/
|
||||
const buildModal = (body, footer) => Modal.create({
|
||||
body,
|
||||
title: getString('addresourceoractivity'),
|
||||
footer: footer.customfootertemplate,
|
||||
large: true,
|
||||
scrollable: false,
|
||||
templateContext: {
|
||||
classes: 'modchooser'
|
||||
},
|
||||
show: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* A small helper function to handle the case where there are no more favourites
|
||||
* and we need to mess a bit with the available tabs in the chooser
|
||||
*
|
||||
* @method nullFavouriteDomManager
|
||||
* @param {HTMLElement} favouriteTabNav Dom node of the favourite tab nav
|
||||
* @param {HTMLElement} modalBody Our current modals' body
|
||||
*/
|
||||
const nullFavouriteDomManager = (favouriteTabNav, modalBody) => {
|
||||
favouriteTabNav.tabIndex = -1;
|
||||
favouriteTabNav.classList.add('d-none');
|
||||
// Need to set active to an available tab.
|
||||
if (favouriteTabNav.classList.contains('active')) {
|
||||
favouriteTabNav.classList.remove('active');
|
||||
favouriteTabNav.setAttribute('aria-selected', 'false');
|
||||
const favouriteTab = modalBody.querySelector(selectors.regions.favouriteTab);
|
||||
favouriteTab.classList.remove('active');
|
||||
const defaultTabNav = modalBody.querySelector(selectors.regions.defaultTabNav);
|
||||
const activitiesTabNav = modalBody.querySelector(selectors.regions.activityTabNav);
|
||||
if (defaultTabNav.classList.contains('d-none') === false) {
|
||||
defaultTabNav.classList.add('active');
|
||||
defaultTabNav.setAttribute('aria-selected', 'true');
|
||||
defaultTabNav.tabIndex = 0;
|
||||
defaultTabNav.focus();
|
||||
const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);
|
||||
defaultTab.classList.add('active');
|
||||
} else {
|
||||
activitiesTabNav.classList.add('active');
|
||||
activitiesTabNav.setAttribute('aria-selected', 'true');
|
||||
activitiesTabNav.tabIndex = 0;
|
||||
activitiesTabNav.focus();
|
||||
const activitiesTab = modalBody.querySelector(selectors.regions.activityTab);
|
||||
activitiesTab.classList.add('active');
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export a curried function where the builtModules has been applied.
|
||||
* We have our array of modules so we can rerender the favourites area and have all of the items sorted.
|
||||
*
|
||||
* @method partiallyAppliedFavouriteManager
|
||||
* @param {Array} moduleData This is our raw WS data that we need to manipulate
|
||||
* @param {Number} sectionnum We need this to add the sectionnum to the URL's in the faves area after rerender
|
||||
* @return {Function} partially applied function so we can manipulate DOM nodes easily & update our internal array
|
||||
*/
|
||||
const partiallyAppliedFavouriteManager = (moduleData, sectionnum) => {
|
||||
/**
|
||||
* Curried function that is being returned.
|
||||
*
|
||||
* @param {String} internal Internal name of the module to manage
|
||||
* @param {Boolean} favourite Is the caller adding a favourite or removing one?
|
||||
* @param {HTMLElement} modalBody What we need to update whilst we are here
|
||||
*/
|
||||
return async(internal, favourite, modalBody) => {
|
||||
const favouriteArea = modalBody.querySelector(selectors.render.favourites);
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
const favouriteButtons = modalBody.querySelectorAll(`[data-internal="${internal}"] ${selectors.actions.optionActions.manageFavourite}`);
|
||||
const favouriteTabNav = modalBody.querySelector(selectors.regions.favouriteTabNav);
|
||||
const result = moduleData.content_items.find(({name}) => name === internal);
|
||||
const newFaves = {};
|
||||
if (result) {
|
||||
if (favourite) {
|
||||
result.favourite = true;
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);
|
||||
|
||||
const builtFaves = sectionMapper(newFaves, sectionnum);
|
||||
|
||||
const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/favourites',
|
||||
{favourites: builtFaves});
|
||||
|
||||
await Templates.replaceNodeContents(favouriteArea, html, js);
|
||||
|
||||
Array.from(favouriteButtons).forEach((element) => {
|
||||
element.classList.remove('text-muted');
|
||||
element.classList.add('text-primary');
|
||||
element.dataset.favourited = 'true';
|
||||
element.setAttribute('aria-pressed', true);
|
||||
element.firstElementChild.classList.remove('fa-star-o');
|
||||
element.firstElementChild.classList.add('fa-star');
|
||||
});
|
||||
|
||||
favouriteTabNav.classList.remove('d-none');
|
||||
} else {
|
||||
result.favourite = false;
|
||||
|
||||
const nodeToRemove = favouriteArea.querySelector(`[data-internal="${internal}"]`);
|
||||
|
||||
nodeToRemove.parentNode.removeChild(nodeToRemove);
|
||||
|
||||
Array.from(favouriteButtons).forEach((element) => {
|
||||
element.classList.add('text-muted');
|
||||
element.classList.remove('text-primary');
|
||||
element.dataset.favourited = 'false';
|
||||
element.setAttribute('aria-pressed', false);
|
||||
element.firstElementChild.classList.remove('fa-star');
|
||||
element.firstElementChild.classList.add('fa-star-o');
|
||||
});
|
||||
const newFaves = moduleData.content_items.filter(mod => mod.favourite === true);
|
||||
|
||||
if (newFaves.length === 0) {
|
||||
nullFavouriteDomManager(favouriteTabNav, modalBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,483 +21,80 @@
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
import Carousel from 'theme_boost/bootstrap/carousel';
|
||||
// import {addIconToContainer} from 'core/loadingicon';
|
||||
import {debounce} from 'core/utils';
|
||||
import DialogueDom from 'core_course/local/activitychooser/dialoguedom';
|
||||
import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';
|
||||
import Exporter from 'core_course/local/activitychooser/exporter';
|
||||
import {getFirst} from 'core/normalise';
|
||||
import {getString} from 'core/str';
|
||||
import Modal from 'core/modal';
|
||||
import * as ModalEvents from 'core/modal_events';
|
||||
import Notification from 'core/notification';
|
||||
import * as Repository from 'core_course/local/activitychooser/repository';
|
||||
import selectors from 'core_course/local/activitychooser/selectors';
|
||||
import * as Templates from 'core/templates';
|
||||
import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';
|
||||
import {addIconToContainer} from 'core/loadingicon';
|
||||
import * as Repository from 'core_course/local/activitychooser/repository';
|
||||
import Notification from 'core/notification';
|
||||
import {debounce} from 'core/utils';
|
||||
import {getFirst} from 'core/normalise';
|
||||
const getPlugin = pluginName => import(pluginName);
|
||||
|
||||
/**
|
||||
* Given an event from the main module 'page' navigate to it's help section via a carousel.
|
||||
* Display the activity chooser modal.
|
||||
*
|
||||
* @method showModuleHelp
|
||||
* @param {Element} carousel Our initialized carousel to manipulate
|
||||
* @param {Object} moduleData Data of the module to carousel to
|
||||
* @param {jQuery} modal We need to figure out if the current modal has a footer.
|
||||
* @method displayActivityChooser
|
||||
* @param {Promise} footerDataPromise Promise for the footer data.
|
||||
* @param {Promise} modulesDataPromise Promise for the modules data.
|
||||
*/
|
||||
const showModuleHelp = (carousel, moduleData, modal = null) => {
|
||||
// If we have a real footer then we need to change temporarily.
|
||||
if (modal !== null && moduleData.showFooter === true) {
|
||||
modal.setFooter(Templates.render('core_course/local/activitychooser/footer_partial', moduleData));
|
||||
}
|
||||
const help = carousel.querySelector(selectors.regions.help);
|
||||
help.innerHTML = '';
|
||||
help.classList.add('m-auto');
|
||||
|
||||
// Add a spinner.
|
||||
const spinnerPromise = addIconToContainer(help);
|
||||
|
||||
// Used later...
|
||||
let transitionPromiseResolver = null;
|
||||
const transitionPromise = new Promise(resolve => {
|
||||
transitionPromiseResolver = resolve;
|
||||
export async function displayActivityChooserModal(
|
||||
footerDataPromise,
|
||||
modulesDataPromise,
|
||||
) {
|
||||
// We want to show the modal instantly but loading whilst waiting for our data.
|
||||
let bodyPromiseResolver;
|
||||
const bodyPromise = new Promise(resolve => {
|
||||
bodyPromiseResolver = resolve;
|
||||
});
|
||||
|
||||
// Build up the html & js ready to place into the help section.
|
||||
const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);
|
||||
const footerData = await footerDataPromise;
|
||||
|
||||
// Wait for the content to be ready, and for the transition to be complet.
|
||||
Promise.all([contentPromise, spinnerPromise, transitionPromise])
|
||||
.then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
|
||||
.then(() => {
|
||||
help.querySelector(selectors.regions.chooserSummary.header).focus();
|
||||
return help;
|
||||
})
|
||||
.catch(Notification.exception);
|
||||
|
||||
// Move to the next slide, and resolve the transition promise when it's done.
|
||||
carousel.addEventListener('slid.bs.carousel', () => {
|
||||
transitionPromiseResolver();
|
||||
}, {once: true});
|
||||
// Trigger the transition between 'pages'.
|
||||
Carousel.getInstance(carousel).next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a user wants to change the favourite state of a module we either add or remove the status.
|
||||
* We also propergate this change across our map of modals.
|
||||
*
|
||||
* @method manageFavouriteState
|
||||
* @param {HTMLElement} modalBody The DOM node of the modal to manipulate
|
||||
* @param {HTMLElement} caller
|
||||
* @param {Function} partialFavourite Partially applied function we need to manage favourite status
|
||||
*/
|
||||
const manageFavouriteState = async(modalBody, caller, partialFavourite) => {
|
||||
const isFavourite = caller.dataset.favourited;
|
||||
const id = caller.dataset.id;
|
||||
const name = caller.dataset.name;
|
||||
const internal = caller.dataset.internal;
|
||||
// Switch on fave or not.
|
||||
if (isFavourite === 'true') {
|
||||
await Repository.unfavouriteModule(name, id);
|
||||
|
||||
partialFavourite(internal, false, modalBody);
|
||||
} else {
|
||||
await Repository.favouriteModule(name, id);
|
||||
|
||||
partialFavourite(internal, true, modalBody);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Register chooser related event listeners.
|
||||
*
|
||||
* @method registerListenerEvents
|
||||
* @param {Promise} modal Our modal that we are working with
|
||||
* @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
|
||||
* @param {Function} partialFavourite Partially applied function we need to manage favourite status
|
||||
* @param {Object} footerData Our base footer object.
|
||||
*/
|
||||
const registerListenerEvents = (modal, mappedModules, partialFavourite, footerData) => {
|
||||
const modalBody = getFirst(modal.getBody());
|
||||
const bodyClickListener = async(e) => {
|
||||
if (e.target.closest(selectors.actions.optionActions.showSummary)) {
|
||||
const carousel = modalBody.querySelector(selectors.regions.carousel);
|
||||
|
||||
const module = e.target.closest(selectors.regions.chooserOption.container);
|
||||
const moduleName = module.dataset.modname;
|
||||
const moduleData = mappedModules.get(moduleName);
|
||||
// We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.
|
||||
moduleData.showFooter = modal.hasFooterContent();
|
||||
showModuleHelp(carousel, moduleData, modal);
|
||||
}
|
||||
|
||||
if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
|
||||
const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
|
||||
await manageFavouriteState(modalBody, caller, partialFavourite);
|
||||
const activeSectionId = modalBody.querySelector(selectors.elements.activetab).getAttribute("href");
|
||||
const sectionChooserOptions = modalBody
|
||||
.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
|
||||
const firstChooserOption = sectionChooserOptions
|
||||
.querySelector(selectors.regions.chooserOption.container);
|
||||
toggleFocusableChooserOption(firstChooserOption, true);
|
||||
initChooserOptionsKeyboardNavigation(modalBody, mappedModules, sectionChooserOptions, modal);
|
||||
}
|
||||
|
||||
// From the help screen go back to the module overview.
|
||||
if (e.target.matches(selectors.actions.closeOption)) {
|
||||
const carousel = modalBody.querySelector(selectors.regions.carousel);
|
||||
|
||||
// Trigger the transition between 'pages'.
|
||||
Carousel.getInstance(carousel).prev();
|
||||
carousel.addEventListener('slid.bs.carousel', () => {
|
||||
const allModules = modalBody.querySelector(selectors.regions.modules);
|
||||
const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));
|
||||
caller.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// The "clear search" button is triggered.
|
||||
if (e.target.closest(selectors.actions.clearSearch)) {
|
||||
// Clear the entered search query in the search bar and hide the search results container.
|
||||
const searchInput = modalBody.querySelector(selectors.actions.search);
|
||||
searchInput.value = "";
|
||||
searchInput.focus();
|
||||
toggleSearchResultsView(modal, mappedModules, searchInput.value);
|
||||
}
|
||||
};
|
||||
|
||||
// We essentially have two types of footer.
|
||||
// A fake one that is handled within the template for chooser_help and then all of the stuff for
|
||||
// modal.footer. We need to ensure we know exactly what type of footer we are using so we know what we
|
||||
// need to manage. The below code handles a real footer going to a mnet carousel item.
|
||||
const footerClickListener = async(e) => {
|
||||
if (footerData.footer === true) {
|
||||
const footerjs = await getPlugin(footerData.customfooterjs);
|
||||
await footerjs.footerClickListener(e, footerData, modal);
|
||||
}
|
||||
};
|
||||
|
||||
modal.getBodyPromise()
|
||||
|
||||
// The return value of getBodyPromise is a jquery object containing the body NodeElement.
|
||||
.then(body => body[0])
|
||||
|
||||
// Set up the carousel.
|
||||
.then(body => {
|
||||
const carousel = document.querySelector(selectors.regions.carousel);
|
||||
new Carousel(carousel, {
|
||||
interval: false,
|
||||
pause: true,
|
||||
keyboard: false
|
||||
});
|
||||
|
||||
return body;
|
||||
})
|
||||
|
||||
// Add the listener for clicks on the body.
|
||||
.then(body => {
|
||||
body.addEventListener('click', bodyClickListener);
|
||||
return body;
|
||||
})
|
||||
|
||||
// Add a listener for an input change in the activity chooser's search bar.
|
||||
.then(body => {
|
||||
const searchInput = body.querySelector(selectors.actions.search);
|
||||
// The search input is triggered.
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
// Display the search results.
|
||||
toggleSearchResultsView(modal, mappedModules, searchInput.value);
|
||||
}, 300));
|
||||
return body;
|
||||
})
|
||||
|
||||
// Register event listeners related to the keyboard navigation controls.
|
||||
.then(body => {
|
||||
// Get the active chooser options section.
|
||||
const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute("href");
|
||||
const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
|
||||
const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);
|
||||
|
||||
toggleFocusableChooserOption(firstChooserOption, true);
|
||||
initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal);
|
||||
|
||||
return body;
|
||||
})
|
||||
.catch(Notification.exception);
|
||||
|
||||
modal.getFooterPromise()
|
||||
|
||||
// The return value of getBodyPromise is a jquery object containing the body NodeElement.
|
||||
.then(footer => footer[0])
|
||||
// Add the listener for clicks on the footer.
|
||||
.then(footer => {
|
||||
footer.addEventListener('click', footerClickListener);
|
||||
return footer;
|
||||
})
|
||||
.catch(Notification.exception);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialise the keyboard navigation controls for the chooser options.
|
||||
*
|
||||
* @method initChooserOptionsKeyboardNavigation
|
||||
* @param {HTMLElement} body Our modal that we are working with
|
||||
* @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
|
||||
* @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items
|
||||
* @param {Object} modal Our created modal for the section
|
||||
*/
|
||||
const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer, modal = null) => {
|
||||
const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);
|
||||
|
||||
Array.from(chooserOptions).forEach((element) => {
|
||||
return element.addEventListener('keydown', (e) => {
|
||||
|
||||
// Check for enter/ space triggers for showing the help.
|
||||
if (e.keyCode === enter || e.keyCode === space) {
|
||||
if (e.target.matches(selectors.actions.optionActions.showSummary)) {
|
||||
e.preventDefault();
|
||||
const module = e.target.closest(selectors.regions.chooserOption.container);
|
||||
const moduleName = module.dataset.modname;
|
||||
const moduleData = mappedModules.get(moduleName);
|
||||
const carousel = document.querySelector(selectors.regions.carousel);
|
||||
new Carousel({
|
||||
interval: false,
|
||||
pause: true,
|
||||
keyboard: false
|
||||
});
|
||||
|
||||
// We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.
|
||||
moduleData.showFooter = modal.hasFooterContent();
|
||||
showModuleHelp(carousel, moduleData, modal);
|
||||
}
|
||||
}
|
||||
|
||||
// Next.
|
||||
if (e.keyCode === arrowRight) {
|
||||
e.preventDefault();
|
||||
const currentOption = e.target.closest(selectors.regions.chooserOption.container);
|
||||
const nextOption = currentOption.nextElementSibling;
|
||||
const firstOption = chooserOptionsContainer.firstElementChild;
|
||||
const toFocusOption = clickErrorHandler(nextOption, firstOption);
|
||||
focusChooserOption(toFocusOption, currentOption);
|
||||
}
|
||||
|
||||
// Previous.
|
||||
if (e.keyCode === arrowLeft) {
|
||||
e.preventDefault();
|
||||
const currentOption = e.target.closest(selectors.regions.chooserOption.container);
|
||||
const previousOption = currentOption.previousElementSibling;
|
||||
const lastOption = chooserOptionsContainer.lastElementChild;
|
||||
const toFocusOption = clickErrorHandler(previousOption, lastOption);
|
||||
focusChooserOption(toFocusOption, currentOption);
|
||||
}
|
||||
|
||||
if (e.keyCode === home) {
|
||||
e.preventDefault();
|
||||
const currentOption = e.target.closest(selectors.regions.chooserOption.container);
|
||||
const firstOption = chooserOptionsContainer.firstElementChild;
|
||||
focusChooserOption(firstOption, currentOption);
|
||||
}
|
||||
|
||||
if (e.keyCode === end) {
|
||||
e.preventDefault();
|
||||
const currentOption = e.target.closest(selectors.regions.chooserOption.container);
|
||||
const lastOption = chooserOptionsContainer.lastElementChild;
|
||||
focusChooserOption(lastOption, currentOption);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus on a chooser option element and remove the previous chooser element from the focus order
|
||||
*
|
||||
* @method focusChooserOption
|
||||
* @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
|
||||
* @param {HTMLElement|null} previousChooserOption The previous focused option element
|
||||
*/
|
||||
const focusChooserOption = (currentChooserOption, previousChooserOption = null) => {
|
||||
if (previousChooserOption !== null) {
|
||||
toggleFocusableChooserOption(previousChooserOption, false);
|
||||
}
|
||||
|
||||
toggleFocusableChooserOption(currentChooserOption, true);
|
||||
currentChooserOption.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or remove a chooser option from the focus order.
|
||||
*
|
||||
* @method toggleFocusableChooserOption
|
||||
* @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order
|
||||
* @param {Boolean} isFocusable Whether the chooser element is focusable or not
|
||||
*/
|
||||
const toggleFocusableChooserOption = (chooserOption, isFocusable) => {
|
||||
const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);
|
||||
const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);
|
||||
const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
|
||||
|
||||
if (isFocusable) {
|
||||
// Set tabindex to 0 to add current chooser option element to the focus order.
|
||||
chooserOption.tabIndex = 0;
|
||||
chooserOptionLink.tabIndex = 0;
|
||||
chooserOptionHelp.tabIndex = 0;
|
||||
chooserOptionFavourite.tabIndex = 0;
|
||||
} else {
|
||||
// Set tabindex to -1 to remove the previous chooser option element from the focus order.
|
||||
chooserOption.tabIndex = -1;
|
||||
chooserOptionLink.tabIndex = -1;
|
||||
chooserOptionHelp.tabIndex = -1;
|
||||
chooserOptionFavourite.tabIndex = -1;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Small error handling function to make sure the navigated to object exists
|
||||
*
|
||||
* @method clickErrorHandler
|
||||
* @param {HTMLElement} item What we want to check exists
|
||||
* @param {HTMLElement} fallback If we dont match anything fallback the focus
|
||||
* @return {HTMLElement}
|
||||
*/
|
||||
const clickErrorHandler = (item, fallback) => {
|
||||
if (item !== null) {
|
||||
return item;
|
||||
} else {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the search results in a defined container
|
||||
*
|
||||
* @method renderSearchResults
|
||||
* @param {HTMLElement} searchResultsContainer The container where the data should be rendered
|
||||
* @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
|
||||
*/
|
||||
const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
|
||||
const templateData = {
|
||||
'searchresultsnumber': searchResultsData.length,
|
||||
'searchresults': searchResultsData
|
||||
};
|
||||
// Build up the html & js ready to place into the help section.
|
||||
const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);
|
||||
await Templates.replaceNodeContents(searchResultsContainer, html, js);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle (display/hide) the search results depending on the value of the search query
|
||||
*
|
||||
* @method toggleSearchResultsView
|
||||
* @param {Object} modal Our created modal for the section
|
||||
* @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
|
||||
* @param {String} searchQuery The search query
|
||||
*/
|
||||
const toggleSearchResultsView = async(modal, mappedModules, searchQuery) => {
|
||||
const modalBody = modal.getBody()[0];
|
||||
const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);
|
||||
const chooserContainer = modalBody.querySelector(selectors.regions.chooser);
|
||||
const clearSearchButton = modalBody.querySelector(selectors.actions.clearSearch);
|
||||
|
||||
if (searchQuery.length > 0) { // Search query is present.
|
||||
const searchResultsData = searchModules(mappedModules, searchQuery);
|
||||
await renderSearchResults(searchResultsContainer, searchResultsData);
|
||||
const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);
|
||||
const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);
|
||||
if (firstSearchResultItem) {
|
||||
// Set the first result item to be focusable.
|
||||
toggleFocusableChooserOption(firstSearchResultItem, true);
|
||||
// Register keyboard events on the created search result items.
|
||||
initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer, modal);
|
||||
}
|
||||
// Display the "clear" search button in the activity chooser search bar.
|
||||
clearSearchButton.classList.remove('d-none');
|
||||
// Hide the default chooser options container.
|
||||
chooserContainer.setAttribute('hidden', 'hidden');
|
||||
// Display the search results container.
|
||||
searchResultsContainer.removeAttribute('hidden');
|
||||
} else { // Search query is not present.
|
||||
// Hide the "clear" search button in the activity chooser search bar.
|
||||
clearSearchButton.classList.add('d-none');
|
||||
// Hide the search results container.
|
||||
searchResultsContainer.setAttribute('hidden', 'hidden');
|
||||
// Display the default chooser options container.
|
||||
chooserContainer.removeAttribute('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the list of modules which have a name or description that matches the given search term.
|
||||
*
|
||||
* @method searchModules
|
||||
* @param {Array} modules List of available modules
|
||||
* @param {String} searchTerm The search term to match
|
||||
* @return {Array}
|
||||
*/
|
||||
const searchModules = (modules, searchTerm) => {
|
||||
if (searchTerm === '') {
|
||||
return modules;
|
||||
}
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
const searchResults = [];
|
||||
modules.forEach((activity) => {
|
||||
const activityName = activity.title.toLowerCase();
|
||||
const activityDesc = activity.help.toLowerCase();
|
||||
if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {
|
||||
searchResults.push(activity);
|
||||
}
|
||||
const sectionModal = Modal.create({
|
||||
body: bodyPromise,
|
||||
title: getString('addresourceoractivity'),
|
||||
footer: footerData.customfootertemplate,
|
||||
large: true,
|
||||
scrollable: false,
|
||||
templateContext: {
|
||||
classes: 'modchooser'
|
||||
},
|
||||
show: true,
|
||||
});
|
||||
|
||||
return searchResults;
|
||||
};
|
||||
try {
|
||||
const modulesData = await modulesDataPromise;
|
||||
|
||||
/**
|
||||
* Set up our tabindex information across the chooser.
|
||||
*
|
||||
* @method setupKeyboardAccessibility
|
||||
* @param {Promise} modal Our created modal for the section
|
||||
* @param {Map} mappedModules A map of all of the built module information
|
||||
*/
|
||||
const setupKeyboardAccessibility = (modal, mappedModules) => {
|
||||
modal.getModal()[0].tabIndex = -1;
|
||||
if (!modulesData) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.getBodyPromise().then(body => {
|
||||
document.querySelectorAll(selectors.elements.tab).forEach((tab) => {
|
||||
tab.addEventListener('shown.bs.tab', (e) => {
|
||||
const activeSectionId = e.target.getAttribute("href");
|
||||
const activeSectionChooserOptions = body[0]
|
||||
.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
|
||||
const firstChooserOption = activeSectionChooserOptions
|
||||
.querySelector(selectors.regions.chooserOption.container);
|
||||
const prevActiveSectionId = e.relatedTarget.getAttribute("href");
|
||||
const prevActiveSectionChooserOptions = body[0]
|
||||
.querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
|
||||
const modal = await sectionModal;
|
||||
const dialogue = new ActivityChooserDialogue(modal, modulesData, footerData);
|
||||
|
||||
// Disable the focus of every chooser option in the previous active section.
|
||||
disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
|
||||
// Enable the focus of the first chooser option in the current active section.
|
||||
toggleFocusableChooserOption(firstChooserOption, true);
|
||||
initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions, modal);
|
||||
});
|
||||
});
|
||||
const templateData = await dialogue.exporter.getModChooserTemplateData(modulesData);
|
||||
bodyPromiseResolver(await Templates.render('core_course/activitychooser', templateData));
|
||||
} catch (error) {
|
||||
const errorTemplateData = {
|
||||
'errormessage': error.message
|
||||
};
|
||||
bodyPromiseResolver(
|
||||
await Templates.render('core_course/local/activitychooser/error', errorTemplateData)
|
||||
);
|
||||
return;
|
||||
}).catch(Notification.exception);
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable the focus of all chooser options in a specific container (section).
|
||||
*
|
||||
* @method disableFocusAllChooserOptions
|
||||
* @param {HTMLElement} sectionChooserOptions The section that contains the chooser items
|
||||
*/
|
||||
const disableFocusAllChooserOptions = (sectionChooserOptions) => {
|
||||
const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);
|
||||
allChooserOptions.forEach((chooserOption) => {
|
||||
toggleFocusableChooserOption(chooserOption, false);
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the module chooser.
|
||||
*
|
||||
* @deprecated since Moodle 5.1
|
||||
* @todo Remove the method in Moodle 6.0 (MDL-85655).
|
||||
* @method displayChooser
|
||||
* @param {Promise} modalPromise Our created modal for the section
|
||||
* @param {Array} sectionModules An array of all of the built module information
|
||||
@@ -505,24 +102,340 @@ const disableFocusAllChooserOptions = (sectionChooserOptions) => {
|
||||
* @param {Object} footerData Our base footer object.
|
||||
*/
|
||||
export const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => {
|
||||
// Make a map so we can quickly fetch a specific module's object for either rendering or searching.
|
||||
const mappedModules = new Map();
|
||||
sectionModules.forEach((module) => {
|
||||
mappedModules.set(module.componentname + '_' + module.link, module);
|
||||
});
|
||||
window.console.warn(
|
||||
'The displayChooser function is deprecated. ' +
|
||||
'Please displayActivityChooserModal instead.'
|
||||
);
|
||||
|
||||
// Register event listeners.
|
||||
modalPromise.then(modal => {
|
||||
registerListenerEvents(modal, mappedModules, partialFavourite, footerData);
|
||||
|
||||
// We want to focus on the first chooser option element as soon as the modal is opened.
|
||||
setupKeyboardAccessibility(modal, mappedModules);
|
||||
|
||||
// We want to focus on the action select when the dialog is closed.
|
||||
modal.getRoot().on(ModalEvents.hidden, () => {
|
||||
modal.destroy();
|
||||
});
|
||||
|
||||
new ActivityChooserDialogue(modal, sectionModules, footerData);
|
||||
return modal;
|
||||
}).catch(Notification.exception);
|
||||
};
|
||||
|
||||
/**
|
||||
* Activity Chooser Dialogue class.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class ActivityChooserDialogue {
|
||||
/**
|
||||
* Constructor for the ActivityChooserDialogue class.
|
||||
* @param {Modal} modal The modal object.
|
||||
* @param {Object} modulesData The data for the modules.
|
||||
* @param {Object} footerData The data for the footer.
|
||||
*/
|
||||
constructor(modal, modulesData, footerData) {
|
||||
this.modal = modal;
|
||||
this.dialogueDom = null; // We cannot init until we have the modal body loaded.
|
||||
this.footerData = footerData;
|
||||
this.exporter = new Exporter();
|
||||
// This attribute marks when the tab content is dirty and needs to be refreshed when the user changes the tab.
|
||||
// We don't want the content to be updated while the user is managing their favourites.
|
||||
this.isFavouriteTabDirty = false;
|
||||
// Make a map so we can quickly fetch a specific module's object for either rendering or searching.
|
||||
this.mappedModules = new Map();
|
||||
modulesData.forEach((module) => {
|
||||
this.mappedModules.set(module.componentname + '_' + module.link, module);
|
||||
});
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the activity chooser dialogue.
|
||||
*
|
||||
* @return {Promise} A promise that resolves when the modal is ready.
|
||||
*/
|
||||
async init() {
|
||||
const modalBody = getFirst(await this.modal.getBodyPromise());
|
||||
this.dialogueDom = new DialogueDom(this, modalBody, this.exporter);
|
||||
this.registerModalListenerEvents();
|
||||
this.setupKeyboardAccessibility();
|
||||
// We want to focus on the action select when the dialog is closed.
|
||||
this.modal.getRoot().on(ModalEvents.hidden, () => {
|
||||
this.modal.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register chooser related event listeners.
|
||||
*
|
||||
* @returns {Promise} A promise that resolves when events are registered
|
||||
*/
|
||||
async registerModalListenerEvents() {
|
||||
const modalRoot = getFirst(this.modal.getRoot());
|
||||
|
||||
// Changing the tab should cancel any active search.
|
||||
modalRoot.addEventListener(
|
||||
'shown.bs.tab',
|
||||
(event) => {
|
||||
// The all tab has the search result, so we do not want to clear the search input.
|
||||
if (event.target.closest(selectors.regions.allTabNav)) {
|
||||
return;
|
||||
}
|
||||
const searchInput = this.dialogueDom.getSearchInputElement();
|
||||
if (searchInput.value.length > 0) {
|
||||
searchInput.value = "";
|
||||
this.toggleSearchResultsView(searchInput.value);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add the listener for clicks on the full modal.
|
||||
modalRoot.addEventListener(
|
||||
'click',
|
||||
this.handleModalClick.bind(this),
|
||||
);
|
||||
|
||||
// Add a listener for an input change in the activity chooser's search bar.
|
||||
const searchInput = this.dialogueDom.getSearchInputElement();
|
||||
searchInput.addEventListener(
|
||||
'input',
|
||||
debounce(
|
||||
() => {
|
||||
this.toggleSearchResultsView(searchInput.value);
|
||||
},
|
||||
300,
|
||||
{pending: true},
|
||||
),
|
||||
);
|
||||
|
||||
this.dialogueDom.initBootstrapComponents();
|
||||
|
||||
// Handle focus when a new tab is shown.
|
||||
modalRoot.addEventListener('shown.bs.tab', (event) => {
|
||||
if (event.relatedTarget) {
|
||||
this.dialogueDom.disableFocusAllChooserOptions(event.relatedTarget);
|
||||
}
|
||||
this.dialogueDom.initActiveTabNavigation();
|
||||
});
|
||||
|
||||
// Update the favourite tab content when the user changes the tab.
|
||||
modalRoot.addEventListener('shown.bs.tab', () => {
|
||||
if (this.isFavouriteTabDirty && !this.dialogueDom.isFavoutiteTabActive()) {
|
||||
this.refreshFavouritesTabContent();
|
||||
}
|
||||
});
|
||||
|
||||
this.dialogueDom.initActiveTabNavigation();
|
||||
|
||||
const modalFooter = getFirst(await this.modal.getFooterPromise());
|
||||
|
||||
// Add the listener for clicks on the footer.
|
||||
modalFooter.addEventListener(
|
||||
'click',
|
||||
this.handleFooterClick.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the click event on the footer of the modal.
|
||||
*
|
||||
* @param {Object} event The event object
|
||||
* @return {Promise} A promise that resolves when the event is handled
|
||||
*/
|
||||
async handleFooterClick(event) {
|
||||
if (this.footerData.footer === true) {
|
||||
const footerjs = await getPlugin(this.footerData.customfooterjs);
|
||||
await footerjs.footerClickListener(event, this.footerData, this.modal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal click handler.
|
||||
*
|
||||
* @param {Object} event The event object
|
||||
* @return {Promise} A promise that resolves when the event is handled
|
||||
*/
|
||||
async handleModalClick(event) {
|
||||
const target = event.target;
|
||||
|
||||
if (target.closest(selectors.actions.optionActions.showSummary)) {
|
||||
this.handleShowSummary(target);
|
||||
}
|
||||
|
||||
if (target.closest(selectors.actions.optionActions.manageFavourite)) {
|
||||
await this.handleFavouriteClick(target);
|
||||
}
|
||||
|
||||
// From the help screen go back to the module overview.
|
||||
if (target.matches(selectors.actions.closeOption)) {
|
||||
this.dialogueDom.hideModuleHelp(target.dataset.modname);
|
||||
}
|
||||
|
||||
// The "clear search" button is triggered.
|
||||
if (target.closest(selectors.actions.clearSearch)) {
|
||||
this.handleClearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the summary of a module when the user clicks on the "show summary" button.
|
||||
*
|
||||
* @param {HTMLElement} target The target element that triggered the event
|
||||
*/
|
||||
handleShowSummary(target) {
|
||||
const module = this.dialogueDom.getClosestChooserOption(target);
|
||||
const moduleName = module.dataset.modname;
|
||||
const moduleData = this.mappedModules.get(moduleName);
|
||||
// We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.
|
||||
moduleData.showFooter = this.modal.hasFooterContent();
|
||||
this.dialogueDom.showModuleHelp(moduleData, this.modal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the favourite state of a module when the user clicks on the "starred" button.
|
||||
*
|
||||
* @param {HTMLElement} target The target element that triggered the event
|
||||
* @return {Promise} A promise that resolves when the event is handled
|
||||
*/
|
||||
async handleFavouriteClick(target) {
|
||||
const caller = target.closest(selectors.actions.optionActions.manageFavourite);
|
||||
const id = caller.dataset.id;
|
||||
const name = caller.dataset.name;
|
||||
const internal = caller.dataset.internal;
|
||||
const isFavourite = caller.dataset.favourited;
|
||||
|
||||
// Switch on fave or not.
|
||||
if (isFavourite === 'true') {
|
||||
await Repository.unfavouriteModule(name, id);
|
||||
this.updateFavouriteItemValue(internal, false);
|
||||
} else {
|
||||
await Repository.favouriteModule(name, id);
|
||||
this.updateFavouriteItemValue(internal, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a clear search action.
|
||||
*/
|
||||
handleClearSearch() {
|
||||
const searchInput = this.dialogueDom.getSearchInputElement();
|
||||
searchInput.value = "";
|
||||
searchInput.focus();
|
||||
this.toggleSearchResultsView(searchInput.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up our tabindex information across the chooser.
|
||||
*
|
||||
* @method setupKeyboardAccessibility
|
||||
*/
|
||||
setupKeyboardAccessibility() {
|
||||
const mainElement = getFirst(this.modal.getModal());
|
||||
|
||||
mainElement.tabIndex = -1;
|
||||
|
||||
mainElement.addEventListener('keydown', (e) => {
|
||||
const currentOption = this.dialogueDom.getClosestChooserOption(e.target);
|
||||
if (currentOption === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for enter/ space triggers for showing the help.
|
||||
if (e.keyCode === enter || e.keyCode === space) {
|
||||
if (e.target.matches(selectors.actions.optionActions.showSummary)) {
|
||||
e.preventDefault();
|
||||
this.handleShowSummary(e.target);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.keyCode === arrowRight) {
|
||||
e.preventDefault();
|
||||
this.dialogueDom.focusNextChooserOption(currentOption);
|
||||
}
|
||||
if (e.keyCode === arrowLeft) {
|
||||
e.preventDefault();
|
||||
this.dialogueDom.focusPreviousChooserOption(currentOption);
|
||||
}
|
||||
if (e.keyCode === home) {
|
||||
e.preventDefault();
|
||||
this.dialogueDom.focusFirstChooserOption(currentOption);
|
||||
}
|
||||
if (e.keyCode === end) {
|
||||
e.preventDefault();
|
||||
this.dialogueDom.focusLastChooserOption(currentOption);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle (display/hide) the search results depending on the value of the search query
|
||||
*
|
||||
* @method toggleSearchResultsView
|
||||
* @param {String} searchQuery The search query
|
||||
*/
|
||||
async toggleSearchResultsView(searchQuery) {
|
||||
const searchResultsData = this.searchModules(searchQuery);
|
||||
|
||||
if (searchQuery.length > 0) {
|
||||
await this.dialogueDom.refreshSearchResults(searchResultsData);
|
||||
this.dialogueDom.showAllActivitiesTab(true);
|
||||
} else {
|
||||
this.dialogueDom.cleanSearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of modules which have a name or description that matches the given search term.
|
||||
*
|
||||
* @method searchModules
|
||||
* @param {String} searchTerm The search term to match
|
||||
* @return {Array}
|
||||
*/
|
||||
searchModules(searchTerm) {
|
||||
if (searchTerm === '') {
|
||||
return this.mappedModules;
|
||||
}
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
const searchResults = [];
|
||||
this.mappedModules.forEach((activity) => {
|
||||
const activityName = activity.title.toLowerCase();
|
||||
const activityDesc = activity.help.toLowerCase();
|
||||
if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {
|
||||
searchResults.push(activity);
|
||||
}
|
||||
});
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the favourite item value in the mapped modules.
|
||||
*
|
||||
* @param {String} internal The internal name of the module.
|
||||
* @param {Boolean} favourite Whether the module is a favourite or not.
|
||||
* @return {Promise} A promise that resolves when the item is updated.
|
||||
*/
|
||||
async updateFavouriteItemValue(internal, favourite) {
|
||||
const moduleItem = this.mappedModules.find(({name}) => name === internal);
|
||||
if (!moduleItem) {
|
||||
return;
|
||||
}
|
||||
moduleItem.favourite = favourite;
|
||||
|
||||
this.dialogueDom.updateItemStarredIcons(internal, favourite);
|
||||
|
||||
if (this.dialogueDom.isFavoutiteTabActive()) {
|
||||
this.isFavouriteTabDirty = true;
|
||||
} else {
|
||||
this.refreshFavouritesTabContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the favourites tab content.
|
||||
*
|
||||
* Note: this method will also hide the favourites tab if there are no favourite modules
|
||||
* to keep the modal consistent.
|
||||
*
|
||||
* @return {Promise} A promise that resolves when the content is refreshed.
|
||||
*/
|
||||
async refreshFavouritesTabContent() {
|
||||
this.isFavouriteTabDirty = false;
|
||||
const favouriteCount = this.mappedModules.filter(mod => mod.favourite === true).size;
|
||||
this.dialogueDom.toggleFavouriteTabDisplay(favouriteCount > 0);
|
||||
await this.dialogueDom.refreshFavouritesTabContent(this.mappedModules);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import {addIconToContainer} from 'core/loadingicon';
|
||||
import Carousel from 'theme_boost/bootstrap/carousel';
|
||||
import Notification from 'core/notification';
|
||||
import Pending from 'core/pending';
|
||||
import selectors from 'core_course/local/activitychooser/selectors';
|
||||
import Tab from 'theme_boost/bootstrap/tab';
|
||||
import * as Templates from 'core/templates';
|
||||
|
||||
|
||||
/**
|
||||
* The activity changer dialogue DOM manipulation module.
|
||||
*
|
||||
* @module core_course/local/activitychooser/dialoguedom
|
||||
* @copyright 2025 Ferran Recio <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
export default class ChooserDialogueDOM {
|
||||
|
||||
constructor(dialogue, modalBody, exporter) {
|
||||
this.modalBody = modalBody;
|
||||
this.exporter = exporter;
|
||||
// Temporal variable while migrating methods.
|
||||
this.dialogue = dialogue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search input element.
|
||||
*
|
||||
* @return {HTMLElement} The search input element.
|
||||
*/
|
||||
getSearchInputElement() {
|
||||
return this.modalBody.querySelector(selectors.actions.search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the closest chooser option element.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @return {HTMLElement|null} element
|
||||
*/
|
||||
getClosestChooserOption(element) {
|
||||
return element.closest(selectors.regions.chooserOption.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the search tab is active.
|
||||
*
|
||||
* @return {Boolean} True if the search tab is active, false otherwise.
|
||||
*/
|
||||
isFavoutiteTabActive() {
|
||||
const favouriteTab = this.modalBody.querySelector(selectors.regions.favouriteTabNav);
|
||||
return favouriteTab && favouriteTab.classList.contains('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the search results.
|
||||
*
|
||||
* @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
|
||||
*/
|
||||
async refreshSearchResults(searchResultsData) {
|
||||
const searchResultsContainer = this.modalBody.querySelector(selectors.regions.searchResults);
|
||||
const clearSearchButton = this.modalBody.querySelector(selectors.actions.clearSearch);
|
||||
|
||||
await this.renderSearchResults(searchResultsContainer, searchResultsData);
|
||||
const chooserOptionsContainer = searchResultsContainer.querySelector(selectors.regions.chooserOptions);
|
||||
const firstSearchResultItem = chooserOptionsContainer.querySelector(selectors.regions.chooserOption.container);
|
||||
if (firstSearchResultItem) {
|
||||
// Set the first result item to be focusable.
|
||||
this.toggleFocusableChooserOption(firstSearchResultItem, true);
|
||||
// Register keyboard events on the created search result items.
|
||||
}
|
||||
clearSearchButton.classList.remove('d-none');
|
||||
|
||||
// Results are rendered in the all activities tab, so we need to hide the category content.
|
||||
const tabContent = searchResultsContainer.closest(selectors.regions.tabContent);
|
||||
const categoryContent = tabContent.querySelector(selectors.regions.categoryContent);
|
||||
categoryContent.classList.add('d-none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search results.
|
||||
*/
|
||||
cleanSearchResults() {
|
||||
const searchResultsContainer = this.modalBody.querySelector(selectors.regions.searchResults);
|
||||
const clearSearchButton = this.modalBody.querySelector(selectors.actions.clearSearch);
|
||||
searchResultsContainer.innerHTML = '';
|
||||
clearSearchButton.classList.add('d-none');
|
||||
|
||||
// Results are rendered in the all activities tab, so we need to show the category content again.
|
||||
const tabContent = searchResultsContainer.closest(selectors.regions.tabContent);
|
||||
const categoryContent = tabContent.querySelector(selectors.regions.categoryContent);
|
||||
categoryContent.classList.remove('d-none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the search results in a defined container
|
||||
*
|
||||
* @private
|
||||
* @method renderSearchResults
|
||||
* @param {HTMLElement} searchResultsContainer The container where the data should be rendered
|
||||
* @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
|
||||
*/
|
||||
async renderSearchResults(searchResultsContainer, searchResultsData) {
|
||||
const templateData = this.exporter.getSearchResultData(searchResultsData);
|
||||
// Build up the html & js ready to place into the help section.
|
||||
const {html, js} = await Templates.renderForPromise(
|
||||
'core_course/local/activitychooser/search_results',
|
||||
templateData
|
||||
);
|
||||
await Templates.replaceNodeContents(searchResultsContainer, html, js);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the "All activities" tab.
|
||||
*
|
||||
* @method showAllActivitiesTab
|
||||
* @return {HTMLElement} The "All activities" tab element.
|
||||
*/
|
||||
showAllActivitiesTab() {
|
||||
const navTab = this.modalBody.querySelector(selectors.regions.allTabNav);
|
||||
|
||||
if (navTab.classList.contains('active')) {
|
||||
return navTab;
|
||||
}
|
||||
|
||||
const pendingPromise = new Pending('core_course/activitychooser:alltab');
|
||||
navTab.addEventListener('shown.bs.tab', pendingPromise.resolve, {once: true});
|
||||
|
||||
Tab.getOrCreateInstance(navTab).show();
|
||||
return navTab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the starred icons in the chooser modal.
|
||||
*
|
||||
* @method updateItemStarredIcons
|
||||
* @param {String} internal The internal name of the module.
|
||||
* @param {Boolean} favourite Whether the module is a favourite or not.
|
||||
*/
|
||||
updateItemStarredIcons(internal, favourite) {
|
||||
const favouriteButtons = this.modalBody.querySelectorAll(
|
||||
`${selectors.elements.moduleItem(internal)} ${selectors.actions.optionActions.manageFavourite}`
|
||||
);
|
||||
Array.from(favouriteButtons).forEach((element) => {
|
||||
element.classList.toggle('text-muted', !favourite);
|
||||
element.classList.toggle('text-primary', favourite);
|
||||
element.dataset.favourited = favourite;
|
||||
element.setAttribute('aria-pressed', favourite);
|
||||
element.querySelector(selectors.elements.favouriteIconActive)?.classList.toggle('d-none', !favourite);
|
||||
element.querySelector(selectors.elements.favouriteIconInactive)?.classList.toggle('d-none', favourite);
|
||||
|
||||
const iconSelectsor = favourite ? selectors.elements.favouriteIconActive : selectors.elements.favouriteIconInactive;
|
||||
const favouriteIcon = element.querySelector(iconSelectsor);
|
||||
element.setAttribute('aria-label', favouriteIcon?.getAttribute('data-action-label') || '');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the favourite content.
|
||||
*
|
||||
* @param {Array} mappedModules The modules to be displayed in the favourite tab.
|
||||
*/
|
||||
async refreshFavouritesTabContent(mappedModules) {
|
||||
const templateData = await this.exporter.getFavouriteTabData(mappedModules);
|
||||
const favouriteArea = this.modalBody.querySelector(selectors.regions.favouriteTab);
|
||||
const {html, js} = await Templates.renderForPromise(
|
||||
'core_course/local/activitychooser/tabcontent',
|
||||
templateData,
|
||||
);
|
||||
await Templates.replaceNodeContents(favouriteArea, html, js);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the display of the favourite tab.
|
||||
*
|
||||
* The favourite tab is only displayed when there are favourite modules
|
||||
* or when it is the active tab.
|
||||
*
|
||||
* @param {Boolean} displayed Whether we want to show or hide the favourite tab
|
||||
*/
|
||||
toggleFavouriteTabDisplay(displayed) {
|
||||
const favouriteTabNav = this.modalBody.querySelector(selectors.regions.favouriteTabNav);
|
||||
|
||||
let moveFocusTo;
|
||||
if (!displayed && favouriteTabNav.classList.contains('active')) {
|
||||
moveFocusTo = this.showAllActivitiesTab();
|
||||
}
|
||||
|
||||
favouriteTabNav?.classList.toggle('d-none', !displayed);
|
||||
favouriteTabNav.tabIndex = displayed ? 0 : -1;
|
||||
// The disabled class is used by Boostrap Tab for keyboard navigation.
|
||||
if (displayed) {
|
||||
favouriteTabNav.classList.remove('disabled');
|
||||
} else {
|
||||
favouriteTabNav.classList.add('disabled');
|
||||
}
|
||||
|
||||
if (moveFocusTo) {
|
||||
moveFocusTo.focus();
|
||||
}
|
||||
this.initActiveTabNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event from the main module 'page' navigate to it's help section via a carousel.
|
||||
*
|
||||
* @method showModuleHelp
|
||||
* @param {Object} moduleData Data of the module to carousel to
|
||||
* @param {Modal} modal The modal object
|
||||
*/
|
||||
showModuleHelp(moduleData, modal) {
|
||||
const carousel = this.modalBody.querySelector(selectors.regions.carousel);
|
||||
// If we have a real footer then we need to change temporarily.
|
||||
if (moduleData.showFooter === true) {
|
||||
modal.setFooter(Templates.render(
|
||||
'core_course/local/activitychooser/footer_partial',
|
||||
moduleData
|
||||
));
|
||||
}
|
||||
const help = carousel.querySelector(selectors.regions.help);
|
||||
help.innerHTML = '';
|
||||
help.classList.add('m-auto');
|
||||
|
||||
// Add a spinner.
|
||||
const spinnerPromise = addIconToContainer(help);
|
||||
|
||||
// Used later...
|
||||
let transitionPromiseResolver = null;
|
||||
const transitionPromise = new Promise(resolve => {
|
||||
transitionPromiseResolver = resolve;
|
||||
});
|
||||
|
||||
// Build up the html & js ready to place into the help section.
|
||||
const contentPromise = Templates.renderForPromise(
|
||||
'core_course/local/activitychooser/help',
|
||||
moduleData
|
||||
);
|
||||
|
||||
// Wait for the content to be ready, and for the transition to be complet.
|
||||
Promise.all([contentPromise, spinnerPromise, transitionPromise])
|
||||
.then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
|
||||
.then(() => {
|
||||
help.querySelector(selectors.regions.chooserSummary.header).focus();
|
||||
return help;
|
||||
})
|
||||
.catch(Notification.exception);
|
||||
|
||||
// Move to the next slide, and resolve the transition promise when it's done.
|
||||
carousel.addEventListener(
|
||||
'slid.bs.carousel',
|
||||
() => {
|
||||
transitionPromiseResolver();
|
||||
},
|
||||
{once: true}
|
||||
);
|
||||
// Trigger the transition between 'pages'.
|
||||
Carousel.getInstance(carousel).next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the help section of the chooser.
|
||||
*
|
||||
* @param {String|null} internal The internal name of the module to return to, if any.
|
||||
*/
|
||||
hideModuleHelp(internal = null) {
|
||||
const carousel = this.modalBody.querySelector(selectors.regions.carousel);
|
||||
// Trigger the transition between 'pages'.
|
||||
Carousel.getInstance(carousel).prev();
|
||||
if (internal === null) {
|
||||
return;
|
||||
}
|
||||
carousel.addEventListener(
|
||||
'slid.bs.carousel',
|
||||
() => {
|
||||
this.focusChooserOption(internal);
|
||||
},
|
||||
{once: true}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on a specific activity inside the active tab (if present).
|
||||
*
|
||||
* @private
|
||||
* @method focusChooserOption
|
||||
* @param {String} internal The internal name of the module.
|
||||
*/
|
||||
focusChooserOption(internal) {
|
||||
const currentTabNav = this.modalBody.querySelector(selectors.elements.activetab);
|
||||
const activeSectionId = currentTabNav.getAttribute("href");
|
||||
const sectionChooserOptions = this.modalBody.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
|
||||
const newCurrent = sectionChooserOptions.querySelector(selectors.regions.getModuleSelector(internal));
|
||||
|
||||
if (!newCurrent) {
|
||||
throw new Error(`Invalid chooser option to focus on: ${internal}`);
|
||||
}
|
||||
|
||||
// Chooser can only have one element focusable at a time, so we disable them all first.
|
||||
this.disableFocusAllChooserOptions(currentTabNav);
|
||||
this.toggleFocusableChooserOption(newCurrent, true);
|
||||
|
||||
// Little hack: we want the element considered a focus-visible element.
|
||||
// But the focus method does not trigger the focus-visible class. There's an
|
||||
// experimental "{focusVisible: true}" option in the focus method, but it's not
|
||||
// supported in all browsers yet so we need to fake an editable element.
|
||||
newCurrent.contentEditable = true;
|
||||
newCurrent.focus();
|
||||
newCurrent.contentEditable = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the active tab navigation.
|
||||
*/
|
||||
initActiveTabNavigation() {
|
||||
const activeSectionId = this.modalBody.querySelector(selectors.elements.activetab).getAttribute("href");
|
||||
const sectionChooserOptions = this.modalBody.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
|
||||
const firstChooserOption = sectionChooserOptions?.querySelector(selectors.regions.chooserOption.container);
|
||||
if (!firstChooserOption) {
|
||||
return;
|
||||
}
|
||||
this.toggleFocusableChooserOption(firstChooserOption, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise all Boostrap components.
|
||||
*/
|
||||
initBootstrapComponents() {
|
||||
this.modalBody.querySelectorAll(selectors.elements.tab).forEach((navTab) => {
|
||||
// Init the Bootstrap Tab navigation.
|
||||
Tab.getOrCreateInstance(navTab);
|
||||
});
|
||||
|
||||
// Set up the carousel.
|
||||
const carousel = this.modalBody.querySelector(selectors.regions.carousel);
|
||||
new Carousel(carousel, {
|
||||
interval: false,
|
||||
pause: true,
|
||||
keyboard: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the focus of all chooser options in a specific container (section).
|
||||
*
|
||||
* @method disableFocusAllChooserOptions
|
||||
* @param {HTMLElement} tabNav The tab navigation element (from the shown.bs.ta event).
|
||||
*/
|
||||
disableFocusAllChooserOptions(tabNav) {
|
||||
const tabId = tabNav.getAttribute("href");
|
||||
const chooserOptions = this.modalBody.querySelector(
|
||||
selectors.regions.getSectionChooserOptions(tabId)
|
||||
);
|
||||
|
||||
if (chooserOptions === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allChooserOptions = chooserOptions.querySelectorAll(selectors.regions.chooserOption.container);
|
||||
allChooserOptions.forEach((chooserOption) => {
|
||||
this.toggleFocusableChooserOption(chooserOption, false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove a chooser option from the focus order.
|
||||
*
|
||||
* @private
|
||||
* @method toggleFocusableChooserOption
|
||||
* @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order
|
||||
* @param {Boolean} isFocusable Whether the chooser element is focusable or not
|
||||
*/
|
||||
toggleFocusableChooserOption(chooserOption, isFocusable) {
|
||||
const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);
|
||||
const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);
|
||||
const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
|
||||
|
||||
if (isFocusable) {
|
||||
// Set tabindex to 0 to add current chooser option element to the focus order.
|
||||
chooserOption.tabIndex = 0;
|
||||
chooserOptionLink.tabIndex = 0;
|
||||
chooserOptionHelp.tabIndex = 0;
|
||||
chooserOptionFavourite.tabIndex = 0;
|
||||
} else {
|
||||
// Set tabindex to -1 to remove the previous chooser option element from the focus order.
|
||||
chooserOption.tabIndex = -1;
|
||||
chooserOptionLink.tabIndex = -1;
|
||||
chooserOptionHelp.tabIndex = -1;
|
||||
chooserOptionFavourite.tabIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the focus to the previous chooser option element.
|
||||
*
|
||||
* @param {HTMLElement} current The current chooser option element
|
||||
*/
|
||||
focusNextChooserOption(current) {
|
||||
this.moveChooserOptionFocus(
|
||||
current,
|
||||
(currentOption) => currentOption.nextElementSibling ?? currentOption,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the focus to the previous chooser option element.
|
||||
*
|
||||
* @param {HTMLElement} current The current chooser option element
|
||||
*/
|
||||
focusPreviousChooserOption(current) {
|
||||
this.moveChooserOptionFocus(
|
||||
current,
|
||||
(currentOption) => currentOption.previousElementSibling ?? currentOption,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the focus to the first chooser option element.
|
||||
*
|
||||
* @param {HTMLElement} current The current chooser option element
|
||||
*/
|
||||
focusFirstChooserOption(current) {
|
||||
this.moveChooserOptionFocus(
|
||||
current,
|
||||
(currentOption, container) => container.firstElementChild ?? currentOption,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the focus to the last chooser option element.
|
||||
*
|
||||
* @param {HTMLElement} current The current chooser option element
|
||||
*/
|
||||
focusLastChooserOption(current) {
|
||||
this.moveChooserOptionFocus(
|
||||
current,
|
||||
(currentOption, container) => container.lastElementChild ?? currentOption,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the focus to the next chooser option element.
|
||||
*
|
||||
* @private
|
||||
* @param {HTMLElement} current The current chooser option element
|
||||
* @param {Function} getNextFocus Function to get the next focusable element
|
||||
*/
|
||||
moveChooserOptionFocus(current, getNextFocus) {
|
||||
const currentOption = this.getClosestChooserOption(current);
|
||||
const container = current.closest(selectors.regions.chooserOptions);
|
||||
|
||||
if (!container || !currentOption) {
|
||||
throw new Error('Invalid chooser options container or current option');
|
||||
}
|
||||
|
||||
const newFocusOption = getNextFocus(currentOption, container);
|
||||
if (!newFocusOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleFocusableChooserOption(currentOption, false);
|
||||
this.toggleFocusableChooserOption(newFocusOption, true);
|
||||
newFocusOption.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Module to generate template data for the activity chooser.
|
||||
*
|
||||
* @module core_course/local/activitychooser/exporter
|
||||
* @copyright 2025 Ferran Recio <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
import {getStrings} from 'core/str';
|
||||
|
||||
const activityCategories = ['activities', 'resources'];
|
||||
|
||||
let allStrings = null;
|
||||
|
||||
loadNecessaryStrings();
|
||||
|
||||
export default class {
|
||||
/**
|
||||
* A tab data structure.
|
||||
*
|
||||
* @typedef {object} TabData
|
||||
* @property {String} tabId the tab ID
|
||||
* @property {Boolean} active whether the tab is active or not
|
||||
* @property {Array} items the filtered modules to be displayed in the tab
|
||||
* @property {Boolean} displayed whether the tab is displayed or not
|
||||
* @property {String} tabLabel the tab label
|
||||
* @property {String|null} tabHelp the help text for the tab (optional)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a tab data object for the activity chooser.
|
||||
*
|
||||
* @private
|
||||
* @param {String} tabId Tab ID.
|
||||
* @param {Array} filteredModules Filtered modules to be displayed in the tab.
|
||||
* @param {String} tabLabel Tab label.
|
||||
* @param {String|null} tabHelp Help text for the tab (optional).
|
||||
* @param {Boolean} active Whether the tab is active or not.
|
||||
* @return {TabData} Tab data object.
|
||||
*/
|
||||
getTabData(tabId, filteredModules, tabLabel, tabHelp = null, active = false) {
|
||||
const result = {
|
||||
tabId: tabId,
|
||||
active: active,
|
||||
items: filteredModules,
|
||||
displayed: filteredModules.length > 0,
|
||||
tabLabel,
|
||||
};
|
||||
if (tabHelp) {
|
||||
result.tabHelp = tabHelp;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the modules data to be used in the chooser.
|
||||
*
|
||||
* The modulesData can be a plain array or a Map. This method will convert it to a
|
||||
* plain array of objects.
|
||||
*
|
||||
* @param {Array|Map} modulesData Modules data to be used in the chooser.
|
||||
* @return {Array} Normalised modules data.
|
||||
*/
|
||||
normaliseModulesData(modulesData) {
|
||||
if (modulesData instanceof Map) {
|
||||
modulesData = Array.from(modulesData.values());
|
||||
} else if (!Array.isArray(modulesData)) {
|
||||
throw new Error('Invalid modules data format. Expected an array or a Map.');
|
||||
}
|
||||
return modulesData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the chooser template data for a specific section.
|
||||
*
|
||||
* @param {Array|Map} modulesData Modules data to be used in the chooser.
|
||||
* @return {Promise<Object>} Promise resolved with the template data.
|
||||
*/
|
||||
async getModChooserTemplateData(modulesData) {
|
||||
modulesData = this.normaliseModulesData(modulesData);
|
||||
const allStrings = await loadNecessaryStrings();
|
||||
const favouriteTab = await this.getFavouriteTabData(modulesData);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
...this.getTabData(
|
||||
'all',
|
||||
modulesData,
|
||||
allStrings.all,
|
||||
null,
|
||||
!favouriteTab.displayed,
|
||||
),
|
||||
hasSearchResults: true, // The all tab will also show search results.
|
||||
},
|
||||
favouriteTab,
|
||||
{
|
||||
...this.getTabData(
|
||||
'recommended',
|
||||
modulesData.filter(mod => mod.recommended === true),
|
||||
allStrings.recommended,
|
||||
allStrings.recommended_help
|
||||
),
|
||||
separator: true, // Add a separator before the purpose categories.
|
||||
},
|
||||
];
|
||||
|
||||
activityCategories.forEach((category, index) => {
|
||||
const categoryModules = modulesData.filter(mod => mod.archetype == index);
|
||||
if (categoryModules.length === 0) {
|
||||
return;
|
||||
}
|
||||
tabs.push(
|
||||
this.getTabData(
|
||||
category,
|
||||
categoryModules,
|
||||
allStrings[category],
|
||||
allStrings[category + '_help']
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
modules: modulesData,
|
||||
tabs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the favourite tab data.
|
||||
*
|
||||
* @param {Array|Map} modulesData Modules data to be used in the chooser.
|
||||
* @return {Promise<TabData>} Promise resolved with the template data.
|
||||
*/
|
||||
async getFavouriteTabData(modulesData) {
|
||||
modulesData = this.normaliseModulesData(modulesData);
|
||||
const allStrings = await loadNecessaryStrings();
|
||||
|
||||
// We need to deconstruct the modules data to ensure it is an array.
|
||||
const favouriteModules = modulesData.filter(
|
||||
mod => {
|
||||
return mod.favourite === true;
|
||||
}
|
||||
);
|
||||
|
||||
return this.getTabData(
|
||||
'favourites',
|
||||
favouriteModules,
|
||||
allStrings.favourites,
|
||||
null,
|
||||
favouriteModules.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search result template data.
|
||||
*
|
||||
* @param {Array|Map} resultsModulesData Modules data to be used in the chooser.
|
||||
* @return {Object} The template data.
|
||||
*/
|
||||
getSearchResultData(resultsModulesData) {
|
||||
resultsModulesData = this.normaliseModulesData(resultsModulesData);
|
||||
return {
|
||||
'searchresultsnumber': resultsModulesData.length,
|
||||
'searchresults': resultsModulesData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of items in a tab.
|
||||
*
|
||||
* @param {TabData} tabData The tab data.
|
||||
* @return {Number} The number of items in the tab.
|
||||
*/
|
||||
countTabItems(tabData) {
|
||||
return tabData.items?.length ?? 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the necessary strings for the activity chooser.
|
||||
*
|
||||
* @return {Promise<Object>} Promise resolved with the loaded strings.
|
||||
*/
|
||||
async function loadNecessaryStrings() {
|
||||
if (allStrings !== null) {
|
||||
return allStrings;
|
||||
}
|
||||
allStrings = {};
|
||||
|
||||
const stringToLoad = [
|
||||
{key: 'all', component: 'core'},
|
||||
{key: 'favourites', component: 'core'},
|
||||
{key: 'recommended', component: 'core'},
|
||||
{key: 'recommended_help', component: 'core_course'},
|
||||
...activityCategories.map(
|
||||
(key) => ({
|
||||
key: key,
|
||||
component: 'core',
|
||||
})
|
||||
),
|
||||
...activityCategories.map(
|
||||
(key) => ({
|
||||
key: key + '_help',
|
||||
component: 'core',
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
const loadedStrings = await getStrings(stringToLoad);
|
||||
stringToLoad.forEach(({key}, index) => {
|
||||
allStrings[key] = loadedStrings[index];
|
||||
});
|
||||
return allStrings;
|
||||
}
|
||||
@@ -22,6 +22,12 @@
|
||||
*/
|
||||
import ajax from 'core/ajax';
|
||||
|
||||
// A promises map for caching specific section modules data.
|
||||
const sectionsModulesPromises = new Map();
|
||||
|
||||
// A promises map for caching a specific section footer data.
|
||||
const sectionsFooterPromises = new Map();
|
||||
|
||||
/**
|
||||
* Fetch all the information on modules we'll need in the activity chooser.
|
||||
*
|
||||
@@ -58,7 +64,12 @@ export const favouriteModule = (modName, modID) => {
|
||||
contentitemid: modID,
|
||||
},
|
||||
};
|
||||
return ajax.call([request])[0];
|
||||
const promise = ajax.call([request])[0];
|
||||
// After the promise is resolved, we need to invalidate the cache for the section.
|
||||
return promise.then(() => {
|
||||
sectionsModulesPromises.clear();
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -78,7 +89,13 @@ export const unfavouriteModule = (modName, modID) => {
|
||||
contentitemid: modID,
|
||||
},
|
||||
};
|
||||
return ajax.call([request])[0];
|
||||
|
||||
const promise = ajax.call([request])[0];
|
||||
// After the promise is resolved, we need to invalidate the cache for the section.
|
||||
return promise.then(() => {
|
||||
sectionsModulesPromises.clear();
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -99,3 +116,87 @@ export const fetchFooterData = (courseid, sectionid) => {
|
||||
};
|
||||
return ajax.call([request])[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all the information on modules we'll need in the activity chooser.
|
||||
*
|
||||
* @method fetchSectionModules
|
||||
*
|
||||
* @private
|
||||
* @param {Number} courseId Course ID.
|
||||
* @param {Number} sectionNum Section number.
|
||||
* @param {Number} sectionReturnNum Section return.
|
||||
* @param {Number} beforeMod Before module number to be used in the module.
|
||||
* @return {Object} Tab data.
|
||||
*/
|
||||
export async function getModulesData(courseId, sectionNum, sectionReturnNum, beforeMod) {
|
||||
const cacheKey = `${courseId}-${sectionNum}`;
|
||||
if (!sectionsModulesPromises.has(cacheKey)) {
|
||||
sectionsModulesPromises.set(
|
||||
cacheKey,
|
||||
new Promise((resolve) => {
|
||||
resolve(activityModules(courseId, sectionNum));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const moduleData = await sectionsModulesPromises.get(cacheKey);
|
||||
|
||||
// Early return if there is no module data.
|
||||
if (!moduleData) {
|
||||
throw new Error('Cannot fetch module data');
|
||||
}
|
||||
|
||||
// Apply the section num to all the module instance links.
|
||||
return sectionMapper(
|
||||
moduleData,
|
||||
sectionNum,
|
||||
sectionReturnNum,
|
||||
beforeMod
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the web service data and an ID we want to make a deep copy
|
||||
* of the WS data then add on the section num to the addoption URL
|
||||
*
|
||||
* @method sectionMapper
|
||||
* @param {Object} webServiceData Our original data from the Web service call
|
||||
* @param {Number} sectionNum The number of the section we need to append to the links
|
||||
* @param {Number|null} sectionReturnNum The number of the section return we need to append to the links
|
||||
* @param {Number|null} beforeMod The ID of the cm we need to append to the links
|
||||
* @return {Array} [modules] with URL's built
|
||||
*/
|
||||
function sectionMapper(webServiceData, sectionNum, sectionReturnNum, beforeMod) {
|
||||
// We need to take a fresh deep copy of the original data as an object is a reference type.
|
||||
const newData = JSON.parse(JSON.stringify(webServiceData));
|
||||
newData.content_items.forEach((module) => {
|
||||
module.link += '§ion=' + sectionNum + '&beforemod=' + (beforeMod ?? 0);
|
||||
if (sectionReturnNum) {
|
||||
module.link += '&sr=' + sectionReturnNum;
|
||||
}
|
||||
});
|
||||
return newData.content_items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the footer data for a specific section.
|
||||
*
|
||||
* @param {Number} courseId Course ID.
|
||||
* @param {Number} sectionNum Section number.
|
||||
* @return {Promise<Object>} Promise resolved with the footer data.
|
||||
*/
|
||||
export async function getModalFooterData(courseId, sectionNum) {
|
||||
const cacheKey = `${courseId}-${sectionNum}`;
|
||||
if (sectionsFooterPromises.has(cacheKey)) {
|
||||
return sectionsFooterPromises.get(cacheKey);
|
||||
}
|
||||
|
||||
sectionsFooterPromises.set(
|
||||
cacheKey,
|
||||
new Promise((resolve) => {
|
||||
resolve(fetchFooterData(courseId, sectionNum));
|
||||
})
|
||||
);
|
||||
return sectionsFooterPromises.get(cacheKey);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ const getDataSelector = (name, value) => {
|
||||
|
||||
export default {
|
||||
regions: {
|
||||
chooser: getDataSelector('region', 'chooser-container'),
|
||||
categoryContent: getDataSelector('region', 'category-content'),
|
||||
getSectionChooserOptions: containerid => `${containerid} ${getDataSelector('region', 'chooser-options-container')}`,
|
||||
chooserOptions: getDataSelector('region', 'chooser-options-container'),
|
||||
chooserOption: {
|
||||
container: getDataSelector('region', 'chooser-option-container'),
|
||||
actions: getDataSelector('region', 'chooser-option-actions-container'),
|
||||
@@ -49,18 +50,12 @@ export default {
|
||||
},
|
||||
carousel: getDataSelector('region', 'carousel'),
|
||||
help: getDataSelector('region', 'help'),
|
||||
modules: getDataSelector('region', 'modules'),
|
||||
favouriteTabNav: getDataSelector('region', 'favourite-tab-nav'),
|
||||
defaultTabNav: getDataSelector('region', 'default-tab-nav'),
|
||||
activityTabNav: getDataSelector('region', 'activity-tab-nav'),
|
||||
tabContent: getDataSelector('region', 'tabcontent'),
|
||||
favouriteTabNav: getDataSelector('region', 'favourites-tab-nav'),
|
||||
allTabNav: getDataSelector('region', 'all-tab-nav'),
|
||||
favouriteTab: getDataSelector('region', 'favourites'),
|
||||
recommendedTab: getDataSelector('region', 'recommended'),
|
||||
defaultTab: getDataSelector('region', 'default'),
|
||||
activityTab: getDataSelector('region', 'activity'),
|
||||
resourceTab: getDataSelector('region', 'resources'),
|
||||
getModuleSelector: modname => `[role="menuitem"][data-modname="${modname}"]`,
|
||||
searchResults: getDataSelector('region', 'search-results-container'),
|
||||
searchResultItems: getDataSelector('region', 'search-result-items-container'),
|
||||
},
|
||||
actions: {
|
||||
optionActions: {
|
||||
@@ -73,16 +68,13 @@ export default {
|
||||
search: getDataSelector('action', 'search'),
|
||||
clearSearch: getDataSelector('action', 'clearsearch'),
|
||||
},
|
||||
render: {
|
||||
favourites: getDataSelector('render', 'favourites-area'),
|
||||
},
|
||||
elements: {
|
||||
section: '.section',
|
||||
sectionmodchooser: 'button.section-modchooser-link',
|
||||
sitemenu: '.block_site_main_menu',
|
||||
sitetopic: 'div.sitetopic',
|
||||
tab: 'a[data-bs-toggle="tab"]',
|
||||
activetab: 'a[data-bs-toggle="tab"][aria-selected="true"]',
|
||||
visibletabs: 'a[data-bs-toggle="tab"]:not(.d-none)'
|
||||
moduleItem: item => `[data-internal="${item}"]`,
|
||||
favouriteIconActive: '[data-icon="favourite-active"]',
|
||||
favouriteIconInactive: '[data-icon="favourite-inactive"]',
|
||||
},
|
||||
};
|
||||
|
||||
+1
-3
@@ -174,9 +174,7 @@ class core_course_renderer extends plugin_renderer_base {
|
||||
}
|
||||
|
||||
// Build an object of config settings that we can then hook into in the Activity Chooser.
|
||||
$chooserconfig = (object) [
|
||||
'tabmode' => get_config('core', 'activitychoosertabmode'),
|
||||
];
|
||||
$chooserconfig = (object) [];
|
||||
$this->page->requires->js_call_amd('core_course/activitychooser', 'init', [$courseid, $chooserconfig]);
|
||||
|
||||
return '';
|
||||
|
||||
@@ -46,132 +46,7 @@
|
||||
<div data-region="carousel" class="carousel slide d-flex flex-fill">
|
||||
<div class="carousel-inner d-flex flex-fill" aria-live="polite">
|
||||
<div class="carousel-item p-3 active" data-region="modules">
|
||||
<div class="modchoosercontainer d-flex flex-column flex-fill" aria-label="{{#str}} activitymodules, core {{/str}}">
|
||||
<div class="searchcontainer mb-3">
|
||||
{{>core_course/local/activitychooser/search}}
|
||||
</div>
|
||||
<div data-region="chooser-container" class="chooser-container">
|
||||
<div class="nav nav-tabs flex-shrink-0 z-index-1" id="activities-{{uniqid}}" role="tablist">
|
||||
<a class="nav-item nav-link {{#favouritesFirst}}active{{/favouritesFirst}} {{^favourites}}d-none{{/favourites}}"
|
||||
id="starred-tab-{{uniqid}}"
|
||||
data-bs-toggle="tab"
|
||||
data-region="favourite-tab-nav"
|
||||
href="#starred-{{uniqid}}"
|
||||
role="tab"
|
||||
aria-label="{{#str}} aria:favouritestab, core_course {{/str}}"
|
||||
aria-controls="starred-{{uniqid}}"
|
||||
aria-selected="{{#favouritesFirst}}true{{/favouritesFirst}}{{^favouritesFirst}}false{{/favouritesFirst}}"
|
||||
tabindex="{{#favouritesFirst}}0{{/favouritesFirst}}{{^favouritesFirst}}-1{{/favouritesFirst}}"
|
||||
>
|
||||
{{#str}} favourites, core {{/str}}
|
||||
</a>
|
||||
{{#recommendedBeginning}}
|
||||
<a class="nav-item nav-link {{#recommendedFirst}}active{{/recommendedFirst}} {{^recommended}}d-none{{/recommended}}"
|
||||
id="recommended-tab-{{uniqid}}"
|
||||
data-region="recommended-tab-nav"
|
||||
data-bs-toggle="tab"
|
||||
href="#recommended-{{uniqid}}"
|
||||
role="tab"
|
||||
aria-label="{{#str}} aria:recommendedtab, core_course {{/str}}"
|
||||
aria-controls="recommended-{{uniqid}}"
|
||||
aria-selected="{{#recommendedFirst}}true{{/recommendedFirst}}{{^recommendedFirst}}false{{/recommendedFirst}}"
|
||||
tabindex="{{#recommendedFirst}}0{{/recommendedFirst}}{{^recommendedFirst}}-1{{/recommendedFirst}}"
|
||||
>
|
||||
{{#str}} recommended, core {{/str}}
|
||||
</a>
|
||||
{{/recommendedBeginning}}
|
||||
<a class="nav-item nav-link {{#fallback}}active{{/fallback}} {{^showAll}}d-none{{/showAll}}"
|
||||
id="all-tab-{{uniqid}}"
|
||||
data-bs-toggle="tab"
|
||||
data-region="default-tab-nav"
|
||||
href="#all-{{uniqid}}"
|
||||
role="tab"
|
||||
aria-label="{{#str}} aria:defaulttab, core_course {{/str}}"
|
||||
aria-controls="all-{{uniqid}}"
|
||||
aria-selected="{{#fallback}}true{{/fallback}}{{^fallback}}false{{/fallback}}"
|
||||
tabindex="{{#fallback}}0{{/fallback}}{{^fallback}}-1{{/fallback}}"
|
||||
>
|
||||
{{#str}} all, core {{/str}}
|
||||
</a>
|
||||
<a class="nav-item nav-link {{#activitiesFirst}}active{{/activitiesFirst}} {{^showActivities}}d-none{{/showActivities}}"
|
||||
id="activity-tab-{{uniqid}}"
|
||||
data-bs-toggle="tab"
|
||||
data-region="activity-tab-nav"
|
||||
href="#activity-{{uniqid}}"
|
||||
role="tab"
|
||||
aria-label="{{#str}} activities, core {{/str}}"
|
||||
aria-controls="activity-{{uniqid}}"
|
||||
aria-selected="{{#activitiesFirst}}true{{/activitiesFirst}}{{^activitiesFirst}}false{{/activitiesFirst}}"
|
||||
tabindex="{{#activitiesFirst}}0{{/activitiesFirst}}{{^activitiesFirst}}-1{{/activitiesFirst}}"
|
||||
>
|
||||
{{#str}} activities, core {{/str}}
|
||||
</a>
|
||||
<a class="nav-item nav-link {{^showResources}}d-none{{/showResources}}"
|
||||
id="resources-tab-{{uniqid}}"
|
||||
data-bs-toggle="tab"
|
||||
data-region="resources-tab-nav"
|
||||
href="#resources-{{uniqid}}"
|
||||
role="tab"
|
||||
aria-label="{{#str}} resources, core {{/str}}"
|
||||
aria-controls="resources-{{uniqid}}"
|
||||
aria-selected="false"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{#str}} resources, core {{/str}}
|
||||
</a>
|
||||
{{^recommendedBeginning}}
|
||||
<a class="nav-item nav-link {{#recommendedFirst}}active{{/recommendedFirst}} {{^recommended}}d-none{{/recommended}}"
|
||||
id="recommended-tab-{{uniqid}}"
|
||||
data-region="recommended-tab-nav"
|
||||
data-bs-toggle="tab"
|
||||
href="#recommended-{{uniqid}}"
|
||||
role="tab"
|
||||
aria-label="{{#str}} aria:recommendedtab, core_course {{/str}}"
|
||||
aria-controls="recommended-{{uniqid}}"
|
||||
aria-selected="false"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{#str}} recommended, core {{/str}}
|
||||
</a>
|
||||
{{/recommendedBeginning}}
|
||||
</div>
|
||||
<div class="tab-content flex-fill border-start border-end border-bottom bg-light" id="tabbed-activities-{{uniqid}}">
|
||||
<div class="tab-pane {{#favouritesFirst}}active{{/favouritesFirst}}" id="starred-{{uniqid}}" data-region="favourites" role="tabpanel" aria-labelledby="starred-tab-{{uniqid}}">
|
||||
<div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container" data-render="favourites-area">
|
||||
{{>core_course/local/activitychooser/favourites}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane {{#fallback}}active{{/fallback}} {{^showAll}}d-none{{/showAll}}" id="all-{{uniqid}}" data-region="default" role="tabpanel" aria-labelledby="all-tab-{{uniqid}}">
|
||||
<div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
|
||||
{{#default}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/default}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane {{#activitiesFirst}}active{{/activitiesFirst}}" id="activity-{{uniqid}}" data-region="activity" role="tabpanel" aria-labelledby="activity-tab-{{uniqid}}">
|
||||
<div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
|
||||
{{#activities}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/activities}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane" id="resources-{{uniqid}}" data-region="resources" role="tabpanel" aria-labelledby="resources-tab-{{uniqid}}">
|
||||
<div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
|
||||
{{#resources}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/resources}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane {{#recommendedFirst}}active{{/recommendedFirst}}" id="recommended-{{uniqid}}" data-region="recommended" role="tabpanel" aria-labelledby="recommended-tab-{{uniqid}}">
|
||||
<div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
|
||||
{{#recommended}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/recommended}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{>core_course/local/activitychooser/modchoosercontainer}}
|
||||
</div>
|
||||
<div class="carousel-item" data-region="help"></div>
|
||||
<!--The following div is used as a place for additional plugins to have widgets in the chooser.-->
|
||||
|
||||
@@ -21,16 +21,30 @@
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"favourites": {
|
||||
"label": "Option name",
|
||||
"description": "Option description",
|
||||
"urls": {
|
||||
"addoption": "http://addoptionurl.com"
|
||||
},
|
||||
"icon": "<img class='icon' src='http://urltooptionicon'>"
|
||||
}
|
||||
"tabLabel": "Favourites",
|
||||
"tabHelp": "This is the help text for the favourites tab.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Option name",
|
||||
"description": "Option description",
|
||||
"urls": {
|
||||
"addoption": "http://addoptionurl.com"
|
||||
},
|
||||
"icon": "<img class='icon' alt='' src='http://urltooptionicon'>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}}
|
||||
{{#favourites}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/favourites}}
|
||||
<h6>{{tabLabel}}</h6>
|
||||
{{#tabHelp}}
|
||||
<p class="activitychooser_help">{{tabHelp}}</p>
|
||||
{{/tabHelp}}
|
||||
<div
|
||||
class="optionscontainer d-flex flex-wrap mw-100 position-relative"
|
||||
role="menubar"
|
||||
data-region="chooser-options-container"
|
||||
>
|
||||
{{#items}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/items}}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"branded": 0
|
||||
}
|
||||
}}
|
||||
<div role="menuitem" tabindex="-1" aria-label="{{title}}" class="option border-0 card m-1 bg-white" data-region="chooser-option-container" data-internal="{{name}}" data-modname="{{componentname}}_{{link}}">
|
||||
<div role="menuitem" tabindex="-1" aria-label="{{title}}" class="option card m-1 bg-white" data-region="chooser-option-container" data-internal="{{name}}" data-modname="{{componentname}}_{{link}}">
|
||||
<div class="optioninfo card-body d-flex flex-column text-center p-1" data-region="chooser-option-info-container">
|
||||
<a class="d-flex flex-column justify-content-between flex-fill" href="{{link}}" title="{{#str}} addnew, moodle, {{title}} {{/str}}" tabindex="-1" data-action="add-chooser-option">
|
||||
<div class="optionicon mt-2 mb-1 mx-auto icon-no-margin modicon_{{name}} activityiconcontainer smaller
|
||||
@@ -50,19 +50,28 @@
|
||||
data-internal="{{name}}"
|
||||
{{^favourite}}
|
||||
aria-pressed="false"
|
||||
aria-label="{{#str}} aria:modulefavourite, core_course, {{title}} {{/str}}"
|
||||
{{/favourite}}
|
||||
{{#favourite}}
|
||||
aria-pressed="true"
|
||||
aria-label="{{#str}} aria:moduleunfavourite, core_course, {{title}} {{/str}}"
|
||||
{{/favourite}}
|
||||
aria-label="{{#str}} aria:modulefavourite, core_course, {{title}} {{/str}}"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{#favourite}}
|
||||
<span
|
||||
class="{{^favourite}}d-none{{/favourite}}"
|
||||
data-icon="favourite-active"
|
||||
data-action-label="{{#str}} aria:moduleunfavourite, core_course, {{title}} {{/str}}"
|
||||
>
|
||||
{{#pix}} i/star, core {{/pix}}
|
||||
{{/favourite}}
|
||||
{{^favourite}}
|
||||
</span>
|
||||
<span
|
||||
class="{{#favourite}}d-none{{/favourite}}"
|
||||
data-icon="favourite-inactive"
|
||||
data-action-label="{{#str}} aria:modulefavourite, core_course, {{title}} {{/str}}"
|
||||
>
|
||||
{{#pix}} i/star-o, core {{/pix}}
|
||||
{{/favourite}}
|
||||
</span>
|
||||
</button>
|
||||
{{/legacyitem}}
|
||||
<button class="btn btn-icon m-0 optionaction" data-action="show-option-summary" tabindex="-1">
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
{{!
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
}}
|
||||
{{!
|
||||
@template core_course/local/activitychooser/modchoosercontainer
|
||||
|
||||
The activity chooser main container.
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"uniqid": "123abc",
|
||||
"tabs": [
|
||||
{
|
||||
"tabId": "collaboration",
|
||||
"tabLabel": "Collaboration",
|
||||
"active": true,
|
||||
"displayed": true,
|
||||
"separator": false,
|
||||
"items": [
|
||||
{
|
||||
"label": "Option name",
|
||||
"description": "Option description",
|
||||
"urls": {
|
||||
"addoption": "http://addoptionurl.com"
|
||||
},
|
||||
"icon": "<img class='icon' src='http://urltooptionicon' alt='icon'>",
|
||||
"purpose": "content",
|
||||
"branded": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tabId": "resources",
|
||||
"tabLabel": "Resources",
|
||||
"active": false,
|
||||
"displayed": true,
|
||||
"separator": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "Option name",
|
||||
"description": "Option description",
|
||||
"urls": {
|
||||
"addoption": "http://addoptionurl.com"
|
||||
},
|
||||
"icon": "<img class='icon' src='http://urltooptionicon' alt='icon'>",
|
||||
"purpose": "content",
|
||||
"branded": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tabId": "tools",
|
||||
"tabLabel": "Tools",
|
||||
"active": false,
|
||||
"displayed": false,
|
||||
"separator": false,
|
||||
"items": [
|
||||
{
|
||||
"label": "Option name",
|
||||
"description": "Option description",
|
||||
"urls": {
|
||||
"addoption": "http://addoptionurl.com"
|
||||
},
|
||||
"icon": "<img class='icon' src='http://urltooptionicon' alt='icon'>",
|
||||
"purpose": "content",
|
||||
"branded": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}}
|
||||
<div
|
||||
class="modchoosercontainer"
|
||||
data-region="chooser-container"
|
||||
>
|
||||
<div class="modchooserfilters">
|
||||
<div class="searchcontainer mb-3 px-1">
|
||||
{{>core_course/local/activitychooser/search}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="nav nav-pills modchoosernav d-flex flex-row flex-md-column flex-nowrap gap-2"
|
||||
id="categories-{{uniqid}}"
|
||||
role="tablist"
|
||||
>
|
||||
{{! Tab navigation items }}
|
||||
{{#tabs}}
|
||||
<a
|
||||
class="nav-item nav-link {{#active}}active{{/active}} {{^displayed}} d-none disabled {{/displayed}}"
|
||||
id="{{tabId}}-tab-{{uniqid}}"
|
||||
data-bs-toggle="tab"
|
||||
data-region="{{tabId}}-tab-nav"
|
||||
href="#{{tabId}}-{{uniqid}}"
|
||||
role="tab"
|
||||
aria-controls="{{tabId}}-{{uniqid}}"
|
||||
aria-selected="{{#active}}true{{/active}}{{^active}}false{{/active}}"
|
||||
>
|
||||
{{tabLabel}}
|
||||
</a>
|
||||
{{#separator}}<hr>{{/separator}}
|
||||
{{/tabs}}
|
||||
</div>
|
||||
<div
|
||||
class="modchoosercontent tab-content"
|
||||
id="tabbed-activities-{{uniqid}}"
|
||||
data-region="tabcontent"
|
||||
{{! We need to set tabindex to prevent Behat errors. }}
|
||||
tabindex="-1"
|
||||
>
|
||||
{{! Tab contents }}
|
||||
{{#tabs}}
|
||||
<div
|
||||
class="tab-pane {{#active}}active{{/active}}"
|
||||
id="{{tabId}}-{{uniqid}}"
|
||||
data-region="{{tabId}}"
|
||||
role="tabpanel"
|
||||
aria-labelledby="{{tabId}}-tab-{{uniqid}}"
|
||||
>
|
||||
{{>core_course/local/activitychooser/tabcontent}}
|
||||
</div>
|
||||
{{/tabs}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,6 +30,3 @@
|
||||
search, core
|
||||
{{/str}}{{/placeholder}}
|
||||
{{/ core/search_input_auto }}
|
||||
|
||||
<div class="searchresultscontainer" data-region="search-results-container" hidden="hidden" aria-live="polite">
|
||||
</div>
|
||||
|
||||
@@ -28,18 +28,18 @@
|
||||
"urls": {
|
||||
"addoption": "http://addoptionurl.com"
|
||||
},
|
||||
"icon": "<img class='icon' src='http://urltooptionicon'>"
|
||||
"icon": "<img class='icon' alt='' src='http://urltooptionicon'>"
|
||||
}
|
||||
}
|
||||
}}
|
||||
<p class="mt-4 px-3 pb-1">{{#str}} resultsfound, core, {{searchresultsnumber}} {{/str}}</p>
|
||||
<div class="bg-light searchresultitemscontainer-wrapper border">
|
||||
<div class="searchresultitemscontainer d-flex flex-wrap h-100 mh-100 mw-100 position-relative p-1"
|
||||
role="menubar"
|
||||
data-region="search-result-items-container"
|
||||
>
|
||||
<h6>{{#str}} search, core, {{searchresultsnumber}} {{/str}}</h6>
|
||||
<p class="activitychooser_help">{{#str}} resultsfound, core, {{searchresultsnumber}} {{/str}}</p>
|
||||
<div
|
||||
class="optionscontainer d-flex flex-wrap mw-100 position-relative"
|
||||
role="menubar"
|
||||
data-region="chooser-options-container"
|
||||
>
|
||||
{{#searchresults}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/searchresults}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{{!
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
}}
|
||||
{{!
|
||||
@template core_course/local/activitychooser/tabcontent
|
||||
|
||||
TODO describe template tabcontent
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"tabLabel": "Activities",
|
||||
"tabHelp": "Activities help",
|
||||
"items": [
|
||||
{
|
||||
"label": "Option name",
|
||||
"description": "Option description",
|
||||
"urls": {
|
||||
"addoption": "http://addoptionurl.com"
|
||||
},
|
||||
"icon": "<img class='icon' src='http://urltooptionicon' alt='icon'>",
|
||||
"purpose": "content",
|
||||
"branded": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}}
|
||||
{{#hasSearchResults}}
|
||||
<div
|
||||
class="searchresultscontainer"
|
||||
data-region="search-results-container"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
{{/hasSearchResults}}
|
||||
<div
|
||||
class="categorycontent"
|
||||
data-region="category-content"
|
||||
>
|
||||
<h6>{{tabLabel}}</h6>
|
||||
{{#tabHelp}}
|
||||
<p class="activitychooser_help">{{tabHelp}}</p>
|
||||
{{/tabHelp}}
|
||||
|
||||
<div
|
||||
class="optionscontainer d-flex flex-wrap mw-100 position-relative"
|
||||
role="menubar"
|
||||
data-region="chooser-options-container"
|
||||
>
|
||||
{{#items}}
|
||||
{{>core_course/local/activitychooser/item}}
|
||||
{{/items}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +93,7 @@ Feature: Display and choose from the available activities in course
|
||||
Scenario: Favourite a module in the activity chooser
|
||||
Given I open the activity chooser
|
||||
And I should not see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Add Assignment to starred" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
When I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
|
||||
Then I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
|
||||
@@ -102,8 +102,8 @@ Feature: Display and choose from the available activities in course
|
||||
|
||||
Scenario: Add a favourite module and check it exists when reopening the chooser
|
||||
Given I open the activity chooser
|
||||
And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Star Forum activity" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Add Assignment to starred" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Add Forum to starred" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Close" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Add content" "button" in the "New section" "section"
|
||||
@@ -113,10 +113,44 @@ Feature: Display and choose from the available activities in course
|
||||
|
||||
Scenario: Add a favourite and then remove it whilst checking the tabs work as expected
|
||||
Given I open the activity chooser
|
||||
And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I should not see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
When I click on "Add Assignment to starred" "button" in the "Add an activity or resource" "dialogue"
|
||||
# The favourite tab should appear once the user stars an activity.
|
||||
Then I should see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
|
||||
Then I should not see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Remove Assignment from starred" "button" in the "favourites" "core_course > Activity chooser tab"
|
||||
# The favourite tab should dissapear once the user changes tab.
|
||||
And I should see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "All" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should not see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
|
||||
Scenario: The teacher can manage favourites form the favourites tab and the list is refreshed when the user change tab
|
||||
Given I open the activity chooser
|
||||
And I click on "Add Assignment to starred" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Add Forum to starred" "button" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Starred" in the "Add an activity or resource" "dialogue"
|
||||
When I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
|
||||
And I should see "Forum" in the "favourites" "core_course > Activity chooser tab"
|
||||
And I click on "Remove Assignment from starred" "button" in the "favourites" "core_course > Activity chooser tab"
|
||||
Then I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
|
||||
And I click on "Resources" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Forum" in the "favourites" "core_course > Activity chooser tab"
|
||||
And I should not see "Assignment" in the "favourites" "core_course > Activity chooser tab"
|
||||
|
||||
Scenario: The teacher can undo a unfavourite action done in the activity chooser favourites tab
|
||||
Given I open the activity chooser
|
||||
And I click on "Add Assignment to starred" "button" in the "Add an activity or resource" "dialogue"
|
||||
When I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
|
||||
And I click on "Remove Assignment from starred" "button" in the "favourites" "core_course > Activity chooser tab"
|
||||
Then I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
|
||||
And I click on "Add Assignment to starred" "button" in the "favourites" "core_course > Activity chooser tab"
|
||||
# Change tab to refresh the favourites tab.
|
||||
And I click on "Resources" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
|
||||
|
||||
Scenario: The teacher can search for an activity by it's name
|
||||
Given I open the activity chooser
|
||||
@@ -134,7 +168,6 @@ Feature: Display and choose from the available activities in course
|
||||
Given I open the activity chooser
|
||||
When I set the field "search" to "Random search query"
|
||||
Then I should see "0 results found" in the "Add an activity or resource" "dialogue"
|
||||
And ".option" "css_element" should not exist in the ".searchresultitemscontainer" "css_element"
|
||||
|
||||
Scenario: Teacher can return to the default activity chooser state by manually removing the search query
|
||||
Given I open the activity chooser
|
||||
@@ -146,16 +179,16 @@ Feature: Display and choose from the available activities in course
|
||||
Then ".searchresultscontainer" "css_element" should not be visible
|
||||
And ".optionscontainer" "css_element" should exist
|
||||
|
||||
Scenario: Teacher can not see a "clear" button if a search query is not entered in the activity chooser search bar
|
||||
Scenario: Teacher can not see a clear button if a search query is not entered in the activity chooser search bar
|
||||
When I open the activity chooser
|
||||
Then "Clear search input" "button" should not be visible
|
||||
|
||||
Scenario: Teacher can see a "clear" button after entering a search query in the activity chooser search bar
|
||||
Scenario: Teacher can see a clear button after entering a search query in the activity chooser search bar
|
||||
Given I open the activity chooser
|
||||
When I set the field "search" to "Search query"
|
||||
Then "Clear search input" "button" should not be visible
|
||||
Then "Clear search input" "button" should be visible
|
||||
|
||||
Scenario: Teacher can not see a "clear" button if the search query is removed in the activity chooser search bar
|
||||
Scenario: Teacher can not see a clear button if the search query is removed in the activity chooser search bar
|
||||
Given I open the activity chooser
|
||||
And I set the field "search" to "Search query"
|
||||
And "Clear search input" "button" should exist
|
||||
@@ -164,7 +197,7 @@ Feature: Display and choose from the available activities in course
|
||||
And I wait "1" seconds
|
||||
Then "Clear search input" "button" should not be visible
|
||||
|
||||
Scenario: Teacher can instantly remove the search query from the activity search bar by clicking on the "clear" button
|
||||
Scenario: Teacher can instantly remove the search query from the activity search bar by clicking on the clear button
|
||||
Given I open the activity chooser
|
||||
And I set the field "search" to "Search query"
|
||||
And I should see "results found" in the "Add an activity or resource" "dialogue"
|
||||
@@ -173,86 +206,28 @@ Feature: Display and choose from the available activities in course
|
||||
And ".searchresultscontainer" "css_element" should not be visible
|
||||
And ".optionscontainer" "css_element" should exist
|
||||
|
||||
Scenario: Teacher gets the base case for the Activity Chooser tab mode
|
||||
Scenario: Click on an activity chosser category should cancel the current search
|
||||
Given I open the activity chooser
|
||||
And I should see "Activities" in the "Add an activity or resource" "dialogue"
|
||||
When I click on "Activities" "link" in the "Add an activity or resource" "dialogue"
|
||||
Then I should not see "Book" in the "activity" "core_course > Activity chooser tab"
|
||||
And I click on "Resources" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should not see "Assignment" in the "resources" "core_course > Activity chooser tab"
|
||||
And I set the field "search" to "Search query"
|
||||
And I should see "results found" in the "Add an activity or resource" "dialogue"
|
||||
And "Clear search input" "button" should be visible
|
||||
When I click on "Resources" "link" in the "Add an activity or resource" "dialogue"
|
||||
And "Clear search input" "button" should not be visible
|
||||
And the field "search" matches value ""
|
||||
And I should see "Book" in the "resources" "core_course > Activity chooser tab"
|
||||
|
||||
Scenario: Teacher gets the simple case for the Activity Chooser tab mode
|
||||
Given I log out
|
||||
And I log in as "admin"
|
||||
And I am on site homepage
|
||||
When I navigate to "Courses > Activity chooser > Activity chooser settings" in site administration
|
||||
And I select "Starred, All, Recommended" from the "Activity chooser tabs" singleselect
|
||||
And I press "Save changes"
|
||||
And I log out
|
||||
And I log in as "teacher"
|
||||
And I am on "Course" course homepage with editing mode on
|
||||
And I open the activity chooser
|
||||
Then I should not see "Activities" in the "Add an activity or resource" "dialogue"
|
||||
And I should not see "Resources" in the "Add an activity or resource" "dialogue"
|
||||
|
||||
Scenario: Teacher gets the final case for the Activity Chooser tab mode
|
||||
Given I log out
|
||||
And I log in as "admin"
|
||||
And I am on site homepage
|
||||
When I navigate to "Courses > Activity chooser > Activity chooser settings" in site administration
|
||||
And I select "Starred, Activities, Resources, Recommended" from the "Activity chooser tabs" singleselect
|
||||
And I press "Save changes"
|
||||
And I log out
|
||||
And I log in as "teacher"
|
||||
And I am on "Course" course homepage with editing mode on
|
||||
And I open the activity chooser
|
||||
Then I should not see "All" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Activities" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Resources" in the "Add an activity or resource" "dialogue"
|
||||
|
||||
Scenario: Recommended tab is displayed in the right position depending on the activitychoosertabmode setting
|
||||
Given I log out
|
||||
And I log in as "admin"
|
||||
And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
|
||||
And I click on ".activity-recommend-checkbox" "css_element" in the "Book" "table_row"
|
||||
And the following config values are set as admin:
|
||||
# 3 = Starred, Recommended, All, Activities, Resources
|
||||
| activitychoosertabmode | 3 |
|
||||
And I log out
|
||||
And I log in as "teacher"
|
||||
And I am on "Course" course homepage with editing mode on
|
||||
Scenario: Teacher gets the base case for the Activity Chooser tab mode
|
||||
When I open the activity chooser
|
||||
Then "Recommended" "link" should appear before "All" "link" in the "Add an activity or resource" "dialogue"
|
||||
But the following config values are set as admin:
|
||||
# 0 = Starred, All, Activities, Resources, Recommended
|
||||
| activitychoosertabmode | 0 |
|
||||
And I reload the page
|
||||
And I open the activity chooser
|
||||
And "Recommended" "link" should appear after "Resources" "link" in the "Add an activity or resource" "dialogue"
|
||||
But the following config values are set as admin:
|
||||
# 1 = Starred, All, Recommended
|
||||
| activitychoosertabmode | 1 |
|
||||
And I reload the page
|
||||
And I open the activity chooser
|
||||
And "Recommended" "link" should appear after "All" "link" in the "Add an activity or resource" "dialogue"
|
||||
But the following config values are set as admin:
|
||||
# 2 = Starred, Activities, Resources, Recommended
|
||||
| activitychoosertabmode | 2 |
|
||||
And I reload the page
|
||||
And I open the activity chooser
|
||||
And "Recommended" "link" should appear after "Resources" "link" in the "Add an activity or resource" "dialogue"
|
||||
But the following config values are set as admin:
|
||||
# 4 = Starred, Recommended, All
|
||||
| activitychoosertabmode | 4 |
|
||||
And I reload the page
|
||||
And I open the activity chooser
|
||||
And "Recommended" "link" should appear before "All" "link" in the "Add an activity or resource" "dialogue"
|
||||
But the following config values are set as admin:
|
||||
# 5 = Starred, Recommended, Activities, Resources
|
||||
| activitychoosertabmode | 5 |
|
||||
And I reload the page
|
||||
And I open the activity chooser
|
||||
And "Recommended" "link" should appear before "Activities" "link" in the "Add an activity or resource" "dialogue"
|
||||
Then I should see "Assignment" in the "all" "core_course > Activity chooser tab"
|
||||
And I should see "Book" in the "all" "core_course > Activity chooser tab"
|
||||
# Activities tab.
|
||||
And I click on "Activities" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Assignment" in the "activities" "core_course > Activity chooser tab"
|
||||
And I should not see "Book" in the "all" "core_course > Activity chooser tab"
|
||||
# Resources tab.
|
||||
And I click on "Resources" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I should see "Book" in the "resources" "core_course > Activity chooser tab"
|
||||
And I should not see "Assignment" in the "resources" "core_course > Activity chooser tab"
|
||||
|
||||
Scenario: Teacher can navigate through activity chooser in Topics format course
|
||||
When I open the activity chooser
|
||||
|
||||
+3
-9
@@ -28,14 +28,6 @@ $string['activitychoosersettings'] = 'Activity chooser settings';
|
||||
$string['activitychooseractivefooter'] = 'Activity chooser footer';
|
||||
$string['activitychooseractivefooter_desc'] = 'The activity chooser can support plugins that add items to the footer.';
|
||||
$string['activitychooserhidefooter'] = 'No footer';
|
||||
$string['activitychoosertabmode'] = 'Activity chooser tabs';
|
||||
$string['activitychoosertabmode_desc'] = "The activity chooser lists activities and resources for a teacher to add to their course. To add items to the Recommended tab, go to <a href='../course/recommendations.php'>Recommended activities</a>.";
|
||||
$string['activitychoosertabmodeone'] = 'Starred, All, Activities, Resources, Recommended';
|
||||
$string['activitychoosertabmodetwo'] = 'Starred, All, Recommended';
|
||||
$string['activitychoosertabmodethree'] = 'Starred, Activities, Resources, Recommended';
|
||||
$string['activitychoosertabmodefour'] = 'Starred, Recommended, All, Activities, Resources';
|
||||
$string['activitychoosertabmodefive'] = 'Starred, Recommended, All';
|
||||
$string['activitychoosertabmodesix'] = 'Starred, Recommended, Activities, Resources';
|
||||
$string['activitydate:closed'] = 'Closed:';
|
||||
$string['activitydate:closes'] = 'Closes:';
|
||||
$string['activitydate:opened'] = 'Opened:';
|
||||
@@ -46,8 +38,9 @@ $string['aria:coursename'] = 'Course name';
|
||||
$string['aria:defaulttab'] = 'Default activities';
|
||||
$string['aria:favourite'] = 'Course is starred';
|
||||
$string['aria:favouritestab'] = 'Starred activities';
|
||||
$string['aria:modulefavourite'] = 'Add {$a} to starred';
|
||||
$string['aria:moduleunfavourite'] = 'Remove {$a} from starred';
|
||||
$string['aria:recommendedtab'] = 'Recommended activities';
|
||||
$string['aria:modulefavourite'] = 'Star {$a} activity';
|
||||
$string['browsecourseadminindex'] = 'Browse the course administration with this index.';
|
||||
$string['browsesettingindex'] = 'Browse settings with this index.';
|
||||
$string['communicationroomlink'] = 'Chat to course participants';
|
||||
@@ -134,6 +127,7 @@ $string['privacy:metadata:completionsummary'] = 'The course contains completion
|
||||
$string['privacy:metadata:favouritessummary'] = 'The course contains information relating to the course being starred by the user.';
|
||||
$string['recommend'] = 'Recommend';
|
||||
$string['recommendcheckbox'] = 'Recommend activity: {$a}';
|
||||
$string['recommended_help'] = 'Activities recommended by your organisation.';
|
||||
$string['relativedatessubmissionduedateafter'] = '{$a->datediffstr} after course start';
|
||||
$string['relativedatessubmissionduedatebefore'] = '{$a->datediffstr} before course start';
|
||||
$string['searchactivitiesbyname'] = 'Search for activities by name';
|
||||
|
||||
@@ -1842,5 +1842,13 @@ function xmldb_main_upgrade($oldversion) {
|
||||
upgrade_main_savepoint(true, 2025053000.02);
|
||||
}
|
||||
|
||||
if ($oldversion < 2025062900.01) {
|
||||
// The activity chooser tab mode setting is no longer used.
|
||||
unset_config('activitychoosertabmode');
|
||||
|
||||
// Main savepoint reached.
|
||||
upgrade_main_savepoint(true, 2025062900.01);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1471,16 +1471,17 @@ audio.mediaplugin_html5audio {
|
||||
}
|
||||
}
|
||||
|
||||
.modchoosercontainer.noscroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.modchoosercontainer .optionscontainer,
|
||||
.modchoosercontainer .searchresultitemscontainer {
|
||||
overflow-x: hidden;
|
||||
.option {
|
||||
// 2 items per line.
|
||||
flex-basis: calc(50% - 0.5rem);
|
||||
|
||||
&:hover {
|
||||
border: 1px solid $secondary-border-subtle-dark;
|
||||
}
|
||||
|
||||
.optionactions {
|
||||
.optionaction {
|
||||
cursor: pointer;
|
||||
@@ -1501,6 +1502,52 @@ audio.mediaplugin_html5audio {
|
||||
}
|
||||
}
|
||||
|
||||
.modchoosercontainer {
|
||||
.modchooserfilters {
|
||||
grid-area: filters;
|
||||
}
|
||||
|
||||
.modchoosernav {
|
||||
grid-area: nav;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.modchoosercontent {
|
||||
grid-area: content;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.searchresultscontainer {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
||||
grid-template-areas:
|
||||
"nav filters"
|
||||
"nav content";
|
||||
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 1rem;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
grid-template-areas:
|
||||
"filters"
|
||||
"nav"
|
||||
"content";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
|
||||
.modchoosernav {
|
||||
overflow-x: auto;
|
||||
height: 3rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modchooser .modal-body .optionsummary {
|
||||
background-color: $white;
|
||||
overflow-x: hidden;
|
||||
@@ -1560,7 +1607,7 @@ audio.mediaplugin_html5audio {
|
||||
.modchoosercontainer .optionscontainer .option,
|
||||
.modchoosercontainer .searchresultitemscontainer .option {
|
||||
// Six items per line.
|
||||
flex-basis: calc(16.66% - 0.5rem);
|
||||
flex-basis: calc(25% - 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27083,10 +27083,6 @@ audio.mediaplugin_html5audio {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.modchoosercontainer.noscroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.modchoosercontainer .optionscontainer,
|
||||
.modchoosercontainer .searchresultitemscontainer {
|
||||
overflow-x: hidden;
|
||||
@@ -27095,6 +27091,10 @@ audio.mediaplugin_html5audio {
|
||||
.modchoosercontainer .searchresultitemscontainer .option {
|
||||
flex-basis: calc(50% - 0.5rem);
|
||||
}
|
||||
.modchoosercontainer .optionscontainer .option:hover,
|
||||
.modchoosercontainer .searchresultitemscontainer .option:hover {
|
||||
border: 1px solid #7c7f83;
|
||||
}
|
||||
.modchoosercontainer .optionscontainer .option .optionactions .optionaction,
|
||||
.modchoosercontainer .searchresultitemscontainer .option .optionactions .optionaction {
|
||||
cursor: pointer;
|
||||
@@ -27113,6 +27113,41 @@ audio.mediaplugin_html5audio {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.modchoosercontainer {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-areas: "nav filters" "nav content";
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.modchoosercontainer .modchooserfilters {
|
||||
grid-area: filters;
|
||||
}
|
||||
.modchoosercontainer .modchoosernav {
|
||||
grid-area: nav;
|
||||
min-width: 150px;
|
||||
}
|
||||
.modchoosercontainer .modchoosercontent {
|
||||
grid-area: content;
|
||||
overflow: auto;
|
||||
}
|
||||
.modchoosercontainer .searchresultscontainer {
|
||||
overflow: auto;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.modchoosercontainer {
|
||||
grid-template-areas: "filters" "nav" "content";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modchoosercontainer .modchoosernav {
|
||||
overflow-x: auto;
|
||||
height: 3rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.modchooser .modal-body .optionsummary {
|
||||
background-color: #fff;
|
||||
overflow-x: hidden;
|
||||
@@ -27164,7 +27199,7 @@ audio.mediaplugin_html5audio {
|
||||
@media (min-width: 992px) {
|
||||
.modchoosercontainer .optionscontainer .option,
|
||||
.modchoosercontainer .searchresultitemscontainer .option {
|
||||
flex-basis: calc(16.66% - 0.5rem);
|
||||
flex-basis: calc(25% - 0.5rem);
|
||||
}
|
||||
}
|
||||
/* Form element: listing */
|
||||
|
||||
@@ -27083,10 +27083,6 @@ audio.mediaplugin_html5audio {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.modchoosercontainer.noscroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.modchoosercontainer .optionscontainer,
|
||||
.modchoosercontainer .searchresultitemscontainer {
|
||||
overflow-x: hidden;
|
||||
@@ -27095,6 +27091,10 @@ audio.mediaplugin_html5audio {
|
||||
.modchoosercontainer .searchresultitemscontainer .option {
|
||||
flex-basis: calc(50% - 0.5rem);
|
||||
}
|
||||
.modchoosercontainer .optionscontainer .option:hover,
|
||||
.modchoosercontainer .searchresultitemscontainer .option:hover {
|
||||
border: 1px solid #7c7f83;
|
||||
}
|
||||
.modchoosercontainer .optionscontainer .option .optionactions .optionaction,
|
||||
.modchoosercontainer .searchresultitemscontainer .option .optionactions .optionaction {
|
||||
cursor: pointer;
|
||||
@@ -27113,6 +27113,41 @@ audio.mediaplugin_html5audio {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.modchoosercontainer {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-areas: "nav filters" "nav content";
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.modchoosercontainer .modchooserfilters {
|
||||
grid-area: filters;
|
||||
}
|
||||
.modchoosercontainer .modchoosernav {
|
||||
grid-area: nav;
|
||||
min-width: 150px;
|
||||
}
|
||||
.modchoosercontainer .modchoosercontent {
|
||||
grid-area: content;
|
||||
overflow: auto;
|
||||
}
|
||||
.modchoosercontainer .searchresultscontainer {
|
||||
overflow: auto;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.modchoosercontainer {
|
||||
grid-template-areas: "filters" "nav" "content";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modchoosercontainer .modchoosernav {
|
||||
overflow-x: auto;
|
||||
height: 3rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.modchooser .modal-body .optionsummary {
|
||||
background-color: #fff;
|
||||
overflow-x: hidden;
|
||||
@@ -27164,7 +27199,7 @@ audio.mediaplugin_html5audio {
|
||||
@media (min-width: 992px) {
|
||||
.modchoosercontainer .optionscontainer .option,
|
||||
.modchoosercontainer .searchresultitemscontainer .option {
|
||||
flex-basis: calc(16.66% - 0.5rem);
|
||||
flex-basis: calc(25% - 0.5rem);
|
||||
}
|
||||
}
|
||||
/* Form element: listing */
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$version = 2025062900.00; // YYYYMMDD = weekly release date of this DEV branch.
|
||||
$version = 2025062900.01; // YYYYMMDD = weekly release date of this DEV branch.
|
||||
// RR = release increments - 00 in DEV branches.
|
||||
// .XX = incremental changes.
|
||||
$release = '5.1dev (Build: 20250629)'; // Human-friendly version name
|
||||
|
||||
Reference in New Issue
Block a user