first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
Course formats
==============
To create a new course format, make another folder in here.
If you want a basic format, you only need to write the 'standard files' listed
below.
If you want to store information in the database for your format, or control
access to features of your format, you need some of the optional files too.
If you want to override some standard course output component (located in
coure/classes/output/{course|section|cm}_format/*) you need to create an
extend class inside your course/format/yourformat/classes/output.
All names below assume that your format is called 'yourformat'.
Standard files
--------------
* yourformat/format.php
Code that actually displays the course view page. See existing formats for
examples.
* yourformat/config.php
Configuration file, mainly controlling default blocks for the format.
See existing formats for examples.
* yourformat/lang/en/format_yourformat.php
Language file containing basic language strings for your format. Here
is a minimal language file:
<?php
$string['formatyourformat']='Your format'; // Name to display for format
$string['nameyourformat']='section'; // Name of a section within your format
?>
The first string is used in the dropdown menu of course settings. The second
is used when editing an activity within a course of your format.
Note that existing formats store their language strings in the main
moodle.php, which you can also do, but this separate file is recommended
for contributed formats.
You can also store other strings in this file if you wish. They can be
accessed as follows, for example to get the section name:
get_string('nameyourformat','format_yourformat');
Of course you can have other folders as well as just English if you want
to provide multiple languages.
Optional files (database access)
--------------------------------
If these files exist, Moodle will use them to set up database tables when you
visit the admin page.
* yourformat/db/install.xml
Database table definitions. Use your format name at the start of the table
names to increase the chance that they are unique.
* yourformat/db/upgrade.php
Database upgrade instructions. Similar to other upgrade.php files, so look
at those for modules etc. if you want to see.
The function must look like:
function xmldb_format_yourformat_upgrade($oldversion) {
...
* yourformat/version.php
Required if you use database tables.
<?php
$plugin->version = 2006120100; // Plugin version (update when tables change)
$plugin->requires = 2006092801; // Required Moodle version
?>
Optional files (backup)
-----------------------
If these files exist, backup and restore run automatically when backing up
the course. You can't back up the course format data independently.
* yourformat/backuplib.php
Similar to backup code for other plugins. Must have a function:
function yourformat_backup_format_data($bf,$preferences) {
...
* yourformat/restorelib.php
Similar to restore code for other plugins. Must have a function:
function yourformat_restore_format_data($restore,$data) {
...
($data is the xmlized data underneath FORMATDATA in the backup XML file.
Do print_object($data); while testing to see how it looks.)
Optional file (capabilities)
----------------------------
If this file exists, Moodle refreshes your format's capabilities
(checks that they are all included in the database) whenever you increase
the version in yourformat/version.php.
* yourformat/db/access.php
Contains capability entries similar to other access.php files.
The array definition must look like:
$format_yourformat_capabilities = array(
...
Format names must look like:
format/yourformat:specialpower
Capability definitions in your language file must look like:
$string['yourformat:specialpower']='Revolutionise the world';
Optional file (styles)
----------------------
* yourformat/styles.php
If this file exists it will be included in the CSS Moodle generates.
Optional files (outputs)
----------------------
By default, the format renderer will use those output classes:
* core_courseformat\output\local\content: for the general course structure
* core_courseformat\output\local\content\*: to render specific course structure parts
* core_courseformat\output\local\content\section: for the complete section output
* core_courseformat\output\local\content\section\*: to render specific section parts
* core_courseformat\output\local\content\cm: for output an activity inside a section
* core_courseformat\output\local\content\cm\*: for speficis parts of the cm output
Your format can override any of this output classes just by creating class
inside your format_yourformat\output\courseformat\* namespace. We recommend to extend the
original class to ensure all element will work as expected.
For example: if you want to change the section header, you should create
format_yourformat\output\section\header, which will extend the original
core_courseformat\output\courseformat\content\section\header class.
By default, only a few format renderer methods are needed to render a course:
- render_content to render a full course content
- course_section_updated used by the course editor to refresh a specific section
- course_section_updated_cm_item used by the course editor to refresh a specific cm item
Formats can override those two methods to use different templates to render a course.
+10
View File
@@ -0,0 +1,10 @@
define("core_courseformat/courseeditor",["exports","core_courseformat/local/courseeditor/mutations","core_courseformat/local/courseeditor/courseeditor","core_course/events"],(function(_exports,_mutations,_courseeditor,_events){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Generic reactive module used in the course editor.
*
* @module core_courseformat/courseeditor
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setViewFormat=_exports.getCurrentCourseEditor=_exports.getCourseEditor=void 0,_mutations=_interopRequireDefault(_mutations),_courseeditor=_interopRequireDefault(_courseeditor),_events=_interopRequireDefault(_events);const courseEditorMap=new Map,courseStateKeyMap=new Map;function dispatchStateChangedEvent(detail,target){void 0===target&&(target=document),target.dispatchEvent(new CustomEvent(_events.default.stateChanged,{bubbles:!0,detail:detail}))}_exports.setViewFormat=(courseId,setup)=>{courseId=parseInt(courseId),setup.editing||courseStateKeyMap.set(courseId,setup.statekey);getCourseEditor(courseId).setViewFormat(setup)};const getCourseEditor=courseId=>(courseId=parseInt(courseId),courseEditorMap.has(courseId)||(courseEditorMap.set(courseId,new _courseeditor.default({name:"CourseEditor".concat(courseId),eventName:_events.default.stateChanged,eventDispatch:dispatchStateChangedEvent,mutations:new _mutations.default})),courseEditorMap.get(courseId).loadCourse(courseId,courseStateKeyMap.get(courseId))),courseEditorMap.get(courseId));_exports.getCourseEditor=getCourseEditor;_exports.getCurrentCourseEditor=()=>getCourseEditor(M.cfg.courseId)}));
//# sourceMappingURL=courseeditor.min.js.map
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
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_courseformat/local/content/activity_header",["exports","core/reactive","core_courseformat/courseeditor","core_course/events"],(function(_exports,_reactive,_courseeditor,CourseEvents){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,CourseEvents=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}
/**
* The activity header component.
*
* @module core_courseformat/local/content/activity_header
* @class core_courseformat/local/content/activity_header
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/(CourseEvents);const SELECTORS_ACTIVITY_HEADER="[data-for='page-activity-header']";class Component extends _reactive.BaseComponent{create(){this.name="activity_header"}static init(target,selectors){const elementselector=target||SELECTORS_ACTIVITY_HEADER;return new Component({element:document.querySelector(elementselector),reactive:(0,_courseeditor.getCurrentCourseEditor)(),selectors:selectors})}stateReady(){this.addEventListener(this.element,CourseEvents.manualCompletionToggled,this._completionHandler)}_completionHandler(_ref){let{detail:detail}=_ref;void 0!==detail&&this.reactive.dispatch("cmCompletion",[detail.cmid],detail.completed)}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=activity_header.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"activity_header.min.js","sources":["../../../src/local/content/activity_header.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * The activity header component.\n *\n * @module core_courseformat/local/content/activity_header\n * @class core_courseformat/local/content/activity_header\n * @copyright 2021 Ferran Recio <ferran@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {getCurrentCourseEditor} from 'core_courseformat/courseeditor';\nimport * as CourseEvents from 'core_course/events';\n\n// Global page selectors.\nconst SELECTORS = {\n ACTIVITY_HEADER: `[data-for='page-activity-header']`,\n};\n\nexport default class Component extends BaseComponent {\n\n /**\n * Constructor hook.\n */\n create() {\n // Optional component name for debugging.\n this.name = 'activity_header';\n }\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {string} target optional altentative DOM main element CSS selector\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n const elementselector = (target) ? target : SELECTORS.ACTIVITY_HEADER;\n return new Component({\n element: document.querySelector(elementselector),\n reactive: getCurrentCourseEditor(),\n selectors\n });\n }\n\n /**\n * Initial state ready method.\n */\n stateReady() {\n // Capture completion events.\n this.addEventListener(\n this.element,\n CourseEvents.manualCompletionToggled,\n this._completionHandler\n );\n }\n\n /**\n * Activity manual completion listener.\n *\n * @param {Event} event the custom event\n * @param {object} event.detail the event details\n */\n _completionHandler({detail}) {\n if (detail === undefined) {\n return;\n }\n this.reactive.dispatch('cmCompletion', [detail.cmid], detail.completed);\n }\n}\n"],"names":["SELECTORS","Component","BaseComponent","create","name","target","selectors","elementselector","element","document","querySelector","reactive","stateReady","addEventListener","this","CourseEvents","manualCompletionToggled","_completionHandler","detail","undefined","dispatch","cmid","completed"],"mappings":";;;;;;;;0BA6BMA,oEAIeC,kBAAkBC,wBAKnCC,cAESC,KAAO,8BAUJC,OAAQC,iBACVC,gBAAmBF,QAAmBL,iCACrC,IAAIC,UAAU,CACjBO,QAASC,SAASC,cAAcH,iBAChCI,UAAU,0CACVL,UAAAA,YAORM,kBAESC,iBACDC,KAAKN,QACLO,aAAaC,wBACbF,KAAKG,oBAUbA,6BAAmBC,OAACA,kBACDC,IAAXD,aAGCP,SAASS,SAAS,eAAgB,CAACF,OAAOG,MAAOH,OAAOI"}
@@ -0,0 +1,11 @@
define("core_courseformat/local/content/bulkedittoggler",["exports","core/reactive","core_courseformat/courseeditor","core/pending"],(function(_exports,_reactive,_courseeditor,_pending){var obj;
/**
* The bulk editor toggler button control.
*
* @module core_courseformat/local/content/bulkedittoggler
* @class core_courseformat/local/content/bulkedittoggler
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};class Component extends _reactive.BaseComponent{create(){this.name="bulk_editor_toogler",this.selectors={BODY:"body",SELECTABLE:"[data-bulkcheckbox][data-is-selectable]"},this.classes={HIDDEN:"d-none",BULK:"bulkenabled"}}static init(target,selectors){return new this({element:document.querySelector(target),reactive:(0,_courseeditor.getCurrentCourseEditor)(),selectors:selectors})}stateReady(){this.addEventListener(this.element,"click",this._enableBulk)}getWatchers(){return[{watch:"bulk.enabled:updated",handler:this._refreshToggler}]}_refreshToggler(_ref){var _element$enabled,_document$querySelect;let{element:element}=_ref;this.element.classList.toggle(this.classes.HIDDEN,null!==(_element$enabled=element.enabled)&&void 0!==_element$enabled&&_element$enabled),null===(_document$querySelect=document.querySelector(this.selectors.BODY))||void 0===_document$querySelect||_document$querySelect.classList.toggle(this.classes.BULK,element.enabled)}_enableBulk(){const pendingToggle=new _pending.default("courseformat/content:bulktoggle_on");this.reactive.dispatch("bulkEnable",!0),setTimeout((()=>{var _document$querySelect2;null===(_document$querySelect2=document.querySelector(this.selectors.SELECTABLE))||void 0===_document$querySelect2||_document$querySelect2.focus(),pendingToggle.resolve()}),150)}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=bulkedittoggler.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"bulkedittoggler.min.js","sources":["../../../src/local/content/bulkedittoggler.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * The bulk editor toggler button control.\n *\n * @module core_courseformat/local/content/bulkedittoggler\n * @class core_courseformat/local/content/bulkedittoggler\n * @copyright 2023 Ferran Recio <ferran@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {getCurrentCourseEditor} from 'core_courseformat/courseeditor';\nimport Pending from 'core/pending';\n\nexport default class Component extends BaseComponent {\n\n /**\n * Constructor hook.\n */\n create() {\n // Optional component name for debugging.\n this.name = 'bulk_editor_toogler';\n // Default query selectors.\n this.selectors = {\n BODY: `body`,\n SELECTABLE: `[data-bulkcheckbox][data-is-selectable]`,\n };\n // Component css classes.\n this.classes = {\n HIDDEN: `d-none`,\n BULK: `bulkenabled`,\n };\n }\n\n /**\n * Static method to create a component instance from the mustache template.\n *\n * @param {string} target optional altentative DOM main element CSS selector\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.querySelector(target),\n reactive: getCurrentCourseEditor(),\n selectors\n });\n }\n\n /**\n * Initial state ready method.\n */\n stateReady() {\n // Capture completion events.\n this.addEventListener(\n this.element,\n 'click',\n this._enableBulk\n );\n }\n\n /**\n * Component watchers.\n *\n * @returns {Array} of watchers\n */\n getWatchers() {\n return [\n {watch: `bulk.enabled:updated`, handler: this._refreshToggler},\n ];\n }\n\n /**\n * Update a content section using the state information.\n *\n * @param {object} param\n * @param {Object} param.element details the update details (state.bulk in this case).\n */\n _refreshToggler({element}) {\n this.element.classList.toggle(this.classes.HIDDEN, element.enabled ?? false);\n document.querySelector(this.selectors.BODY)?.classList.toggle(this.classes.BULK, element.enabled);\n }\n\n /**\n * Dispatch the enable bulk mutation.\n *\n * The enable bulk button is outside of the course content main div.\n * Because content/actions captures click events only in the course\n * content, this button needs to trigger the enable bulk mutation\n * by itself.\n */\n _enableBulk() {\n const pendingToggle = new Pending(`courseformat/content:bulktoggle_on`);\n this.reactive.dispatch('bulkEnable', true);\n // Wait for a while and focus on the first checkbox.\n setTimeout(() => {\n document.querySelector(this.selectors.SELECTABLE)?.focus();\n pendingToggle.resolve();\n }, 150);\n }\n}\n"],"names":["Component","BaseComponent","create","name","selectors","BODY","SELECTABLE","classes","HIDDEN","BULK","target","this","element","document","querySelector","reactive","stateReady","addEventListener","_enableBulk","getWatchers","watch","handler","_refreshToggler","classList","toggle","enabled","pendingToggle","Pending","dispatch","setTimeout","focus","resolve"],"mappings":";;;;;;;;qJA4BqBA,kBAAkBC,wBAKnCC,cAESC,KAAO,2BAEPC,UAAY,CACbC,YACAC,2DAGCC,QAAU,CACXC,gBACAC,gCAWIC,OAAQN,kBACT,IAAIO,KAAK,CACZC,QAASC,SAASC,cAAcJ,QAChCK,UAAU,0CACVX,UAAAA,YAORY,kBAESC,iBACDN,KAAKC,QACL,QACAD,KAAKO,aASbC,oBACW,CACH,CAACC,6BAA+BC,QAASV,KAAKW,kBAUtDA,qEAAgBV,QAACA,mBACRA,QAAQW,UAAUC,OAAOb,KAAKJ,QAAQC,gCAAQI,QAAQa,qFAC3DZ,SAASC,cAAcH,KAAKP,UAAUC,8DAAOkB,UAAUC,OAAOb,KAAKJ,QAAQE,KAAMG,QAAQa,SAW7FP,oBACUQ,cAAgB,IAAIC,4DACrBZ,SAASa,SAAS,cAAc,GAErCC,YAAW,+DACPhB,SAASC,cAAcH,KAAKP,UAAUE,sEAAawB,QACnDJ,cAAcK,YACf"}
@@ -0,0 +1,11 @@
define("core_courseformat/local/content/bulkedittools",["exports","core/reactive","core/sticky-footer","core_courseformat/courseeditor","core/str","core/pending","core/prefetch","core_courseformat/local/content/actions/bulkselection","core/notification"],(function(_exports,_reactive,_stickyFooter,_courseeditor,_str,_pending,_prefetch,_bulkselection,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* The bulk editor tools bar.
*
* @module core_courseformat/local/content/bulkedittools
* @class core_courseformat/local/content/bulkedittools
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_pending=_interopRequireDefault(_pending),_notification=_interopRequireDefault(_notification),(0,_prefetch.prefetchStrings)("core_courseformat",["bulkselection"]);class Component extends _reactive.BaseComponent{create(){this.name="bulk_editor_tools",this.selectors={ACTIONS:'[data-for="bulkaction"]',ACTIONTOOL:'[data-for="bulkactions"] li',CANCEL:'[data-for="bulkcancel"]',COUNT:"[data-for='bulkcount']",SELECTABLE:"[data-bulkcheckbox][data-is-selectable]",SELECTALL:'[data-for="selectall"]',BULKBTN:'[data-for="enableBulk"]'},this.classes={HIDE:"d-none",DISABLED:"disabled"}}static init(target,selectors){return new this({element:document.querySelector(target),reactive:(0,_courseeditor.getCurrentCourseEditor)(),selectors:selectors})}stateReady(){const cancelBtn=this.getElement(this.selectors.CANCEL);cancelBtn&&this.addEventListener(cancelBtn,"click",this._cancelBulk);const selectAll=this.getElement(this.selectors.SELECTALL);selectAll&&this.addEventListener(selectAll,"click",this._selectAllClick)}getWatchers(){return[{watch:"bulk.enabled:updated",handler:this._refreshEnabled},{watch:"bulk:updated",handler:this._refreshTools}]}_refreshEnabled(_ref){let{element:element}=_ref;this._updatePageTitle(element.enabled).catch(_notification.default.exception),element.enabled?(0,_stickyFooter.enableStickyFooter)():(0,_stickyFooter.disableStickyFooter)()}_refreshTools(param){this._refreshSelectCount(param),this._refreshSelectAll(param),this._refreshActions(param)}async _refreshSelectCount(_ref2){let{element:bulk}=_ref2;const stringName=bulk.selection.length>1?"bulkselection_plural":"bulkselection",selectedCount=await(0,_str.getString)(stringName,"core_courseformat",bulk.selection.length),selectedElement=this.getElement(this.selectors.COUNT);selectedElement&&(selectedElement.innerHTML=selectedCount)}_refreshSelectAll(_ref3){let{element:bulk}=_ref3;const selectall=this.getElement(this.selectors.SELECTALL);if(!selectall)return;selectall.disabled=""===bulk.selectedType;const pending=new _pending.default("courseformat/bulktools:refreshSelectAll");setTimeout((()=>{selectall.checked=(0,_bulkselection.checkAllBulkSelected)(this.reactive),pending.resolve()}),100)}_refreshActions(_ref4){let{element:bulk}=_ref4;const displayType="section"==bulk.selectedType?"section":"cm",enabled=""!==bulk.selectedType;this.getElements(this.selectors.ACTIONS).forEach((action=>{action.classList.toggle(this.classes.DISABLED,!enabled),action.tabIndex=enabled?0:-1;const actionTool=action.closest(this.selectors.ACTIONTOOL),isHidden=action.dataset.bulk!=displayType;null==actionTool||actionTool.classList.toggle(this.classes.HIDE,isHidden)}))}_cancelBulk(){const pending=new _pending.default("courseformat/content:bulktoggle_off");this.reactive.dispatch("bulkEnable",!1),setTimeout((()=>{var _document$querySelect;null===(_document$querySelect=document.querySelector(this.selectors.BULKBTN))||void 0===_document$querySelect||_document$querySelect.focus(),pending.resolve()}),150)}_selectAllClick(event){event.preventDefault(),event.altKey?(0,_bulkselection.switchBulkSelection)(this.reactive):(0,_bulkselection.checkAllBulkSelected)(this.reactive)?this._handleUnselectAll():(0,_bulkselection.selectAllBulk)(this.reactive,!0)}_handleUnselectAll(){const pending=new _pending.default("courseformat/content:bulktUnselectAll");(0,_bulkselection.selectAllBulk)(this.reactive,!1),setTimeout((()=>{var _document$querySelect2;null===(_document$querySelect2=document.querySelector(this.selectors.SELECTABLE))||void 0===_document$querySelect2||_document$querySelect2.focus(),pending.resolve()}),150)}async _updatePageTitle(enabled){const enableBulk=document.querySelector(this.selectors.BULKBTN);let params,bulkEditTitle,editingTitle;enableBulk.dataset.sectiontitle?(params={course:enableBulk.dataset.coursename,sectionname:enableBulk.dataset.sectionname,sectiontitle:enableBulk.dataset.sectiontitle},bulkEditTitle=await(0,_str.getString)("coursesectiontitlebulkediting","moodle",params),editingTitle=await(0,_str.getString)("coursesectiontitleediting","moodle",params)):(params={course:enableBulk.dataset.coursename},bulkEditTitle=await(0,_str.getString)("coursetitlebulkediting","moodle",params),editingTitle=await(0,_str.getString)("coursetitleediting","moodle",params));const pageTitle=document.title;document.title=enabled?pageTitle.replace(editingTitle,bulkEditTitle):pageTitle.replace(bulkEditTitle,editingTitle)}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=bulkedittools.min.js.map
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
define("core_courseformat/local/content/section",["exports","core_courseformat/local/content/section/header","core_courseformat/local/courseeditor/dndsection","core/templates","core/pending"],(function(_exports,_header,_dndsection,_templates,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Course section format component.
*
* @module core_courseformat/local/content/section
* @class core_courseformat/local/content/section
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_header=_interopRequireDefault(_header),_dndsection=_interopRequireDefault(_dndsection),_templates=_interopRequireDefault(_templates),_pending=_interopRequireDefault(_pending);class _default extends _dndsection.default{create(){this.name="content_section",this.selectors={SECTION_ITEM:"[data-for='section_title']",CM:'[data-for="cmitem"]',SECTIONINFO:'[data-for="sectioninfo"]',SECTIONBADGES:'[data-region="sectionbadges"]',SHOWSECTION:'[data-action="sectionShow"]',HIDESECTION:'[data-action="sectionHide"]',ACTIONTEXT:".menu-action-text",ICON:".icon"},this.classes={LOCKED:"editinprogress",HASDESCRIPTION:"description",HIDE:"d-none",HIDDEN:"hidden",CURRENT:"current"},this.id=this.element.dataset.id}stateReady(state){if(this.configState(state),this.reactive.isEditing&&this.reactive.supportComponents){const sectionItem=this.getElement(this.selectors.SECTION_ITEM);if(sectionItem){const headerComponent=new _header.default({...this,element:sectionItem,fullregion:this.element});this.configDragDrop(headerComponent)}}this._openSectionIfNecessary()}async _openSectionIfNecessary(){const pageCmInfo=this.reactive.getPageAnchorCmInfo();if(!pageCmInfo||pageCmInfo.sectionid!==this.id)return;await this.reactive.dispatch("sectionContentCollapsed",[this.id],!1);const pendingOpen=new _pending.default("courseformat/section:openSectionIfNecessary");this.element.scrollIntoView({block:"center"}),setTimeout((()=>{this.reactive.dispatch("setPageItem","cm",pageCmInfo.id),pendingOpen.resolve()}),250)}getWatchers(){return[{watch:"section[".concat(this.id,"]:updated"),handler:this._refreshSection}]}validateDropData(dropdata){return("section"!==(null==dropdata?void 0:dropdata.type)||null===this.reactive.sectionReturn)&&super.validateDropData(dropdata)}getLastCm(){const cms=this.getElements(this.selectors.CM);return cms&&0!==cms.length?cms[cms.length-1]:null}_refreshSection(_ref){var _element$dragging,_element$locked,_element$visible,_element$current;let{element:element}=_ref;this.element.classList.toggle(this.classes.DRAGGING,null!==(_element$dragging=element.dragging)&&void 0!==_element$dragging&&_element$dragging),this.element.classList.toggle(this.classes.LOCKED,null!==(_element$locked=element.locked)&&void 0!==_element$locked&&_element$locked),this.element.classList.toggle(this.classes.HIDDEN,null!==(_element$visible=!element.visible)&&void 0!==_element$visible&&_element$visible),this.element.classList.toggle(this.classes.CURRENT,null!==(_element$current=element.current)&&void 0!==_element$current&&_element$current),this.locked=element.locked;const sectioninfo=this.getElement(this.selectors.SECTIONINFO);sectioninfo&&sectioninfo.classList.toggle(this.classes.HASDESCRIPTION,element.hasrestrictions),this._updateBadges(element),this._updateActionsMenu(element)}_updateBadges(section){const current=this.getElement("".concat(this.selectors.SECTIONBADGES," [data-type='iscurrent']"));null==current||current.classList.toggle(this.classes.HIDE,!section.current);const hiddenFromStudents=this.getElement("".concat(this.selectors.SECTIONBADGES," [data-type='hiddenfromstudents']"));null==hiddenFromStudents||hiddenFromStudents.classList.toggle(this.classes.HIDE,section.visible)}async _updateActionsMenu(section){var _affectedAction$datas,_affectedAction$datas2;let selector,newAction;section.visible?(selector=this.selectors.SHOWSECTION,newAction="sectionHide"):(selector=this.selectors.HIDESECTION,newAction="sectionShow");const affectedAction=this.getElement(selector);if(!affectedAction)return;affectedAction.dataset.action=newAction;const actionText=affectedAction.querySelector(this.selectors.ACTIONTEXT);if(null!==(_affectedAction$datas=affectedAction.dataset)&&void 0!==_affectedAction$datas&&_affectedAction$datas.swapname&&actionText){const oldText=null==actionText?void 0:actionText.innerText;actionText.innerText=affectedAction.dataset.swapname,affectedAction.dataset.swapname=oldText}const icon=affectedAction.querySelector(this.selectors.ICON);if(null!==(_affectedAction$datas2=affectedAction.dataset)&&void 0!==_affectedAction$datas2&&_affectedAction$datas2.swapicon&&icon){const newIcon=affectedAction.dataset.swapicon;if(affectedAction.dataset.swapicon=affectedAction.dataset.icon,affectedAction.dataset.icon=newIcon,newIcon){const pixHtml=await _templates.default.renderPix(newIcon,"core");_templates.default.replaceNode(icon,pixHtml,"")}}}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=section.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
define("core_courseformat/local/content/section/cmitem",["exports","core_courseformat/local/courseeditor/dndcmitem"],(function(_exports,_dndcmitem){var obj;
/**
* Course course module item component.
*
* This component is used to control specific course modules interactions like drag and drop.
*
* @module core_courseformat/local/content/section/cmitem
* @class core_courseformat/local/content/section/cmitem
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_dndcmitem=(obj=_dndcmitem)&&obj.__esModule?obj:{default:obj};class _default extends _dndcmitem.default{create(){this.name="content_section_cmitem",this.selectors={BULKSELECT:"[data-for='cmBulkSelect']",BULKCHECKBOX:"[data-bulkcheckbox]",CARD:"[data-region='activity-card']",DRAGICON:".editing_move",INPLACEEDITABLE:"[data-inplaceeditablelink]"},this.classes={LOCKED:"editinprogress",HIDE:"d-none",SELECTED:"selected"},this.id=this.element.dataset.id}stateReady(state){var _this$getElement;this.configDragDrop(this.id),null===(_this$getElement=this.getElement(this.selectors.DRAGICON))||void 0===_this$getElement||_this$getElement.classList.add(this.classes.DRAGICON),this._refreshBulk({state:state})}getWatchers(){return[{watch:"cm[".concat(this.id,"]:deleted"),handler:this.unregister},{watch:"cm[".concat(this.id,"]:updated"),handler:this._refreshCm},{watch:"bulk:updated",handler:this._refreshBulk}]}setDragImage(){return this.getElement(this.selectors.CARD)}_refreshCm(_ref){var _element$dragging,_element$locked;let{element:element}=_ref;this.element.classList.toggle(this.classes.DRAGGING,null!==(_element$dragging=element.dragging)&&void 0!==_element$dragging&&_element$dragging),this.element.classList.toggle(this.classes.LOCKED,null!==(_element$locked=element.locked)&&void 0!==_element$locked&&_element$locked),this.locked=element.locked}_refreshBulk(_ref2){var _this$getElement2;let{state:state}=_ref2;const bulk=state.bulk;this.setDraggable(!bulk.enabled),bulk.enabled?(this.element.dataset.action="toggleSelectionCm",this.element.dataset.preventDefault=1):(this.element.removeAttribute("data-action"),this.element.removeAttribute("data-preventDefault")),null===(_this$getElement2=this.getElement(this.selectors.BULKSELECT))||void 0===_this$getElement2||_this$getElement2.classList.toggle(this.classes.HIDE,!bulk.enabled);const disabled=!this._isCmBulkEnabled(bulk),selected=this._isSelected(bulk);this._refreshActivityCard(bulk,selected),this._setCheckboxValue(selected,disabled)}_refreshActivityCard(bulk,selected){var _this$getElement3,_this$getElement4;null===(_this$getElement3=this.getElement(this.selectors.INPLACEEDITABLE))||void 0===_this$getElement3||_this$getElement3.classList.toggle(this.classes.HIDE,bulk.enabled),null===(_this$getElement4=this.getElement(this.selectors.CARD))||void 0===_this$getElement4||_this$getElement4.classList.toggle(this.classes.SELECTED,selected),this.element.classList.toggle(this.classes.SELECTED,selected)}_setCheckboxValue(checked,disabled){const checkbox=this.getElement(this.selectors.BULKCHECKBOX);checkbox&&(checkbox.checked=checked,checkbox.disabled=disabled,disabled?checkbox.removeAttribute("data-is-selectable"):checkbox.dataset.isSelectable=1)}_isCmBulkEnabled(bulk){return!!bulk.enabled&&(""===bulk.selectedType||"cm"===bulk.selectedType)}_isSelected(bulk){return"cm"===bulk.selectedType&&bulk.selection.includes(this.id)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=cmitem.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
define("core_courseformat/local/content/section/header",["exports","core_courseformat/local/courseeditor/dndsectionitem"],(function(_exports,_dndsectionitem){var obj;
/**
* Course section header component.
*
* This component is used to control specific course section interactions like drag and drop.
*
* @module core_courseformat/local/content/section/header
* @class core_courseformat/local/content/section/header
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_dndsectionitem=(obj=_dndsectionitem)&&obj.__esModule?obj:{default:obj};class _default extends _dndsectionitem.default{create(descriptor){this.name="content_section_header",this.selectors={ACTIONSMENU:".section_action_menu",BULKSELECT:"[data-for='sectionBulkSelect']",BULKCHECKBOX:"[data-bulkcheckbox]",CHEVRON:"[data-for='sectiontoggler']"},this.classes={HIDE:"d-none",SELECTED:"selected"},this.id=descriptor.id,this.section=descriptor.section,this.course=descriptor.course,this.fullregion=descriptor.fullregion}stateReady(state){this.configDragDrop(this.id,state,this.fullregion),this._refreshBulk({state:state})}getWatchers(){return[{watch:"bulk:updated",handler:this._refreshBulk},{watch:"section[".concat(this.id,"].title:updated"),handler:this._refreshSectionTitle}]}_refreshSectionTitle(param){var _this$getElement;const element=param.element;null===(_this$getElement=this.getElement(this.selectors.CHEVRON))||void 0===_this$getElement||_this$getElement.setAttribute("aria-label",element.title),this._refreshSectionBulkSelector(param)}async _refreshSectionBulkSelector(_ref){let{element:element}=_ref;const checkbox=this.getElement(this.selectors.BULKCHECKBOX);if(!checkbox)return;const newLabel=await this.reactive.getFormatString("selectsection",element.title);checkbox.title=newLabel;const label=this.getElement("label[for='".concat(checkbox.id,"']"));label&&(label.innerText=newLabel)}_refreshBulk(_ref2){var _this$getElement2;let{state:state}=_ref2;const bulk=state.bulk;if(!this._isSectionBulkEditable())return;this.setDraggable(!bulk.enabled),null===(_this$getElement2=this.getElement(this.selectors.BULKSELECT))||void 0===_this$getElement2||_this$getElement2.classList.toggle(this.classes.HIDE,!bulk.enabled);const disabled=!this._isSectionBulkEnabled(bulk),selected=this._isSelected(bulk);this.element.classList.toggle(this.classes.SELECTED,selected),this._setCheckboxValue(selected,disabled)}_setCheckboxValue(checked,disabled){const checkbox=this.getElement(this.selectors.BULKCHECKBOX);checkbox&&(checkbox.checked=checked,checkbox.disabled=disabled,disabled?checkbox.removeAttribute("data-is-selectable"):checkbox.dataset.isSelectable=1)}_isSectionBulkEnabled(bulk){return!!bulk.enabled&&(""===bulk.selectedType||"section"===bulk.selectedType)}_isSectionBulkEditable(){var _section$bulkeditable;const section=this.reactive.get("section",this.id);return null!==(_section$bulkeditable=null==section?void 0:section.bulkeditable)&&void 0!==_section$bulkeditable&&_section$bulkeditable}_isSelected(bulk){return"section"===bulk.selectedType&&bulk.selection.includes(this.id)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=header.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
define("core_courseformat/local/courseeditor/contenttree",["exports","jquery","core/tree","core/normalise"],(function(_exports,_jquery,_tree,_normalise){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Course index keyboard navigation and aria-tree compatibility.
*
* Node tree and bootstrap collapsibles don't use the same HTML structure. However,
* all keybindings and logic is compatible. This class translate the primitive opetations
* to a bootstrap collapsible structure.
*
* @module core_courseformat/local/courseeditor/contenttree
* @class core_courseformat/local/courseeditor/contenttree
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_tree=_interopRequireDefault(_tree);class _default extends _tree.default{constructor(mainElement,selectors,preventcache){var _selectors$ENTER;super(mainElement),this.selectors={SECTION:selectors.SECTION,TOGGLER:selectors.TOGGLER,COLLAPSE:selectors.COLLAPSE,ENTER:null!==(_selectors$ENTER=selectors.ENTER)&&void 0!==_selectors$ENTER?_selectors$ENTER:selectors.TOGGLER},preventcache&&(this._getVisibleItems=this.getVisibleItems,this.getVisibleItems=()=>(this.refreshVisibleItemsCache(),this._getVisibleItems())),this.treeRoot.on("hidden.bs.collapse shown.bs.collapse",(()=>{this.refreshVisibleItemsCache()})),this.registerEnterCallback(this.enterCallback.bind(this))}getActiveItem(){const activeItem=this.treeRoot.data("activeItem");if(activeItem)return(0,_normalise.getList)(activeItem)[0]}enterCallback(jQueryItem){const item=(0,_normalise.getList)(jQueryItem)[0];if(this.isGroupItem(jQueryItem)){const enter=item.querySelector(this.selectors.ENTER);"#"!==enter.getAttribute("href")&&(window.location.href=enter.getAttribute("href")),enter.click()}else{const link=item.querySelector("a");"#"!==link.getAttribute("href")?window.location.href=link.getAttribute("href"):link.click()}}handleItemClick(event,jQueryItem){event.target.closest(this.selectors.COLLAPSE)?super.handleItemClick(event,jQueryItem):(jQueryItem.focus(),this.isGroupItem(jQueryItem)&&this.expandGroup(jQueryItem))}isGroupCollapsed(jQueryItem){return"false"===(0,_normalise.getList)(jQueryItem)[0].querySelector("[aria-expanded]").getAttribute("aria-expanded")}toggleGroup(item){var _toggler$data;const toggler=item.find(this.selectors.COLLAPSE);let collapsibleId=null!==(_toggler$data=toggler.data("target"))&&void 0!==_toggler$data?_toggler$data:toggler.attr("href");if(!collapsibleId)return;collapsibleId=collapsibleId.replace("#","");(0,_jquery.default)("#".concat(collapsibleId)).length&&(0,_jquery.default)("#".concat(collapsibleId)).collapse("toggle")}expandGroup(item){this.isGroupCollapsed(item)&&this.toggleGroup(item)}collapseGroup(item){this.isGroupCollapsed(item)||this.toggleGroup(item)}expandAllGroups(){(0,_normalise.getList)(this.treeRoot)[0].querySelectorAll(this.selectors.SECTION).forEach((item=>{this.expandGroup((0,_jquery.default)(item))}))}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=contenttree.min.js.map
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,15 @@
define("core_courseformat/local/courseeditor/dndcmitem",["exports","core/reactive"],(function(_exports,_reactive){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;
/**
* Course index cm component.
*
* This component is used to control specific course modules interactions like drag and drop
* in both course index and course content.
*
* @module core_courseformat/local/courseeditor/dndcmitem
* @class core_courseformat/local/courseeditor/dndcmitem
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class _default extends _reactive.BaseComponent{configDragDrop(cmid){this.id=cmid,this.reactive.isEditing&&this.reactive.supportComponents&&(this.dragdrop=new _reactive.DragDrop(this),this.classes=this.dragdrop.getClasses())}destroy(){void 0!==this.dragdrop&&this.dragdrop.unregister()}setDraggable(value){var _this$dragdrop;null===(_this$dragdrop=this.dragdrop)||void 0===_this$dragdrop||_this$dragdrop.setDraggable(value)}dragStart(dropdata){this.reactive.dispatch("cmDrag",[dropdata.id],!0)}dragEnd(dropdata){this.reactive.dispatch("cmDrag",[dropdata.id],!1)}getDraggableData(){return this.reactive.getExporter().cmDraggableData(this.reactive.state,this.id)}validateDropData(dropdata){if("cm"!==(null==dropdata?void 0:dropdata.type))return!1;if(!0===(null==dropdata?void 0:dropdata.delegatesection)){const mycminfo=this.reactive.get("cm",this.id),mysection=this.reactive.get("section",mycminfo.sectionid);if(null!==(null==mysection?void 0:mysection.component))return!1}return!0}showDropZone(dropdata){dropdata.nextcmid!=this.id&&dropdata.id!=this.id&&this.element.classList.add(this.classes.DROPUP)}hideDropZone(){this.element.classList.remove(this.classes.DROPUP)}drop(dropdata,event){if(dropdata.id!=this.id&&dropdata.nextcmid!=this.id){const mutation=event.altKey?"cmDuplicate":"cmMove";this.reactive.dispatch(mutation,[dropdata.id],null,this.id)}}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=dndcmitem.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
define("core_courseformat/local/courseeditor/dndsection",["exports","core/reactive","core/str","core/prefetch","core/templates"],(function(_exports,_reactive,_str,_prefetch,_templates){var obj;
/**
* Course index section component.
*
* This component is used to control specific course section interactions like drag and drop
* in both course index and course content.
*
* @module core_courseformat/local/courseeditor/dndsection
* @class core_courseformat/local/courseeditor/dndsection
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=(obj=_templates)&&obj.__esModule?obj:{default:obj},(0,_prefetch.prefetchStrings)("core",["addfilehere"]);class _default extends _reactive.BaseComponent{configState(state){this.id=this.element.dataset.id,this.section=state.section.get(this.id),this.course=state.course}configDragDrop(sectionitem){this.reactive.isEditing&&this.reactive.supportComponents&&(this.sectionitem=sectionitem,this.dragdrop=new _reactive.DragDrop(this),this.classes=this.dragdrop.getClasses())}destroy(){void 0!==this.sectionitem&&this.sectionitem.unregister(),void 0!==this.dragdrop&&this.dragdrop.unregister()}getLastCm(){return null}dragStart(dropdata){this.reactive.dispatch("sectionDrag",[dropdata.id],!0)}dragEnd(dropdata){this.reactive.dispatch("sectionDrag",[dropdata.id],!1)}validateDropData(dropdata){return"files"===(null==dropdata?void 0:dropdata.type)||("cm"===(null==dropdata?void 0:dropdata.type)?null===(_this$section=this.section)||void 0===_this$section||!_this$section.component||!0!==(null==dropdata?void 0:dropdata.delegatesection):"section"===(null==dropdata?void 0:dropdata.type)&&((null==dropdata?void 0:dropdata.id)!=this.id&&(null==dropdata?void 0:dropdata.number)!=this.section.number+1));var _this$section}showDropZone(dropdata){var _this$getLastCm;("files"==dropdata.type&&this.addOverlay({content:(0,_str.getString)("addfilehere","core"),icon:_templates.default.renderPix("t/download","core")}).then((()=>{var _this$dragdrop;null!==(_this$dragdrop=this.dragdrop)&&void 0!==_this$dragdrop&&_this$dragdrop.isDropzoneVisible()||this.removeOverlay()})).catch((error=>{throw error})),"cm"==dropdata.type)&&(null===(_this$getLastCm=this.getLastCm())||void 0===_this$getLastCm||_this$getLastCm.classList.add(this.classes.DROPDOWN));"section"==dropdata.type&&(this.element.classList.remove(this.classes.DROPUP),this.element.classList.add(this.classes.DROPDOWN))}hideDropZone(){var _this$getLastCm2;null===(_this$getLastCm2=this.getLastCm())||void 0===_this$getLastCm2||_this$getLastCm2.classList.remove(this.classes.DROPDOWN),this.element.classList.remove(this.classes.DROPUP),this.element.classList.remove(this.classes.DROPDOWN),this.removeOverlay()}drop(dropdata,event){if("files"!=dropdata.type){if("cm"==dropdata.type){const mutation=event.altKey?"cmDuplicate":"cmMove";this.reactive.dispatch(mutation,[dropdata.id],this.id)}"section"==dropdata.type&&this.reactive.dispatch("sectionMoveAfter",[dropdata.id],this.id)}else this.reactive.uploadFiles(this.section.id,this.section.number,dropdata.files)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=dndsection.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
define("core_courseformat/local/courseeditor/dndsectionitem",["exports","core/reactive"],(function(_exports,_reactive){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;
/**
* Course index section title draggable component.
*
* This component is used to control specific course section interactions like drag and drop
* in both course index and course content.
*
* @module core_courseformat/local/courseeditor/dndsectionitem
* @class core_courseformat/local/courseeditor/dndsectionitem
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class _default extends _reactive.BaseComponent{configDragDrop(sectionid,state,fullregion){this.id=sectionid,void 0===this.section&&(this.section=state.section.get(this.id)),void 0===this.course&&(this.course=state.course),this.section.number>0&&(this.getDraggableData=this._getDraggableData),this.fullregion=fullregion,this.reactive.isEditing&&this.reactive.supportComponents&&(this.dragdrop=new _reactive.DragDrop(this),this.classes=this.dragdrop.getClasses())}destroy(){void 0!==this.dragdrop&&this.dragdrop.unregister()}setDraggable(value){var _this$dragdrop;this.getDraggableData&&(null===(_this$dragdrop=this.dragdrop)||void 0===_this$dragdrop||_this$dragdrop.setDraggable(value))}dragStart(dropdata){this.reactive.dispatch("sectionDrag",[dropdata.id],!0)}dragEnd(dropdata){this.reactive.dispatch("sectionDrag",[dropdata.id],!1)}_getDraggableData(){return this.reactive.getExporter().sectionDraggableData(this.reactive.state,this.id)}validateDropData(dropdata){if("cm"===(null==dropdata?void 0:dropdata.type)){var _this$section,_this$section2;if(null!==(_this$section=this.section)&&void 0!==_this$section&&_this$section.component&&!0===(null==dropdata?void 0:dropdata.delegatesection))return!1;const firstcmid=null===(_this$section2=this.section)||void 0===_this$section2?void 0:_this$section2.cmlist[0];return dropdata.id!==firstcmid}return!1}showDropZone(){this.element.classList.add(this.classes.DROPZONE)}hideDropZone(){this.element.classList.remove(this.classes.DROPZONE)}drop(dropdata,event){if("cm"==dropdata.type){var _this$section3;const mutation=event.altKey?"cmDuplicate":"cmMove";this.reactive.dispatch(mutation,[dropdata.id],this.id,null===(_this$section3=this.section)||void 0===_this$section3?void 0:_this$section3.cmlist[0])}}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=dndsectionitem.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
define("core_courseformat/local/courseeditor/exporter",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default=
/**
* Module to export parts of the state and transform them to be used in templates
* and as draggable data.
*
* @module core_courseformat/local/courseeditor/exporter
* @class core_courseformat/local/courseeditor/exporter
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class{constructor(reactive){this.reactive=reactive,this.COMPLETIONS=["incomplete","complete","complete","fail"]}course(state){var _state$course$highlig;const data={sections:[],editmode:this.reactive.isEditing,highlighted:null!==(_state$course$highlig=state.course.highlighted)&&void 0!==_state$course$highlig?_state$course$highlig:""};return this.listedSectionIds(state).forEach((sectionid=>{var _state$section$get;const sectioninfo=null!==(_state$section$get=state.section.get(sectionid))&&void 0!==_state$section$get?_state$section$get:{},section=this.section(state,sectioninfo);data.sections.push(section)})),data.hassections=0!=data.sections.length,data}listedSectionIds(state){var _state$course$section;return(null!==(_state$course$section=state.course.sectionlist)&&void 0!==_state$course$section?_state$course$section:[]).filter((sectionid=>{var _state$section$get2;return null===(null!==(_state$section$get2=state.section.get(sectionid))&&void 0!==_state$section$get2?_state$section$get2:{}).component}))}section(state,sectioninfo){var _state$course$highlig2,_sectioninfo$cmlist;const section={...sectioninfo,highlighted:null!==(_state$course$highlig2=state.course.highlighted)&&void 0!==_state$course$highlig2?_state$course$highlig2:"",cms:[]};return(null!==(_sectioninfo$cmlist=sectioninfo.cmlist)&&void 0!==_sectioninfo$cmlist?_sectioninfo$cmlist:[]).forEach((cmid=>{const cminfo=state.cm.get(cmid),cm=this.cm(state,cminfo);section.cms.push(cm)})),section.hascms=0!=section.cms.length,section}cm(state,cminfo){return{...cminfo,isactive:!1}}cmDraggableData(state,cmid){const cminfo=state.cm.get(cmid);if(!cminfo)return null;let nextcmid;const section=state.section.get(cminfo.sectionid),currentindex=null==section?void 0:section.cmlist.indexOf(cminfo.id);return void 0!==currentindex&&(nextcmid=null==section?void 0:section.cmlist[currentindex+1]),{type:"cm",id:cminfo.id,name:cminfo.name,sectionid:cminfo.sectionid,delegatesection:cminfo.delegatesection,nextcmid:nextcmid}}sectionDraggableData(state,sectionid){const sectioninfo=state.section.get(sectionid);return sectioninfo?{type:"section",id:sectioninfo.id,name:sectioninfo.name,number:sectioninfo.number}:null}fileDraggableData(state,dataTransfer){var _dataTransfer$files;const files=[];return(null===(_dataTransfer$files=dataTransfer.files)||void 0===_dataTransfer$files?void 0:_dataTransfer$files.length)>0&&dataTransfer.files.forEach((file=>{files.push(file)})),{type:"files",files:files}}cmCompletion(state,cminfo){const data={statename:"",state:"NaN"};if(void 0!==cminfo.completionstate){var _this$COMPLETIONS$cmi;data.state=cminfo.completionstate,data.hasstate=!0;let statename=null!==(_this$COMPLETIONS$cmi=this.COMPLETIONS[cminfo.completionstate])&&void 0!==_this$COMPLETIONS$cmi?_this$COMPLETIONS$cmi:"NaN";void 0!==cminfo.isoverallcomplete&&!0===cminfo.isoverallcomplete&&(statename="complete"),data["is".concat(statename)]=!0}return data}allItemsArray(state){var _state$course$section2;const items=[];return(null!==(_state$course$section2=state.course.sectionlist)&&void 0!==_state$course$section2?_state$course$section2:[]).forEach((sectionid=>{var _sectioninfo$cmlist2;const sectioninfo=state.section.get(sectionid);items.push({type:"section",id:sectioninfo.id,url:sectioninfo.sectionurl});(null!==(_sectioninfo$cmlist2=sectioninfo.cmlist)&&void 0!==_sectioninfo$cmlist2?_sectioninfo$cmlist2:[]).forEach((cmid=>{const cminfo=state.cm.get(cmid);items.push({type:"cm",id:cminfo.id,url:cminfo.url})}))})),items}canUseStealth(state,cmIds){return cmIds.some((cmId=>{var _cminfo$allowstealth;const cminfo=state.cm.get(cmId);return null!==(_cminfo$allowstealth=null==cminfo?void 0:cminfo.allowstealth)&&void 0!==_cminfo$allowstealth&&_cminfo$allowstealth}))}},_exports.default}));
//# sourceMappingURL=exporter.min.js.map
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
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
define("core_courseformat/local/courseindex/cm",["exports","core_courseformat/local/courseeditor/dndcmitem","core/templates","core/prefetch","core/config","core/pending"],(function(_exports,_dndcmitem,_templates,_prefetch,_config,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Course index cm component.
*
* This component is used to control specific course modules interactions like drag and drop.
*
* @module core_courseformat/local/courseindex/cm
* @class core_courseformat/local/courseindex/cm
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_dndcmitem=_interopRequireDefault(_dndcmitem),_templates=_interopRequireDefault(_templates),_prefetch=_interopRequireDefault(_prefetch),_config=_interopRequireDefault(_config),_pending=_interopRequireDefault(_pending);_prefetch.default.prefetchTemplate("core_courseformat/local/courseindex/cmcompletion");class Component extends _dndcmitem.default{create(){this.name="courseindex_cm",this.selectors={CM_NAME:"[data-for='cm_name']",CM_COMPLETION:"[data-for='cm_completion']"},this.classes={CMHIDDEN:"dimmed",LOCKED:"editinprogress",RESTRICTIONS:"restrictions",PAGEITEM:"pageitem",INDENTED:"indented"},this.id=this.element.dataset.id}static init(target,selectors){return new this({element:document.getElementById(target),selectors:selectors})}stateReady(state){this.configDragDrop(this.id);const cm=state.cm.get(this.id),course=state.course;this._refreshCompletion({state:state,element:cm});const anchor=new URL(window.location.href).hash.replace("#","");(window.location.href==cm.url||window.location.href.includes(course.baseurl)&&anchor==cm.anchor)&&this.element.scrollIntoView({block:"center"}),_config.default.contextid!=_config.default.courseContextId&&_config.default.contextInstanceId==this.id&&(this.reactive.dispatch("setPageItem","cm",this.id,!0),this.element.scrollIntoView({block:"center"})),cm.uservisible&&cm.url||this.addEventListener(this.getElement(this.selectors.CM_NAME),"click",this._activityAnchor)}getWatchers(){return[{watch:"cm[".concat(this.id,"]:deleted"),handler:this.remove},{watch:"cm[".concat(this.id,"]:updated"),handler:this._refreshCm},{watch:"cm[".concat(this.id,"].completionstate:updated"),handler:this._refreshCompletion},{watch:"course.pageItem:updated",handler:this._refreshPageItem}]}_refreshCm(_ref){var _element$dragging,_element$locked,_element$hascmrestric;let{element:element}=_ref;this.element.classList.toggle(this.classes.CMHIDDEN,!element.visible),this.getElement(this.selectors.CM_NAME).innerHTML=element.name,this.element.classList.toggle(this.classes.DRAGGING,null!==(_element$dragging=element.dragging)&&void 0!==_element$dragging&&_element$dragging),this.element.classList.toggle(this.classes.LOCKED,null!==(_element$locked=element.locked)&&void 0!==_element$locked&&_element$locked),this.element.classList.toggle(this.classes.RESTRICTIONS,null!==(_element$hascmrestric=element.hascmrestrictions)&&void 0!==_element$hascmrestric&&_element$hascmrestric),this.element.classList.toggle(this.classes.INDENTED,element.indent),this.locked=element.locked}_refreshPageItem(_ref2){let{element:element}=_ref2;if(!element.pageItem)return;const isPageId="cm"==element.pageItem.type&&element.pageItem.id==this.id;this.element.classList.toggle(this.classes.PAGEITEM,isPageId),isPageId&&!this.reactive.isEditing&&this.element.scrollIntoView({block:"nearest"})}async _refreshCompletion(_ref3){let{state:state,element:element}=_ref3;if(this.reactive.isEditing||!element.istrackeduser)return;const completionElement=this.getElement(this.selectors.CM_COMPLETION);if(completionElement.dataset.value==element.completionstate)return;const data=this.reactive.getExporter().cmCompletion(state,element),{html:html,js:js}=await _templates.default.renderForPromise("core_courseformat/local/courseindex/cmcompletion",data);_templates.default.replaceNode(completionElement,html,js)}_activityAnchor(event){const cm=this.reactive.get("cm",this.id);if(document.getElementById(cm.anchor)){this.reactive.dispatch("sectionContentCollapsed",[cm.sectionid],!1);const pendingAnchor=new _pending.default("courseformat/activity:openAnchor");return void setTimeout((()=>{this.reactive.dispatch("setPageItem","cm",cm.id),pendingAnchor.resolve()}),50)}const course=this.reactive.get("course"),section=this.reactive.get("section",cm.sectionid);if(!section)return;const url="".concat(course.baseurl,"&section=").concat(section.number,"#").concat(cm.anchor);event.preventDefault(),window.location=url}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=cm.min.js.map
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
+15
View File
@@ -0,0 +1,15 @@
define("core_courseformat/local/courseindex/drawer",["exports","core/reactive","core_courseformat/courseeditor"],(function(_exports,_reactive,_courseeditor){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;
/**
* Course index drawer wrap.
*
* This component is mostly used to ensure all subcomponents find a parent
* compoment with a reactive instance defined.
*
* @module core_courseformat/local/courseindex/drawer
* @class core_courseformat/local/courseindex/drawer
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class Component extends _reactive.BaseComponent{create(){this.name="courseindex-drawer"}static init(target,selectors){return new this({element:document.getElementById(target),reactive:(0,_courseeditor.getCurrentCourseEditor)(),selectors:selectors})}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=drawer.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"drawer.min.js","sources":["../../../src/local/courseindex/drawer.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Course index drawer wrap.\n *\n * This component is mostly used to ensure all subcomponents find a parent\n * compoment with a reactive instance defined.\n *\n * @module core_courseformat/local/courseindex/drawer\n * @class core_courseformat/local/courseindex/drawer\n * @copyright 2021 Ferran Recio <ferran@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {getCurrentCourseEditor} from 'core_courseformat/courseeditor';\n\nexport default class Component extends BaseComponent {\n\n /**\n * Constructor hook.\n */\n create() {\n // Optional component name for debugging.\n this.name = 'courseindex-drawer';\n }\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {element|string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.getElementById(target),\n reactive: getCurrentCourseEditor(),\n selectors,\n });\n }\n}\n"],"names":["Component","BaseComponent","create","name","target","selectors","this","element","document","getElementById","reactive"],"mappings":";;;;;;;;;;;;MA8BqBA,kBAAkBC,wBAKnCC,cAESC,KAAO,iCAUJC,OAAQC,kBACT,IAAIC,KAAK,CACZC,QAASC,SAASC,eAAeL,QACjCM,UAAU,0CACVL,UAAAA"}
@@ -0,0 +1,11 @@
define("core_courseformat/local/courseindex/placeholder",["exports","core/reactive","core/templates","core_courseformat/courseeditor","core/pending"],(function(_exports,_reactive,_templates,_courseeditor,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Course index placeholder replacer.
*
* @module core_courseformat/local/courseindex/placeholder
* @class core_courseformat/local/courseindex/placeholder
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_pending=_interopRequireDefault(_pending);class Component extends _reactive.BaseComponent{static init(target,selectors){return new this({element:document.getElementById(target),reactive:(0,_courseeditor.getCurrentCourseEditor)(),selectors:selectors})}create(){this.pendingContent=new _pending.default("core_courseformat/placeholder:loadcourseindex")}async stateReady(state){this.loadStaticContent()||await this.loadTemplateContent(state)}loadStaticContent(){const index=this.reactive.getStorageValue("courseIndex");return!(!index.html||!index.js)&&(_templates.default.replaceNode(this.element,index.html,index.js),this.pendingContent.resolve(),!0)}async loadTemplateContent(state){const data=this.reactive.getExporter().course(state);try{const{html:html,js:js}=await _templates.default.renderForPromise("core_courseformat/local/courseindex/courseindex",data);_templates.default.replaceNode(this.element,html,js),this.pendingContent.resolve(),this.reactive.setStorageValue("courseIndex",{html:html,js:js})}catch(error){throw this.pendingContent.resolve(error),error}}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=placeholder.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"placeholder.min.js","sources":["../../../src/local/courseindex/placeholder.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Course index placeholder replacer.\n *\n * @module core_courseformat/local/courseindex/placeholder\n * @class core_courseformat/local/courseindex/placeholder\n * @copyright 2021 Ferran Recio <ferran@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport Templates from 'core/templates';\nimport {getCurrentCourseEditor} from 'core_courseformat/courseeditor';\nimport Pending from 'core/pending';\n\nexport default class Component extends BaseComponent {\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {element|string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.getElementById(target),\n reactive: getCurrentCourseEditor(),\n selectors,\n });\n }\n\n /**\n * Component creation hook.\n */\n create() {\n // Add a pending operation waiting for the initial content.\n this.pendingContent = new Pending(`core_courseformat/placeholder:loadcourseindex`);\n }\n\n /**\n * Initial state ready method.\n *\n * This stateReady to be async because it loads the real courseindex.\n *\n * @param {object} state the initial state\n */\n async stateReady(state) {\n\n // Check if we have a static course index already loded from a previous page.\n if (!this.loadStaticContent()) {\n await this.loadTemplateContent(state);\n }\n }\n\n /**\n * Load the course index from the session storage if any.\n *\n * @return {boolean} true if the static version is loaded form the session\n */\n loadStaticContent() {\n // Load the previous static course index from the session cache.\n const index = this.reactive.getStorageValue(`courseIndex`);\n if (index.html && index.js) {\n Templates.replaceNode(this.element, index.html, index.js);\n this.pendingContent.resolve();\n return true;\n }\n return false;\n }\n\n /**\n * Load the course index template.\n *\n * @param {Object} state the initial state\n */\n async loadTemplateContent(state) {\n // Collect section information from the state.\n const exporter = this.reactive.getExporter();\n const data = exporter.course(state);\n try {\n // To render an HTML into our component we just use the regular Templates module.\n const {html, js} = await Templates.renderForPromise(\n 'core_courseformat/local/courseindex/courseindex',\n data,\n );\n Templates.replaceNode(this.element, html, js);\n this.pendingContent.resolve();\n\n // Save the rendered template into the session cache.\n this.reactive.setStorageValue(`courseIndex`, {html, js});\n } catch (error) {\n this.pendingContent.resolve(error);\n throw error;\n }\n }\n}\n"],"names":["Component","BaseComponent","target","selectors","this","element","document","getElementById","reactive","create","pendingContent","Pending","state","loadStaticContent","loadTemplateContent","index","getStorageValue","html","js","replaceNode","resolve","data","getExporter","course","Templates","renderForPromise","setStorageValue","error"],"mappings":";;;;;;;;mLA6BqBA,kBAAkBC,oCASvBC,OAAQC,kBACT,IAAIC,KAAK,CACZC,QAASC,SAASC,eAAeL,QACjCM,UAAU,0CACVL,UAAAA,YAORM,cAESC,eAAiB,IAAIC,mFAUbC,OAGRR,KAAKS,2BACAT,KAAKU,oBAAoBF,OASvCC,0BAEUE,MAAQX,KAAKI,SAASQ,wCACxBD,MAAME,OAAQF,MAAMG,yBACVC,YAAYf,KAAKC,QAASU,MAAME,KAAMF,MAAMG,SACjDR,eAAeU,WACb,6BAUWR,aAGhBS,KADWjB,KAAKI,SAASc,cACTC,OAAOX,iBAGnBK,KAACA,KAADC,GAAOA,UAAYM,mBAAUC,iBAC/B,kDACAJ,yBAEMF,YAAYf,KAAKC,QAASY,KAAMC,SACrCR,eAAeU,eAGfZ,SAASkB,8BAA+B,CAACT,KAAAA,KAAMC,GAAAA,KACtD,MAAOS,kBACAjB,eAAeU,QAAQO,OACtBA"}
@@ -0,0 +1,13 @@
define("core_courseformat/local/courseindex/section",["exports","core_courseformat/local/courseindex/sectiontitle","core_courseformat/local/courseeditor/dndsection"],(function(_exports,_sectiontitle,_dndsection){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Course index section component.
*
* This component is used to control specific course section interactions like drag and drop.
*
* @module core_courseformat/local/courseindex/section
* @class core_courseformat/local/courseindex/section
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_sectiontitle=_interopRequireDefault(_sectiontitle),_dndsection=_interopRequireDefault(_dndsection);class Component extends _dndsection.default{create(){this.name="courseindex_section",this.selectors={SECTION_ITEM:"[data-for='section_item']",SECTION_TITLE:"[data-for='section_title']",CM_LAST:'[data-for="cm"]:last-child'},this.classes={SECTIONHIDDEN:"dimmed",SECTIONCURRENT:"current",LOCKED:"editinprogress",RESTRICTIONS:"restrictions",PAGEITEM:"pageitem",OVERLAYBORDERS:"overlay-preview-borders"},this.id=this.element.dataset.id,this.isPageItem=!1}static init(target,selectors){return new this({element:document.getElementById(target),selectors:selectors})}stateReady(state){this.configState(state);const sectionItem=this.getElement(this.selectors.SECTION_ITEM);if(this.reactive.isEditing&&this.reactive.supportComponents){const titleitem=new _sectiontitle.default({...this,element:sectionItem,fullregion:this.element});this.configDragDrop(titleitem)}const section=state.section.get(this.id);window.location.href==section.sectionurl.replace(/&amp;/g,"&")&&(this.reactive.dispatch("setPageItem","section",this.id),sectionItem.scrollIntoView())}getWatchers(){return[{watch:"section[".concat(this.id,"]:deleted"),handler:this.remove},{watch:"section[".concat(this.id,"]:updated"),handler:this._refreshSection},{watch:"course.pageItem:updated",handler:this._refreshPageItem}]}getLastCm(){return this.getElement(this.selectors.CM_LAST)}_refreshSection(_ref){var _element$hasrestricti,_element$dragging,_element$locked;let{element:element}=_ref;const sectionItem=this.getElement(this.selectors.SECTION_ITEM);sectionItem.classList.toggle(this.classes.SECTIONHIDDEN,!element.visible),sectionItem.classList.toggle(this.classes.RESTRICTIONS,null!==(_element$hasrestricti=element.hasrestrictions)&&void 0!==_element$hasrestricti&&_element$hasrestricti),this.element.classList.toggle(this.classes.SECTIONCURRENT,element.current),this.element.classList.toggle(this.classes.DRAGGING,null!==(_element$dragging=element.dragging)&&void 0!==_element$dragging&&_element$dragging),this.element.classList.toggle(this.classes.LOCKED,null!==(_element$locked=element.locked)&&void 0!==_element$locked&&_element$locked),this.locked=element.locked,this.getElement(this.selectors.SECTION_TITLE).innerHTML=element.title}_refreshPageItem(_ref2){var _element$pageItem,_this$pageItem;let{element:element,state:state}=_ref2;if(!element.pageItem)return;const section=state.section.get(this.id),isRelevantPageItem=element.pageItem.sectionId===this.id||!this.isPageItem,isSectionOrCollapsed="section"===element.pageItem.type||section.indexcollapsed;if(!isRelevantPageItem||!isSectionOrCollapsed)return this.pageItem=!1,void this.getElement(this.selectors.SECTION_ITEM).classList.remove(this.classes.PAGEITEM);var _element$pageItem2;!section.indexcollapsed||null!==(_element$pageItem=element.pageItem)&&void 0!==_element$pageItem&&_element$pageItem.isStatic?this.pageItem="section"==element.pageItem.type&&element.pageItem.id==this.id:this.pageItem=(null===(_element$pageItem2=element.pageItem)||void 0===_element$pageItem2?void 0:_element$pageItem2.sectionId)==this.id;this.getElement(this.selectors.SECTION_ITEM).classList.toggle(this.classes.PAGEITEM,null!==(_this$pageItem=this.pageItem)&&void 0!==_this$pageItem&&_this$pageItem),this.pageItem&&!this.reactive.isEditing&&this.element.scrollIntoView({block:"nearest"})}async addOverlay(){this.element.classList.add(this.classes.OVERLAYBORDERS)}removeOverlay(){this.element.classList.remove(this.classes.OVERLAYBORDERS)}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=section.min.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
define("core_courseformat/local/courseindex/sectiontitle",["exports","core_courseformat/local/courseeditor/dndsectionitem"],(function(_exports,_dndsectionitem){var obj;
/**
* Course index section title component.
*
* This component is used to control specific course section interactions like drag and drop.
*
* @module core_courseformat/local/courseindex/sectiontitle
* @class core_courseformat/local/courseindex/sectiontitle
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_dndsectionitem=(obj=_dndsectionitem)&&obj.__esModule?obj:{default:obj};class Component extends _dndsectionitem.default{create(descriptor){this.name="courseindex_sectiontitle",this.id=descriptor.id,this.section=descriptor.section,this.course=descriptor.course,this.fullregion=descriptor.fullregion,this.section.number>0&&(this.getDraggableData=this._getDraggableData)}static init(target,selectors){return new this({element:document.getElementById(target),selectors:selectors})}stateReady(state){this.configDragDrop(this.id,state,this.fullregion)}}return _exports.default=Component,_exports.default}));
//# sourceMappingURL=sectiontitle.min.js.map
@@ -0,0 +1 @@
{"version":3,"file":"sectiontitle.min.js","sources":["../../../src/local/courseindex/sectiontitle.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Course index section title component.\n *\n * This component is used to control specific course section interactions like drag and drop.\n *\n * @module core_courseformat/local/courseindex/sectiontitle\n * @class core_courseformat/local/courseindex/sectiontitle\n * @copyright 2021 Ferran Recio <ferran@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport DndSectionItem from 'core_courseformat/local/courseeditor/dndsectionitem';\n\nexport default class Component extends DndSectionItem {\n\n /**\n * Constructor hook.\n *\n * @param {Object} descriptor\n */\n create(descriptor) {\n // Optional component name for debugging.\n this.name = 'courseindex_sectiontitle';\n\n this.id = descriptor.id;\n this.section = descriptor.section;\n this.course = descriptor.course;\n this.fullregion = descriptor.fullregion;\n\n // Prevent topic zero from being draggable.\n if (this.section.number > 0) {\n this.getDraggableData = this._getDraggableData;\n }\n }\n\n /**\n * Static method to create a component instance form the mustahce template.\n *\n * @param {element|string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.getElementById(target),\n selectors,\n });\n }\n\n /**\n * Initial state ready method.\n *\n * @param {Object} state the initial state\n */\n stateReady(state) {\n this.configDragDrop(this.id, state, this.fullregion);\n }\n}\n"],"names":["Component","DndSectionItem","create","descriptor","name","id","section","course","fullregion","this","number","getDraggableData","_getDraggableData","target","selectors","element","document","getElementById","stateReady","state","configDragDrop"],"mappings":";;;;;;;;;;mKA4BqBA,kBAAkBC,wBAOnCC,OAAOC,iBAEEC,KAAO,gCAEPC,GAAKF,WAAWE,QAChBC,QAAUH,WAAWG,aACrBC,OAASJ,WAAWI,YACpBC,WAAaL,WAAWK,WAGzBC,KAAKH,QAAQI,OAAS,SACjBC,iBAAmBF,KAAKG,+BAWzBC,OAAQC,kBACT,IAAIL,KAAK,CACZM,QAASC,SAASC,eAAeJ,QACjCC,UAAAA,YASRI,WAAWC,YACFC,eAAeX,KAAKJ,GAAIc,MAAOV,KAAKD"}
+109
View File
@@ -0,0 +1,109 @@
// 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/>.
/**
* Generic reactive module used in the course editor.
*
* @module core_courseformat/courseeditor
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import DefaultMutations from 'core_courseformat/local/courseeditor/mutations';
import CourseEditor from 'core_courseformat/local/courseeditor/courseeditor';
import events from 'core_course/events';
// A map with all the course editor instances.
const courseEditorMap = new Map();
// Map with all the state keys the backend send us to know if the frontend cache is valid or not.
const courseStateKeyMap = new Map();
/**
* Trigger a state changed event.
*
* This function will be moved to core_course/events module
* when the file is migrated to the new JS events structure proposed in MDL-70990.
*
* @method dispatchStateChangedEvent
* @param {object} detail the full state
* @param {object} target the custom event target (document if none provided)
*/
function dispatchStateChangedEvent(detail, target) {
if (target === undefined) {
target = document;
}
target.dispatchEvent(new CustomEvent(events.stateChanged, {
bubbles: true,
detail: detail,
}));
}
/**
* Setup the current view settings
*
* The backend cache state revision is a combination of the course->cacherev, the
* user course preferences and completion state. The backend updates that number
* everytime some change in the course affects the user course state.
*
* @param {number} courseId the course id
* @param {setup} setup format, page and course settings
* @param {boolean} setup.editing if the page is in edit mode
* @param {boolean} setup.supportscomponents if the format supports components for content
* @param {String} setup.statekey the backend cached state revision
* @param {Array} setup.overriddenStrings optional overridden strings
*/
export const setViewFormat = (courseId, setup) => {
courseId = parseInt(courseId);
// Caches are ignored in edit mode.
if (!setup.editing) {
courseStateKeyMap.set(courseId, setup.statekey);
}
const editor = getCourseEditor(courseId);
editor.setViewFormat(setup);
};
/**
* Get a specific course editor reactive instance.
*
* @param {number} courseId the course id
* @returns {CourseEditor}
*/
export const getCourseEditor = (courseId) => {
courseId = parseInt(courseId);
if (!courseEditorMap.has(courseId)) {
courseEditorMap.set(
courseId,
new CourseEditor({
name: `CourseEditor${courseId}`,
eventName: events.stateChanged,
eventDispatch: dispatchStateChangedEvent,
// Mutations can be overridden by the format plugin using setMutations
// but we need the default one at least.
mutations: new DefaultMutations(),
})
);
courseEditorMap.get(courseId).loadCourse(courseId, courseStateKeyMap.get(courseId));
}
return courseEditorMap.get(courseId);
};
/**
* Get the current course reactive instance.
*
* @returns {CourseEditor}
*/
export const getCurrentCourseEditor = () => getCourseEditor(M.cfg.courseId);
+767
View File
@@ -0,0 +1,767 @@
// 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/>.
/**
* Course index main component.
*
* @module core_courseformat/local/content
* @class core_courseformat/local/content
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {debounce} from 'core/utils';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
import Config from 'core/config';
import inplaceeditable from 'core/inplace_editable';
import Section from 'core_courseformat/local/content/section';
import CmItem from 'core_courseformat/local/content/section/cmitem';
import Fragment from 'core/fragment';
import Templates from 'core/templates';
import DispatchActions from 'core_courseformat/local/content/actions';
import * as CourseEvents from 'core_course/events';
// The jQuery module is only used for interacting with Boostrap 4. It can we removed when MDL-71979 is integrated.
import jQuery from 'jquery';
import Pending from 'core/pending';
export default class Component extends BaseComponent {
/**
* Constructor hook.
*
* @param {Object} descriptor the component descriptor
*/
create(descriptor) {
// Optional component name for debugging.
this.name = 'course_format';
// Default query selectors.
this.selectors = {
SECTION: `[data-for='section']`,
SECTION_ITEM: `[data-for='section_title']`,
SECTION_CMLIST: `[data-for='cmlist']`,
COURSE_SECTIONLIST: `[data-for='course_sectionlist']`,
CM: `[data-for='cmitem']`,
TOGGLER: `[data-action="togglecoursecontentsection"]`,
COLLAPSE: `[data-toggle="collapse"]`,
TOGGLEALL: `[data-toggle="toggleall"]`,
// Formats can override the activity tag but a default one is needed to create new elements.
ACTIVITYTAG: 'li',
SECTIONTAG: 'li',
};
this.selectorGenerators = {
cmNameFor: (id) => `[data-cm-name-for='${id}']`,
sectionNameFor: (id) => `[data-section-name-for='${id}']`,
};
// Default classes to toggle on refresh.
this.classes = {
COLLAPSED: `collapsed`,
// Course content classes.
ACTIVITY: `activity`,
STATEDREADY: `stateready`,
SECTION: `section`,
};
// Array to save dettached elements during element resorting.
this.dettachedCms = {};
this.dettachedSections = {};
// Index of sections and cms components.
this.sections = {};
this.cms = {};
// The page section return.
this.sectionReturn = descriptor.sectionReturn ?? null;
this.debouncedReloads = new Map();
}
/**
* Static method to create a component instance form the mustahce template.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @param {number} sectionReturn the content section return
* @return {Component}
*/
static init(target, selectors, sectionReturn) {
return new Component({
element: document.getElementById(target),
reactive: getCurrentCourseEditor(),
selectors,
sectionReturn,
});
}
/**
* Initial state ready method.
*
* @param {Object} state the state data
*/
stateReady(state) {
this._indexContents();
// Activate section togglers.
this.addEventListener(this.element, 'click', this._sectionTogglers);
// Collapse/Expand all sections button.
const toogleAll = this.getElement(this.selectors.TOGGLEALL);
if (toogleAll) {
// Ensure collapse menu button adds aria-controls attribute referring to each collapsible element.
const collapseElements = this.getElements(this.selectors.COLLAPSE);
const collapseElementIds = [...collapseElements].map(element => element.id);
toogleAll.setAttribute('aria-controls', collapseElementIds.join(' '));
this.addEventListener(toogleAll, 'click', this._allSectionToggler);
this.addEventListener(toogleAll, 'keydown', e => {
// Collapse/expand all sections when Space key is pressed on the toggle button.
if (e.key === ' ') {
this._allSectionToggler(e);
}
});
this._refreshAllSectionsToggler(state);
}
if (this.reactive.supportComponents) {
// Actions are only available in edit mode.
if (this.reactive.isEditing) {
new DispatchActions(this);
}
// Mark content as state ready.
this.element.classList.add(this.classes.STATEDREADY);
}
// Capture completion events.
this.addEventListener(
this.element,
CourseEvents.manualCompletionToggled,
this._completionHandler
);
// Capture page scroll to update page item.
this.addEventListener(
document,
"scroll",
this._scrollHandler
);
}
/**
* Setup sections toggler.
*
* Toggler click is delegated to the main course content element because new sections can
* appear at any moment and this way we prevent accidental double bindings.
*
* @param {Event} event the triggered event
*/
_sectionTogglers(event) {
const sectionlink = event.target.closest(this.selectors.TOGGLER);
const closestCollapse = event.target.closest(this.selectors.COLLAPSE);
// Assume that chevron is the only collapse toggler in a section heading;
// I think this is the most efficient way to verify at the moment.
const isChevron = closestCollapse?.closest(this.selectors.SECTION_ITEM);
if (sectionlink || isChevron) {
const section = event.target.closest(this.selectors.SECTION);
const toggler = section.querySelector(this.selectors.COLLAPSE);
const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;
const sectionId = section.getAttribute('data-id');
this.reactive.dispatch(
'sectionContentCollapsed',
[sectionId],
!isCollapsed,
);
}
}
/**
* Handle the collapse/expand all sections button.
*
* Toggler click is delegated to the main course content element because new sections can
* appear at any moment and this way we prevent accidental double bindings.
*
* @param {Event} event the triggered event
*/
_allSectionToggler(event) {
event.preventDefault();
const target = event.target.closest(this.selectors.TOGGLEALL);
const isAllCollapsed = target.classList.contains(this.classes.COLLAPSED);
const course = this.reactive.get('course');
this.reactive.dispatch(
'sectionContentCollapsed',
course.sectionlist ?? [],
!isAllCollapsed
);
}
/**
* Return the component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
// Section return is a global page variable but most formats define it just before start printing
// the course content. This is the reason why we define this page setting here.
this.reactive.sectionReturn = this.sectionReturn;
// Check if the course format is compatible with reactive components.
if (!this.reactive.supportComponents) {
return [];
}
return [
// State changes that require to reload some course modules.
{watch: `cm.visible:updated`, handler: this._reloadCm},
{watch: `cm.stealth:updated`, handler: this._reloadCm},
{watch: `cm.sectionid:updated`, handler: this._reloadCm},
{watch: `cm.indent:updated`, handler: this._reloadCm},
{watch: `cm.groupmode:updated`, handler: this._reloadCm},
{watch: `cm.name:updated`, handler: this._refreshCmName},
// Update section number and title.
{watch: `section.number:updated`, handler: this._refreshSectionNumber},
{watch: `section.title:updated`, handler: this._refreshSectionTitle},
// Collapse and expand sections.
{watch: `section.contentcollapsed:updated`, handler: this._refreshSectionCollapsed},
// Sections and cm sorting.
{watch: `transaction:start`, handler: this._startProcessing},
{watch: `course.sectionlist:updated`, handler: this._refreshCourseSectionlist},
{watch: `section.cmlist:updated`, handler: this._refreshSectionCmlist},
// Section visibility.
{watch: `section.visible:updated`, handler: this._reloadSection},
// Reindex sections and cms.
{watch: `state:updated`, handler: this._indexContents},
];
}
/**
* Update a course module name on the whole page.
*
* @param {object} param
* @param {Object} param.element details the update details.
*/
_refreshCmName({element}) {
// Update classes.
// Replace the text content of the cm name.
const allCmNamesFor = this.getElements(
this.selectorGenerators.cmNameFor(element.id)
);
allCmNamesFor.forEach((cmNameFor) => {
cmNameFor.textContent = element.name;
});
}
/**
* Update section collapsed state via bootstrap 4 if necessary.
*
* Formats that do not use bootstrap 4 must override this method in order to keep the section
* toggling working.
*
* @param {object} args
* @param {Object} args.state The state data
* @param {Object} args.element The element to update
*/
_refreshSectionCollapsed({state, element}) {
const target = this.getElement(this.selectors.SECTION, element.id);
if (!target) {
throw new Error(`Unknown section with ID ${element.id}`);
}
// Check if it is already done.
const toggler = target.querySelector(this.selectors.COLLAPSE);
const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;
if (element.contentcollapsed !== isCollapsed) {
let collapsibleId = toggler.dataset.target ?? toggler.getAttribute("href");
if (!collapsibleId) {
return;
}
collapsibleId = collapsibleId.replace('#', '');
const collapsible = document.getElementById(collapsibleId);
if (!collapsible) {
return;
}
// Course index is based on Bootstrap 4 collapsibles. To collapse them we need jQuery to
// interact with collapsibles methods. Hopefully, this will change in Bootstrap 5 because
// it does not require jQuery anymore (when MDL-71979 is integrated).
jQuery(collapsible).collapse(element.contentcollapsed ? 'hide' : 'show');
}
this._refreshAllSectionsToggler(state);
}
/**
* Refresh the collapse/expand all sections element.
*
* @param {Object} state The state data
*/
_refreshAllSectionsToggler(state) {
const target = this.getElement(this.selectors.TOGGLEALL);
if (!target) {
return;
}
// Check if we have all sections collapsed/expanded.
let allcollapsed = true;
let allexpanded = true;
state.section.forEach(
section => {
allcollapsed = allcollapsed && section.contentcollapsed;
allexpanded = allexpanded && !section.contentcollapsed;
}
);
if (allcollapsed) {
target.classList.add(this.classes.COLLAPSED);
target.setAttribute('aria-expanded', false);
}
if (allexpanded) {
target.classList.remove(this.classes.COLLAPSED);
target.setAttribute('aria-expanded', true);
}
}
/**
* Setup the component to start a transaction.
*
* Some of the course actions replaces the current DOM element with a new one before updating the
* course state. This means the component cannot preload any index properly until the transaction starts.
*
*/
_startProcessing() {
// During a section or cm sorting, some elements could be dettached from the DOM and we
// need to store somewhare in case they are needed later.
this.dettachedCms = {};
this.dettachedSections = {};
}
/**
* Activity manual completion listener.
*
* @param {Event} event the custom ecent
*/
_completionHandler({detail}) {
if (detail === undefined) {
return;
}
this.reactive.dispatch('cmCompletion', [detail.cmid], detail.completed);
}
/**
* Check the current page scroll and update the active element if necessary.
*/
_scrollHandler() {
const pageOffset = window.scrollY;
const items = this.reactive.getExporter().allItemsArray(this.reactive.state);
// Check what is the active element now.
let pageItem = null;
items.every(item => {
const index = (item.type === 'section') ? this.sections : this.cms;
if (index[item.id] === undefined) {
return true;
}
const element = index[item.id].element;
pageItem = item;
return pageOffset >= element.offsetTop;
});
if (pageItem) {
this.reactive.dispatch('setPageItem', pageItem.type, pageItem.id);
}
}
/**
* Update a course section when the section number changes.
*
* The courseActions module used for most course section tools still depends on css classes and
* section numbers (not id). To prevent inconsistencies when a section is moved, we need to refresh
* the
*
* Course formats can override the section title rendering so the frontend depends heavily on backend
* rendering. Luckily in edit mode we can trigger a title update using the inplace_editable module.
*
* @param {Object} param
* @param {Object} param.element details the update details.
*/
_refreshSectionNumber({element}) {
// Find the element.
const target = this.getElement(this.selectors.SECTION, element.id);
if (!target) {
// Job done. Nothing to refresh.
return;
}
// Update section numbers in all data, css and YUI attributes.
target.id = `section-${element.number}`;
// YUI uses section number as section id in data-sectionid, in principle if a format use components
// don't need this sectionid attribute anymore, but we keep the compatibility in case some plugin
// use it for legacy purposes.
target.dataset.sectionid = element.number;
// The data-number is the attribute used by components to store the section number.
target.dataset.number = element.number;
// Update title and title inplace editable, if any.
const inplace = inplaceeditable.getInplaceEditable(target.querySelector(this.selectors.SECTION_ITEM));
if (inplace) {
// The course content HTML can be modified at any moment, so the function need to do some checkings
// to make sure the inplace editable still represents the same itemid.
const currentvalue = inplace.getValue();
const currentitemid = inplace.getItemId();
// Unnamed sections must be recalculated.
if (inplace.getValue() === '') {
// The value to send can be an empty value if it is a default name.
if (currentitemid == element.id && (currentvalue != element.rawtitle || element.rawtitle == '')) {
inplace.setValue(element.rawtitle);
}
}
}
}
/**
* Update a course section name on the whole page.
*
* @param {object} param
* @param {Object} param.element details the update details.
*/
_refreshSectionTitle({element}) {
// Replace the text content of the section name in the whole page.
const allSectionNamesFor = document.querySelectorAll(
this.selectorGenerators.sectionNameFor(element.id)
);
allSectionNamesFor.forEach((sectionNameFor) => {
sectionNameFor.textContent = element.title;
});
}
/**
* Refresh a section cm list.
*
* @param {Object} param
* @param {Object} param.element details the update details.
*/
_refreshSectionCmlist({element}) {
const cmlist = element.cmlist ?? [];
const section = this.getElement(this.selectors.SECTION, element.id);
const listparent = section?.querySelector(this.selectors.SECTION_CMLIST);
// A method to create a fake element to be replaced when the item is ready.
const createCm = this._createCmItem.bind(this);
if (listparent) {
this._fixOrder(listparent, cmlist, this.selectors.CM, this.dettachedCms, createCm);
}
}
/**
* Refresh the section list.
*
* @param {Object} param
* @param {Object} param.state the full state object.
*/
_refreshCourseSectionlist({state}) {
// If we have a section return means we only show a single section so no need to fix order.
if (this.reactive.sectionReturn !== null) {
return;
}
const sectionlist = this.reactive.getExporter().listedSectionIds(state);
const listparent = this.getElement(this.selectors.COURSE_SECTIONLIST);
// For now section cannot be created at a frontend level.
const createSection = this._createSectionItem.bind(this);
if (listparent) {
this._fixOrder(listparent, sectionlist, this.selectors.SECTION, this.dettachedSections, createSection);
}
}
/**
* Regenerate content indexes.
*
* This method is used when a legacy action refresh some content element.
*/
_indexContents() {
// Find unindexed sections.
this._scanIndex(
this.selectors.SECTION,
this.sections,
(item) => {
return new Section(item);
}
);
// Find unindexed cms.
this._scanIndex(
this.selectors.CM,
this.cms,
(item) => {
return new CmItem(item);
}
);
}
/**
* Reindex a content (section or cm) of the course content.
*
* This method is used internally by _indexContents.
*
* @param {string} selector the DOM selector to scan
* @param {*} index the index attribute to update
* @param {*} creationhandler method to create a new indexed element
*/
_scanIndex(selector, index, creationhandler) {
const items = this.getElements(`${selector}:not([data-indexed])`);
items.forEach((item) => {
if (!item?.dataset?.id) {
return;
}
// Delete previous item component.
if (index[item.dataset.id] !== undefined) {
index[item.dataset.id].unregister();
}
// Create the new component.
index[item.dataset.id] = creationhandler({
...this,
element: item,
});
// Mark as indexed.
item.dataset.indexed = true;
});
}
/**
* Reload a course module contents.
*
* Most course module HTML is still strongly backend dependant.
* Some changes require to get a new version of the module.
*
* @param {object} param0 the watcher details
* @param {object} param0.element the state object
*/
_reloadCm({element}) {
if (!this.getElement(this.selectors.CM, element.id)) {
return;
}
const debouncedReload = this._getDebouncedReloadCm(element.id);
debouncedReload();
}
/**
* Generate or get a reload CM debounced function.
* @param {Number} cmId
* @returns {Function} the debounced reload function
*/
_getDebouncedReloadCm(cmId) {
const pendingKey = `courseformat/content:reloadCm_${cmId}`;
let debouncedReload = this.debouncedReloads.get(pendingKey);
if (debouncedReload) {
return debouncedReload;
}
const reload = () => {
const pendingReload = new Pending(pendingKey);
this.debouncedReloads.delete(pendingKey);
const cmitem = this.getElement(this.selectors.CM, cmId);
if (!cmitem) {
return pendingReload.resolve();
}
const promise = Fragment.loadFragment(
'core_courseformat',
'cmitem',
Config.courseContextId,
{
id: cmId,
courseid: Config.courseId,
sr: this.reactive.sectionReturn ?? null,
}
);
promise.then((html, js) => {
// Other state change can reload the CM or the section before this one.
if (!document.contains(cmitem)) {
pendingReload.resolve();
return false;
}
Templates.replaceNode(cmitem, html, js);
this._indexContents();
pendingReload.resolve();
return true;
}).catch(() => {
pendingReload.resolve();
});
return pendingReload;
};
debouncedReload = debounce(
reload,
200,
{
cancel: true, pending: true
}
);
this.debouncedReloads.set(pendingKey, debouncedReload);
return debouncedReload;
}
/**
* Cancel the active reload CM debounced function, if any.
* @param {Number} cmId
*/
_cancelDebouncedReloadCm(cmId) {
const pendingKey = `courseformat/content:reloadCm_${cmId}`;
const debouncedReload = this.debouncedReloads.get(pendingKey);
if (!debouncedReload) {
return;
}
debouncedReload.cancel();
this.debouncedReloads.delete(pendingKey);
}
/**
* Reload a course section contents.
*
* Section HTML is still strongly backend dependant.
* Some changes require to get a new version of the section.
*
* @param {details} param0 the watcher details
* @param {object} param0.element the state object
*/
_reloadSection({element}) {
const pendingReload = new Pending(`courseformat/content:reloadSection_${element.id}`);
const sectionitem = this.getElement(this.selectors.SECTION, element.id);
if (sectionitem) {
// Cancel any pending reload because the section will reload cms too.
for (const cmId of element.cmlist) {
this._cancelDebouncedReloadCm(cmId);
}
const promise = Fragment.loadFragment(
'core_courseformat',
'section',
Config.courseContextId,
{
id: element.id,
courseid: Config.courseId,
sr: this.reactive.sectionReturn ?? null,
}
);
promise.then((html, js) => {
Templates.replaceNode(sectionitem, html, js);
this._indexContents();
pendingReload.resolve();
}).catch(() => {
pendingReload.resolve();
});
}
}
/**
* Create a new course module item in a section.
*
* Thos method will append a fake item in the container and trigger an ajax request to
* replace the fake element by the real content.
*
* @param {Element} container the container element (section)
* @param {Number} cmid the course-module ID
* @returns {Element} the created element
*/
_createCmItem(container, cmid) {
const newItem = document.createElement(this.selectors.ACTIVITYTAG);
newItem.dataset.for = 'cmitem';
newItem.dataset.id = cmid;
// The legacy actions.js requires a specific ID and class to refresh the CM.
newItem.id = `module-${cmid}`;
newItem.classList.add(this.classes.ACTIVITY);
container.append(newItem);
this._reloadCm({
element: this.reactive.get('cm', cmid),
});
return newItem;
}
/**
* Create a new section item.
*
* This method will append a fake item in the container and trigger an ajax request to
* replace the fake element by the real content.
*
* @param {Element} container the container element (section)
* @param {Number} sectionid the course-module ID
* @returns {Element} the created element
*/
_createSectionItem(container, sectionid) {
const section = this.reactive.get('section', sectionid);
const newItem = document.createElement(this.selectors.SECTIONTAG);
newItem.dataset.for = 'section';
newItem.dataset.id = sectionid;
newItem.dataset.number = section.number;
// The legacy actions.js requires a specific ID and class to refresh the section.
newItem.id = `section-${sectionid}`;
newItem.classList.add(this.classes.SECTION);
container.append(newItem);
this._reloadSection({
element: section,
});
return newItem;
}
/**
* Fix/reorder the section or cms order.
*
* @param {Element} container the HTML element to reorder.
* @param {Array} neworder an array with the ids order
* @param {string} selector the element selector
* @param {Object} dettachedelements a list of dettached elements
* @param {function} createMethod method to create missing elements
*/
async _fixOrder(container, neworder, selector, dettachedelements, createMethod) {
if (container === undefined) {
return;
}
// Empty lists should not be visible.
if (!neworder.length) {
container.classList.add('hidden');
container.innerHTML = '';
return;
}
// Grant the list is visible (in case it was empty).
container.classList.remove('hidden');
// Move the elements in order at the beginning of the list.
neworder.forEach((itemid, index) => {
let item = this.getElement(selector, itemid) ?? dettachedelements[itemid] ?? createMethod(container, itemid);
if (item === undefined) {
// Missing elements cannot be sorted.
return;
}
// Get the current elemnt at that position.
const currentitem = container.children[index];
if (currentitem === undefined) {
container.append(item);
return;
}
if (currentitem !== item) {
container.insertBefore(item, currentitem);
}
});
// Dndupload add a fake element we need to keep.
let dndFakeActivity;
// Remove the remaining elements.
while (container.children.length > neworder.length) {
const lastchild = container.lastChild;
if (lastchild?.classList?.contains('dndupload-preview')) {
dndFakeActivity = lastchild;
} else {
dettachedelements[lastchild?.dataset?.id ?? 0] = lastchild;
}
container.removeChild(lastchild);
}
// Restore dndupload fake element.
if (dndFakeActivity) {
container.append(dndFakeActivity);
}
}
}
@@ -0,0 +1,754 @@
// 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/>.
/**
* Course state actions dispatcher.
*
* This module captures all data-dispatch links in the course content and dispatch the proper
* state mutation, including any confirmation and modal required.
*
* @module core_courseformat/local/content/actions
* @class core_courseformat/local/content/actions
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import Modal from 'core/modal';
import ModalSaveCancel from 'core/modal_save_cancel';
import ModalDeleteCancel from 'core/modal_delete_cancel';
import ModalEvents from 'core/modal_events';
import Templates from 'core/templates';
import {prefetchStrings} from 'core/prefetch';
import {getString} from 'core/str';
import {getFirst} from 'core/normalise';
import {toggleBulkSelectionAction} from 'core_courseformat/local/content/actions/bulkselection';
import * as CourseEvents from 'core_course/events';
import Pending from 'core/pending';
import ContentTree from 'core_courseformat/local/courseeditor/contenttree';
// The jQuery module is only used for interacting with Boostrap 4. It can we removed when MDL-71979 is integrated.
import jQuery from 'jquery';
// Load global strings.
prefetchStrings('core', ['movecoursesection', 'movecoursemodule', 'confirm', 'delete']);
// Mutations are dispatched by the course content actions.
// Formats can use this module addActions static method to add custom actions.
// Direct mutations can be simple strings (mutation) name or functions.
const directMutations = {
sectionHide: 'sectionHide',
sectionShow: 'sectionShow',
cmHide: 'cmHide',
cmShow: 'cmShow',
cmStealth: 'cmStealth',
cmMoveRight: 'cmMoveRight',
cmMoveLeft: 'cmMoveLeft',
cmNoGroups: 'cmNoGroups',
cmSeparateGroups: 'cmSeparateGroups',
cmVisibleGroups: 'cmVisibleGroups',
};
export default class extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'content_actions';
// Default query selectors.
this.selectors = {
ACTIONLINK: `[data-action]`,
// Move modal selectors.
SECTIONLINK: `[data-for='section']`,
CMLINK: `[data-for='cm']`,
SECTIONNODE: `[data-for='sectionnode']`,
MODALTOGGLER: `[data-toggle='collapse']`,
ADDSECTION: `[data-action='addSection']`,
CONTENTTREE: `#destination-selector`,
ACTIONMENU: `.action-menu`,
ACTIONMENUTOGGLER: `[data-toggle="dropdown"]`,
// Availability modal selectors.
OPTIONSRADIO: `[type='radio']`,
};
// Component css classes.
this.classes = {
DISABLED: `text-body`,
ITALIC: `font-italic`,
};
}
/**
* Add extra actions to the module.
*
* @param {array} actions array of methods to execute
*/
static addActions(actions) {
for (const [action, mutationReference] of Object.entries(actions)) {
if (typeof mutationReference !== 'function' && typeof mutationReference !== 'string') {
throw new Error(`${action} action must be a mutation name or a function`);
}
directMutations[action] = mutationReference;
}
}
/**
* Initial state ready method.
*
* @param {Object} state the state data.
*
*/
stateReady(state) {
// Delegate dispatch clicks.
this.addEventListener(
this.element,
'click',
this._dispatchClick
);
// Check section limit.
this._checkSectionlist({state});
// Add an Event listener to recalculate limits it if a section HTML is altered.
this.addEventListener(
this.element,
CourseEvents.sectionRefreshed,
() => this._checkSectionlist({state})
);
}
/**
* Return the component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
// Check section limit.
{watch: `course.sectionlist:updated`, handler: this._checkSectionlist},
];
}
_dispatchClick(event) {
const target = event.target.closest(this.selectors.ACTIONLINK);
if (!target) {
return;
}
if (target.classList.contains(this.classes.DISABLED)) {
event.preventDefault();
return;
}
// Invoke proper method.
const actionName = target.dataset.action;
const methodName = this._actionMethodName(actionName);
if (this[methodName] !== undefined) {
this[methodName](target, event);
return;
}
// Check direct mutations or mutations handlers.
if (directMutations[actionName] !== undefined) {
if (typeof directMutations[actionName] === 'function') {
directMutations[actionName](target, event);
return;
}
this._requestMutationAction(target, event, directMutations[actionName]);
return;
}
}
_actionMethodName(name) {
const requestName = name.charAt(0).toUpperCase() + name.slice(1);
return `_request${requestName}`;
}
/**
* Check the section list and disable some options if needed.
*
* @param {Object} detail the update details.
* @param {Object} detail.state the state object.
*/
_checkSectionlist({state}) {
// Disable "add section" actions if the course max sections has been exceeded.
this._setAddSectionLocked(state.course.sectionlist.length > state.course.maxsections);
}
/**
* Return the ids represented by this element.
*
* Depending on the dataset attributes the action could represent a single id
* or a bulk actions with all the current selected ids.
*
* @param {HTMLElement} target
* @returns {Number[]} array of Ids
*/
_getTargetIds(target) {
let ids = [];
if (target?.dataset?.id) {
ids.push(target.dataset.id);
}
const bulkType = target?.dataset?.bulk;
if (!bulkType) {
return ids;
}
const bulk = this.reactive.get('bulk');
if (bulk.enabled && bulk.selectedType === bulkType) {
ids = [...ids, ...bulk.selection];
}
return ids;
}
/**
* Handle a move section request.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestMoveSection(target, event) {
// Check we have an id.
const sectionIds = this._getTargetIds(target);
if (sectionIds.length == 0) {
return;
}
event.preventDefault();
const pendingModalReady = new Pending(`courseformat/actions:prepareMoveSectionModal`);
// The section edit menu to refocus on end.
const editTools = this._getClosestActionMenuToogler(target);
// Collect section information from the state.
const exporter = this.reactive.getExporter();
const data = exporter.course(this.reactive.state);
let titleText = null;
// Add the target section id and title.
let sectionInfo = null;
if (sectionIds.length == 1) {
sectionInfo = this.reactive.get('section', sectionIds[0]);
data.sectionid = sectionInfo.id;
data.sectiontitle = sectionInfo.title;
data.information = await this.reactive.getFormatString('sectionmove_info', data.sectiontitle);
titleText = this.reactive.getFormatString('sectionmove_title');
} else {
data.information = await this.reactive.getFormatString('sectionsmove_info', sectionIds.length);
titleText = this.reactive.getFormatString('sectionsmove_title');
}
// Create the modal.
// Build the modal parameters from the event data.
const modal = await this._modalBodyRenderedPromise(Modal, {
title: titleText,
body: Templates.render('core_courseformat/local/content/movesection', data),
});
const modalBody = getFirst(modal.getBody());
// Disable current selected section ids.
sectionIds.forEach(sectionId => {
const currentElement = modalBody.querySelector(`${this.selectors.SECTIONLINK}[data-id='${sectionId}']`);
this._disableLink(currentElement);
});
// Setup keyboard navigation.
new ContentTree(
modalBody.querySelector(this.selectors.CONTENTTREE),
{
SECTION: this.selectors.SECTIONNODE,
TOGGLER: this.selectors.MODALTOGGLER,
COLLAPSE: this.selectors.MODALTOGGLER,
},
true
);
// Capture click.
modalBody.addEventListener('click', (event) => {
const target = event.target;
if (!target.matches('a') || target.dataset.for != 'section' || target.dataset.id === undefined) {
return;
}
if (target.getAttribute('aria-disabled')) {
return;
}
event.preventDefault();
this.reactive.dispatch('sectionMoveAfter', sectionIds, target.dataset.id);
this._destroyModal(modal, editTools);
});
pendingModalReady.resolve();
}
/**
* Handle a move cm request.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestMoveCm(target, event) {
// Check we have an id.
const cmIds = this._getTargetIds(target);
if (cmIds.length == 0) {
return;
}
event.preventDefault();
const pendingModalReady = new Pending(`courseformat/actions:prepareMoveCmModal`);
// The section edit menu to refocus on end.
const editTools = this._getClosestActionMenuToogler(target);
// Collect information from the state.
const exporter = this.reactive.getExporter();
const data = exporter.course(this.reactive.state);
let titleText = null;
if (cmIds.length == 1) {
const cmInfo = this.reactive.get('cm', cmIds[0]);
data.cmid = cmInfo.id;
data.cmname = cmInfo.name;
data.information = await this.reactive.getFormatString('cmmove_info', data.cmname);
titleText = this.reactive.getFormatString('cmmove_title');
} else {
data.information = await this.reactive.getFormatString('cmsmove_info', cmIds.length);
titleText = this.reactive.getFormatString('cmsmove_title');
}
// Create the modal.
// Build the modal parameters from the event data.
const modal = await this._modalBodyRenderedPromise(Modal, {
title: titleText,
body: Templates.render('core_courseformat/local/content/movecm', data),
});
const modalBody = getFirst(modal.getBody());
// Disable current selected section ids.
cmIds.forEach(cmId => {
const currentElement = modalBody.querySelector(`${this.selectors.CMLINK}[data-id='${cmId}']`);
this._disableLink(currentElement);
});
// Setup keyboard navigation.
new ContentTree(
modalBody.querySelector(this.selectors.CONTENTTREE),
{
SECTION: this.selectors.SECTIONNODE,
TOGGLER: this.selectors.MODALTOGGLER,
COLLAPSE: this.selectors.MODALTOGGLER,
ENTER: this.selectors.SECTIONLINK,
}
);
// Open the cm section node if possible (Bootstrap 4 uses jQuery to interact with collapsibles).
// All jQuery in this code can be replaced when MDL-71979 is integrated.
cmIds.forEach(cmId => {
const currentElement = modalBody.querySelector(`${this.selectors.CMLINK}[data-id='${cmId}']`);
const sectionnode = currentElement.closest(this.selectors.SECTIONNODE);
const toggler = jQuery(sectionnode).find(this.selectors.MODALTOGGLER);
let collapsibleId = toggler.data('target') ?? toggler.attr('href');
if (collapsibleId) {
// We cannot be sure we have # in the id element name.
collapsibleId = collapsibleId.replace('#', '');
const expandNode = modalBody.querySelector(`#${collapsibleId}`);
jQuery(expandNode).collapse('show');
}
});
modalBody.addEventListener('click', (event) => {
const target = event.target;
if (!target.matches('a') || target.dataset.for === undefined || target.dataset.id === undefined) {
return;
}
if (target.getAttribute('aria-disabled')) {
return;
}
event.preventDefault();
let targetSectionId;
let targetCmId;
if (target.dataset.for == 'cm') {
const dropData = exporter.cmDraggableData(this.reactive.state, target.dataset.id);
targetSectionId = dropData.sectionid;
targetCmId = dropData.nextcmid;
} else {
const section = this.reactive.get('section', target.dataset.id);
targetSectionId = target.dataset.id;
targetCmId = section?.cmlist[0];
}
this.reactive.dispatch('cmMove', cmIds, targetSectionId, targetCmId);
this._destroyModal(modal, editTools);
});
pendingModalReady.resolve();
}
/**
* Handle a create section request.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestAddSection(target, event) {
event.preventDefault();
this.reactive.dispatch('addSection', target.dataset.id ?? 0);
}
/**
* Handle a delete section request.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestDeleteSection(target, event) {
const sectionIds = this._getTargetIds(target);
if (sectionIds.length == 0) {
return;
}
event.preventDefault();
// We don't need confirmation to delete empty sections.
let needsConfirmation = sectionIds.some(sectionId => {
const sectionInfo = this.reactive.get('section', sectionId);
const cmList = sectionInfo.cmlist ?? [];
return (cmList.length || sectionInfo.hassummary || sectionInfo.rawtitle);
});
if (!needsConfirmation) {
this.reactive.dispatch('sectionDelete', sectionIds);
return;
}
let bodyText = null;
let titleText = null;
if (sectionIds.length == 1) {
titleText = this.reactive.getFormatString('sectiondelete_title');
const sectionInfo = this.reactive.get('section', sectionIds[0]);
bodyText = this.reactive.getFormatString('sectiondelete_info', {name: sectionInfo.title});
} else {
titleText = this.reactive.getFormatString('sectionsdelete_title');
bodyText = this.reactive.getFormatString('sectionsdelete_info', {count: sectionIds.length});
}
const modal = await this._modalBodyRenderedPromise(ModalDeleteCancel, {
title: titleText,
body: bodyText,
});
modal.getRoot().on(
ModalEvents.delete,
e => {
// Stop the default save button behaviour which is to close the modal.
e.preventDefault();
modal.destroy();
this.reactive.dispatch('sectionDelete', sectionIds);
}
);
}
/**
* Handle a toggle cm selection.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestToggleSelectionCm(target, event) {
toggleBulkSelectionAction(this.reactive, target, event, 'cm');
}
/**
* Handle a toggle section selection.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestToggleSelectionSection(target, event) {
toggleBulkSelectionAction(this.reactive, target, event, 'section');
}
/**
* Basic mutation action helper.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
* @param {string} mutationName the mutation name
*/
async _requestMutationAction(target, event, mutationName) {
if (!target.dataset.id && target.dataset.for !== 'bulkaction') {
return;
}
event.preventDefault();
if (target.dataset.for === 'bulkaction') {
// If the mutation is a bulk action we use the current selection.
this.reactive.dispatch(mutationName, this.reactive.get('bulk').selection);
} else {
this.reactive.dispatch(mutationName, [target.dataset.id]);
}
}
/**
* Handle a course module duplicate request.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestCmDuplicate(target, event) {
const cmIds = this._getTargetIds(target);
if (cmIds.length == 0) {
return;
}
const sectionId = target.dataset.sectionid ?? null;
event.preventDefault();
this.reactive.dispatch('cmDuplicate', cmIds, sectionId);
}
/**
* Handle a delete cm request.
*
* @param {Element} target the dispatch action element
* @param {Event} event the triggered event
*/
async _requestCmDelete(target, event) {
const cmIds = this._getTargetIds(target);
if (cmIds.length == 0) {
return;
}
event.preventDefault();
let bodyText = null;
let titleText = null;
if (cmIds.length == 1) {
const cmInfo = this.reactive.get('cm', cmIds[0]);
titleText = getString('cmdelete_title', 'core_courseformat');
bodyText = getString(
'cmdelete_info',
'core_courseformat',
{
type: cmInfo.modname,
name: cmInfo.name,
}
);
} else {
titleText = getString('cmsdelete_title', 'core_courseformat');
bodyText = getString(
'cmsdelete_info',
'core_courseformat',
{count: cmIds.length}
);
}
const modal = await this._modalBodyRenderedPromise(ModalDeleteCancel, {
title: titleText,
body: bodyText,
});
modal.getRoot().on(
ModalEvents.delete,
e => {
// Stop the default save button behaviour which is to close the modal.
e.preventDefault();
modal.destroy();
this.reactive.dispatch('cmDelete', cmIds);
}
);
}
/**
* Handle a cm availability change request.
*
* @param {Element} target the dispatch action element
*/
async _requestCmAvailability(target) {
const cmIds = this._getTargetIds(target);
if (cmIds.length == 0) {
return;
}
// Show the availability modal to decide which action to trigger.
const exporter = this.reactive.getExporter();
const data = {
allowstealth: exporter.canUseStealth(this.reactive.state, cmIds),
};
const modal = await this._modalBodyRenderedPromise(ModalSaveCancel, {
title: getString('availability', 'core'),
body: Templates.render('core_courseformat/local/content/cm/availabilitymodal', data),
saveButtonText: getString('apply', 'core'),
});
this._setupMutationRadioButtonModal(modal, cmIds);
}
/**
* Handle a section availability change request.
*
* @param {Element} target the dispatch action element
*/
async _requestSectionAvailability(target) {
const sectionIds = this._getTargetIds(target);
if (sectionIds.length == 0) {
return;
}
const title = (sectionIds.length == 1) ? 'sectionavailability_title' : 'sectionsavailability_title';
// Show the availability modal to decide which action to trigger.
const modal = await this._modalBodyRenderedPromise(ModalSaveCancel, {
title: this.reactive.getFormatString(title),
body: Templates.render('core_courseformat/local/content/section/availabilitymodal', []),
saveButtonText: getString('apply', 'core'),
});
this._setupMutationRadioButtonModal(modal, sectionIds);
}
/**
* Add events to a mutation selector radio buttons modal.
* @param {Modal} modal
* @param {Number[]} ids the section or cm ids to apply the mutation
*/
_setupMutationRadioButtonModal(modal, ids) {
// The save button is not enabled until the user selects an option.
modal.setButtonDisabled('save', true);
const submitFunction = (radio) => {
const mutation = radio?.value;
if (!mutation) {
return false;
}
this.reactive.dispatch(mutation, ids);
return true;
};
const modalBody = getFirst(modal.getBody());
const radioOptions = modalBody.querySelectorAll(this.selectors.OPTIONSRADIO);
radioOptions.forEach(radio => {
radio.addEventListener('change', () => {
modal.setButtonDisabled('save', false);
});
radio.parentNode.addEventListener('click', () => {
radio.checked = true;
modal.setButtonDisabled('save', false);
});
radio.parentNode.addEventListener('dblclick', dbClickEvent => {
if (submitFunction(radio)) {
dbClickEvent.preventDefault();
modal.destroy();
}
});
});
modal.getRoot().on(
ModalEvents.save,
() => {
const radio = modalBody.querySelector(`${this.selectors.OPTIONSRADIO}:checked`);
submitFunction(radio);
}
);
}
/**
* Disable all add sections actions.
*
* @param {boolean} locked the new locked value.
*/
_setAddSectionLocked(locked) {
const targets = this.getElements(this.selectors.ADDSECTION);
targets.forEach(element => {
element.classList.toggle(this.classes.DISABLED, locked);
element.classList.toggle(this.classes.ITALIC, locked);
this.setElementLocked(element, locked);
});
}
/**
* Replace an element with a copy with a different tag name.
*
* @param {Element} element the original element
*/
_disableLink(element) {
if (element) {
element.style.pointerEvents = 'none';
element.style.userSelect = 'none';
element.classList.add(this.classes.DISABLED);
element.classList.add(this.classes.ITALIC);
element.setAttribute('aria-disabled', true);
element.addEventListener('click', event => event.preventDefault());
}
}
/**
* Render a modal and return a body ready promise.
*
* @param {Modal} ModalClass the modal class
* @param {object} modalParams the modal params
* @return {Promise} the modal body ready promise
*/
_modalBodyRenderedPromise(ModalClass, modalParams) {
return new Promise((resolve, reject) => {
ModalClass.create(modalParams).then((modal) => {
modal.setRemoveOnClose(true);
// Handle body loading event.
modal.getRoot().on(ModalEvents.bodyRendered, () => {
resolve(modal);
});
// Configure some extra modal params.
if (modalParams.saveButtonText !== undefined) {
modal.setSaveButtonText(modalParams.saveButtonText);
}
if (modalParams.deleteButtonText !== undefined) {
modal.setDeleteButtonText(modalParams.saveButtonText);
}
modal.show();
return;
}).catch(() => {
reject(`Cannot load modal content`);
});
});
}
/**
* Hide and later destroy a modal.
*
* Behat will fail if we remove the modal while some boostrap collapse is executing.
*
* @param {Modal} modal
* @param {HTMLElement} element the dom element to focus on.
*/
_destroyModal(modal, element) {
modal.hide();
const pendingDestroy = new Pending(`courseformat/actions:destroyModal`);
if (element) {
element.focus();
}
setTimeout(() =>{
modal.destroy();
pendingDestroy.resolve();
}, 500);
}
/**
* Get the closest actions menu toggler to an action element.
*
* @param {HTMLElement} element the action link element
* @returns {HTMLElement|undefined}
*/
_getClosestActionMenuToogler(element) {
const actionMenu = element.closest(this.selectors.ACTIONMENU);
if (!actionMenu) {
return undefined;
}
return actionMenu.querySelector(this.selectors.ACTIONMENUTOGGLER);
}
}
@@ -0,0 +1,391 @@
// 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/>.
/**
* Bulk selection auxiliar methods.
*
* @module core_courseformat/local/content/actions/bulkselection
* @class core_courseformat/local/content/actions/bulkselection
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class BulkSelector {
/**
* The class constructor.
* @param {CourseEditor} courseEditor the original actions component.
*/
constructor(courseEditor) {
this.courseEditor = courseEditor;
this.selectors = {
BULKCMCHECKBOX: `[data-bulkcheckbox][data-action='toggleSelectionCm']`,
BULKSECTIONCHECKBOX: `[data-bulkcheckbox][data-action='toggleSelectionSection']`,
CONTENT: `#region-main`,
};
}
/**
* Process a new selection.
* @param {Number} id
* @param {String} elementType cm or section
* @param {Object} settings special selection settings
* @param {Boolean} settings.all if the action is over all elements of the same type
* @param {Boolean} settings.range if the action is over a range of elements
*/
processNewSelection(id, elementType, settings) {
const value = !this._isBulkSelected(id, elementType);
if (settings.all && settings.range) {
this.switchCurrentSelection();
return;
}
if (!this._isSelectable(id, elementType)) {
return;
}
if (settings.all) {
if (elementType == 'cm') {
this._updateBulkCmSiblings(id, value);
} else {
this._updateBulkSelectionAll(elementType, value);
}
return;
}
if (settings.range) {
this._updateBulkSelectionRange(id, elementType, value);
return;
}
this._updateBulkSelection([id], elementType, value);
}
/**
* Switch between section and cm selection.
*/
switchCurrentSelection() {
const bulk = this.courseEditor.get('bulk');
if (bulk.selectedType === '' || bulk.selection.length == 0) {
return;
}
const newSelectedType = (bulk.selectedType === 'section') ? 'cm' : 'section';
let newSelectedIds;
if (bulk.selectedType === 'section') {
newSelectedIds = this._getCmIdsFromSections(bulk.selection);
} else {
newSelectedIds = this._getSectionIdsFromCms(bulk.selection);
}
// Formats can display only a few activities of the section,
// We need to select on the activities present in the page.
const affectedIds = [];
newSelectedIds.forEach(newId => {
if (this._getSelector(newId, newSelectedType)) {
affectedIds.push(newId);
}
});
this.courseEditor.dispatch('bulkEnable', true);
if (affectedIds.length != 0) {
this._updateBulkSelection(affectedIds, newSelectedType, true);
}
}
/**
* Select all elements of the current type.
* @param {Boolean} value the wanted selected value
*/
selectAll(value) {
const bulk = this.courseEditor.get('bulk');
if (bulk.selectedType == '') {
return;
}
if (!value) {
this.courseEditor.dispatch('bulkEnable', true);
return;
}
const elementType = bulk.selectedType;
this._updateBulkSelectionAll(elementType, value);
}
/**
* Checks if all selectable elements are selected.
* @returns {Boolean} true if all are selected
*/
checkAllSelected() {
const bulk = this.courseEditor.get('bulk');
if (bulk.selectedType == '') {
return false;
}
return this._getContentCheckboxes(bulk.selectedType).every(bulkSelect => {
if (bulkSelect.disabled) {
return true;
}
// Some sections may not be selectale for bulk actions.
if (bulk.selectedType == 'section') {
const section = this.courseEditor.get('section', bulkSelect.dataset.id);
if (!section.bulkeditable) {
return true;
}
}
return bulk.selection.includes(bulkSelect.dataset.id);
});
}
/**
* Check if the id is part of the current bulk selection.
* @private
* @param {Number} id
* @param {String} elementType
* @returns {Boolean} if the element is present in the current selection.
*/
_isBulkSelected(id, elementType) {
const bulk = this.courseEditor.get('bulk');
if (bulk.selectedType !== elementType) {
return false;
}
return bulk.selection.includes(id);
}
/**
* Update the current bulk selection removing or adding Ids.
* @private
* @param {Number[]} ids the user selected element id
* @param {String} elementType cm or section
* @param {Boolean} value the wanted selected value
*/
_updateBulkSelection(ids, elementType, value) {
let mutation = elementType;
mutation += (value) ? 'Select' : 'Unselect';
this.courseEditor.dispatch(mutation, ids);
}
/**
* Get all content bulk selector checkboxes of one type (section/cm).
* @private
* @param {String} elementType section or cm
* @returns {HTMLElement[]} an array with all checkboxes
*/
_getContentCheckboxes(elementType) {
const selector = (elementType == 'cm') ? this.selectors.BULKCMCHECKBOX : this.selectors.BULKSECTIONCHECKBOX;
const checkboxes = document.querySelectorAll(`${this.selectors.CONTENT} ${selector}`);
// Converting to array because NodeList has less iteration methods.
return [...checkboxes];
}
/**
* Validate if an element is selectable in the current page.
* @private
* @param {Number} id the user selected element id
* @param {String} elementType cm or section
* @return {Boolean}
*/
_isSelectable(id, elementType) {
const bulkSelect = this._getSelector(id, elementType);
if (!bulkSelect || bulkSelect.disabled) {
return false;
}
return true;
}
/**
* Get as specific element checkbox.
* @private
* @param {Number} id
* @param {String} elementType cm or section
* @returns {HTMLElement|undefined}
*/
_getSelector(id, elementType) {
let selector = (elementType == 'cm') ? this.selectors.BULKCMCHECKBOX : this.selectors.BULKSECTIONCHECKBOX;
selector += `[data-id='${id}']`;
return document.querySelector(`${this.selectors.CONTENT} ${selector}`);
}
/**
* Update the current bulk selection when a user uses shift to select a range.
* @private
* @param {Number} id the user selected element id
* @param {String} elementType cm or section
* @param {Boolean} value the wanted selected value
*/
_updateBulkSelectionRange(id, elementType, value) {
const bulk = this.courseEditor.get('bulk');
let lastSelectedId = bulk.selection.at(-1);
if (bulk.selectedType !== elementType || lastSelectedId == id) {
this._updateBulkSelection([id], elementType, value);
return;
}
const affectedIds = [];
let found = 0;
this._getContentCheckboxes(elementType).every(bulkSelect => {
if (bulkSelect.disabled) {
return true;
}
if (elementType == 'section') {
const section = this.courseEditor.get('section', bulkSelect.dataset.id);
if (value && !section?.bulkeditable) {
return true;
}
}
if (bulkSelect.dataset.id == id || bulkSelect.dataset.id == lastSelectedId) {
found++;
}
if (found == 0) {
return true;
}
affectedIds.push(bulkSelect.dataset.id);
return found != 2;
});
this._updateBulkSelection(affectedIds, elementType, value);
}
/**
* Select or unselect all cm siblings.
* @private
* @param {Number} cmId the user selected element id
* @param {Boolean} value the wanted selected value
*/
_updateBulkCmSiblings(cmId, value) {
const bulk = this.courseEditor.get('bulk');
if (bulk.selectedType === 'section') {
return;
}
const cm = this.courseEditor.get('cm', cmId);
const section = this.courseEditor.get('section', cm.sectionid);
// Formats can display only a few activities of the section,
// We need to select on the activities selectable in the page.
const affectedIds = [];
section.cmlist.forEach(sectionCmId => {
if (this._isSelectable(sectionCmId, 'cm')) {
affectedIds.push(sectionCmId);
}
});
this._updateBulkSelection(affectedIds, 'cm', value);
}
/**
* Select or unselects al elements of the same type.
* @private
* @param {String} elementType section or cm
* @param {Boolean} value if the elements must be selected or unselected.
*/
_updateBulkSelectionAll(elementType, value) {
const affectedIds = [];
this._getContentCheckboxes(elementType).forEach(bulkSelect => {
if (bulkSelect.disabled) {
return;
}
if (elementType == 'section') {
const section = this.courseEditor.get('section', bulkSelect.dataset.id);
if (value && !section?.bulkeditable) {
return;
}
}
affectedIds.push(bulkSelect.dataset.id);
});
this._updateBulkSelection(affectedIds, elementType, value);
}
/**
* Get all cm ids from a specific section ids.
* @private
* @param {Number[]} sectionIds
* @returns {Number[]} the cm ids
*/
_getCmIdsFromSections(sectionIds) {
const result = [];
sectionIds.forEach(sectionId => {
const section = this.courseEditor.get('section', sectionId);
result.push(...section.cmlist);
});
return result;
}
/**
* Get all section ids containing a specific cm ids.
* @private
* @param {Number[]} cmIds
* @returns {Number[]} the section ids
*/
_getSectionIdsFromCms(cmIds) {
const result = new Set();
cmIds.forEach(cmId => {
const cm = this.courseEditor.get('cm', cmId);
if (cm.sectionnumber == 0) {
return;
}
result.add(cm.sectionid);
});
return [...result];
}
}
/**
* Process a bulk selection toggle action.
* @method
* @param {CourseEditor} courseEditor
* @param {HTMLElement} target the action element
* @param {Event} event
* @param {String} elementType cm or section
*/
export const toggleBulkSelectionAction = function(courseEditor, target, event, elementType) {
const id = target.dataset.id;
if (!id) {
return;
}
// When the action cames from a form element (checkbox) we should not preventDefault.
// If we do it the changechecker module will execute the state change twice.
if (target.dataset.preventDefault) {
event.preventDefault();
}
// Using shift or alt key can produce text selection.
document.getSelection().removeAllRanges();
const bulkSelector = new BulkSelector(courseEditor);
bulkSelector.processNewSelection(
id,
elementType,
{
range: event.shiftKey,
all: event.altKey,
}
);
};
/**
* Switch the current bulk selection.
* @method
* @param {CourseEditor} courseEditor
*/
export const switchBulkSelection = function(courseEditor) {
const bulkSelector = new BulkSelector(courseEditor);
bulkSelector.switchCurrentSelection();
};
/**
* Select/unselect all element of the selected type.
* @method
* @param {CourseEditor} courseEditor
* @param {Boolean} value if the elements must be selected or unselected.
*/
export const selectAllBulk = function(courseEditor, value) {
const bulkSelector = new BulkSelector(courseEditor);
bulkSelector.selectAll(value);
};
/**
* Check if all possible elements are selected.
* @method
* @param {CourseEditor} courseEditor
* @return {Boolean} if all elements of the current type are selected.
*/
export const checkAllBulkSelected = function(courseEditor) {
const bulkSelector = new BulkSelector(courseEditor);
return bulkSelector.checkAllSelected();
};
@@ -0,0 +1,84 @@
// 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/>.
/**
* The activity header component.
*
* @module core_courseformat/local/content/activity_header
* @class core_courseformat/local/content/activity_header
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
import * as CourseEvents from 'core_course/events';
// Global page selectors.
const SELECTORS = {
ACTIVITY_HEADER: `[data-for='page-activity-header']`,
};
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'activity_header';
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {string} target optional altentative DOM main element CSS selector
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
const elementselector = (target) ? target : SELECTORS.ACTIVITY_HEADER;
return new Component({
element: document.querySelector(elementselector),
reactive: getCurrentCourseEditor(),
selectors
});
}
/**
* Initial state ready method.
*/
stateReady() {
// Capture completion events.
this.addEventListener(
this.element,
CourseEvents.manualCompletionToggled,
this._completionHandler
);
}
/**
* Activity manual completion listener.
*
* @param {Event} event the custom event
* @param {object} event.detail the event details
*/
_completionHandler({detail}) {
if (detail === undefined) {
return;
}
this.reactive.dispatch('cmCompletion', [detail.cmid], detail.completed);
}
}
@@ -0,0 +1,115 @@
// 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/>.
/**
* The bulk editor toggler button control.
*
* @module core_courseformat/local/content/bulkedittoggler
* @class core_courseformat/local/content/bulkedittoggler
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
import Pending from 'core/pending';
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'bulk_editor_toogler';
// Default query selectors.
this.selectors = {
BODY: `body`,
SELECTABLE: `[data-bulkcheckbox][data-is-selectable]`,
};
// Component css classes.
this.classes = {
HIDDEN: `d-none`,
BULK: `bulkenabled`,
};
}
/**
* Static method to create a component instance from the mustache template.
*
* @param {string} target optional altentative DOM main element CSS selector
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.querySelector(target),
reactive: getCurrentCourseEditor(),
selectors
});
}
/**
* Initial state ready method.
*/
stateReady() {
// Capture completion events.
this.addEventListener(
this.element,
'click',
this._enableBulk
);
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `bulk.enabled:updated`, handler: this._refreshToggler},
];
}
/**
* Update a content section using the state information.
*
* @param {object} param
* @param {Object} param.element details the update details (state.bulk in this case).
*/
_refreshToggler({element}) {
this.element.classList.toggle(this.classes.HIDDEN, element.enabled ?? false);
document.querySelector(this.selectors.BODY)?.classList.toggle(this.classes.BULK, element.enabled);
}
/**
* Dispatch the enable bulk mutation.
*
* The enable bulk button is outside of the course content main div.
* Because content/actions captures click events only in the course
* content, this button needs to trigger the enable bulk mutation
* by itself.
*/
_enableBulk() {
const pendingToggle = new Pending(`courseformat/content:bulktoggle_on`);
this.reactive.dispatch('bulkEnable', true);
// Wait for a while and focus on the first checkbox.
setTimeout(() => {
document.querySelector(this.selectors.SELECTABLE)?.focus();
pendingToggle.resolve();
}, 150);
}
}
@@ -0,0 +1,283 @@
// 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/>.
/**
* The bulk editor tools bar.
*
* @module core_courseformat/local/content/bulkedittools
* @class core_courseformat/local/content/bulkedittools
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {disableStickyFooter, enableStickyFooter} from 'core/sticky-footer';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
import {getString} from 'core/str';
import Pending from 'core/pending';
import {prefetchStrings} from 'core/prefetch';
import {
selectAllBulk,
switchBulkSelection,
checkAllBulkSelected
} from 'core_courseformat/local/content/actions/bulkselection';
import Notification from 'core/notification';
// Load global strings.
prefetchStrings(
'core_courseformat',
['bulkselection']
);
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'bulk_editor_tools';
// Default query selectors.
this.selectors = {
ACTIONS: `[data-for="bulkaction"]`,
ACTIONTOOL: `[data-for="bulkactions"] li`,
CANCEL: `[data-for="bulkcancel"]`,
COUNT: `[data-for='bulkcount']`,
SELECTABLE: `[data-bulkcheckbox][data-is-selectable]`,
SELECTALL: `[data-for="selectall"]`,
BULKBTN: `[data-for="enableBulk"]`,
};
// Most classes will be loaded later by DndCmItem.
this.classes = {
HIDE: 'd-none',
DISABLED: 'disabled',
};
}
/**
* Static method to create a component instance from the mustache template.
*
* @param {string} target optional altentative DOM main element CSS selector
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.querySelector(target),
reactive: getCurrentCourseEditor(),
selectors
});
}
/**
* Initial state ready method.
*/
stateReady() {
const cancelBtn = this.getElement(this.selectors.CANCEL);
if (cancelBtn) {
this.addEventListener(cancelBtn, 'click', this._cancelBulk);
}
const selectAll = this.getElement(this.selectors.SELECTALL);
if (selectAll) {
this.addEventListener(selectAll, 'click', this._selectAllClick);
}
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `bulk.enabled:updated`, handler: this._refreshEnabled},
{watch: `bulk:updated`, handler: this._refreshTools},
];
}
/**
* Hide and show the bulk edit tools.
*
* @param {object} param
* @param {Object} param.element details the update details (state.bulk in this case).
*/
_refreshEnabled({element}) {
this._updatePageTitle(element.enabled).catch(Notification.exception);
if (element.enabled) {
enableStickyFooter();
} else {
disableStickyFooter();
}
}
/**
* Refresh the tools depending on the current selection.
*
* @param {object} param the state watcher information
* @param {Object} param.state the full state data.
* @param {Object} param.element the affected element (bulk in this case).
*/
_refreshTools(param) {
this._refreshSelectCount(param);
this._refreshSelectAll(param);
this._refreshActions(param);
}
/**
* Refresh the selection count.
*
* @param {object} param
* @param {Object} param.element the affected element (bulk in this case).
*/
async _refreshSelectCount({element: bulk}) {
const stringName = (bulk.selection.length > 1) ? 'bulkselection_plural' : 'bulkselection';
const selectedCount = await getString(stringName, 'core_courseformat', bulk.selection.length);
const selectedElement = this.getElement(this.selectors.COUNT);
if (selectedElement) {
selectedElement.innerHTML = selectedCount;
}
}
/**
* Refresh the select all element.
*
* @param {object} param
* @param {Object} param.element the affected element (bulk in this case).
*/
_refreshSelectAll({element: bulk}) {
const selectall = this.getElement(this.selectors.SELECTALL);
if (!selectall) {
return;
}
selectall.disabled = (bulk.selectedType === '');
// The changechecker module can prevent the checkbox form changing it's value.
// To avoid that we leave the sniffer to act before changing the value.
const pending = new Pending(`courseformat/bulktools:refreshSelectAll`);
setTimeout(
() => {
selectall.checked = checkAllBulkSelected(this.reactive);
pending.resolve();
},
100
);
}
/**
* Refresh the visible action buttons depending on the selection type.
*
* @param {object} param
* @param {Object} param.element the affected element (bulk in this case).
*/
_refreshActions({element: bulk}) {
// By default, we show the cm options.
const displayType = (bulk.selectedType == 'section') ? 'section' : 'cm';
const enabled = (bulk.selectedType !== '');
this.getElements(this.selectors.ACTIONS).forEach(action => {
action.classList.toggle(this.classes.DISABLED, !enabled);
action.tabIndex = (enabled) ? 0 : -1;
const actionTool = action.closest(this.selectors.ACTIONTOOL);
const isHidden = (action.dataset.bulk != displayType);
actionTool?.classList.toggle(this.classes.HIDE, isHidden);
});
}
/**
* Cancel bulk handler.
*/
_cancelBulk() {
const pending = new Pending(`courseformat/content:bulktoggle_off`);
this.reactive.dispatch('bulkEnable', false);
// Wait for a while and focus on enable bulk button.
setTimeout(() => {
document.querySelector(this.selectors.BULKBTN)?.focus();
pending.resolve();
}, 150);
}
/**
* Handle special select all cases.
* @param {Event} event
*/
_selectAllClick(event) {
event.preventDefault();
if (event.altKey) {
switchBulkSelection(this.reactive);
return;
}
if (checkAllBulkSelected(this.reactive)) {
this._handleUnselectAll();
return;
}
selectAllBulk(this.reactive, true);
}
/**
* Process unselect all elements.
*/
_handleUnselectAll() {
const pending = new Pending(`courseformat/content:bulktUnselectAll`);
selectAllBulk(this.reactive, false);
// Wait for a while and focus on the first checkbox.
setTimeout(() => {
document.querySelector(this.selectors.SELECTABLE)?.focus();
pending.resolve();
}, 150);
}
/**
* Updates the <title> attribute of the page whenever bulk editing is toggled.
*
* This helps users, especially screen reader users, to understand the current state of the course homepage.
*
* @param {Boolean} enabled True when bulk editing is turned on. False, otherwise.
* @returns {Promise<void>}
* @private
*/
async _updatePageTitle(enabled) {
const enableBulk = document.querySelector(this.selectors.BULKBTN);
let params, bulkEditTitle, editingTitle;
if (enableBulk.dataset.sectiontitle) {
// Section editing mode.
params = {
course: enableBulk.dataset.coursename,
sectionname: enableBulk.dataset.sectionname,
sectiontitle: enableBulk.dataset.sectiontitle,
};
bulkEditTitle = await getString('coursesectiontitlebulkediting', 'moodle', params);
editingTitle = await getString('coursesectiontitleediting', 'moodle', params);
} else {
// Whole course editing mode.
params = {
course: enableBulk.dataset.coursename
};
bulkEditTitle = await getString('coursetitlebulkediting', 'moodle', params);
editingTitle = await getString('coursetitleediting', 'moodle', params);
}
const pageTitle = document.title;
if (enabled) {
// Use bulk editing string for the page title.
// At this point, the current page title should be the normal editing title.
// So replace the normal editing title with the bulk editing title.
document.title = pageTitle.replace(editingTitle, bulkEditTitle);
} else {
// Use the normal editing string for the page title.
// At this point, the current page title should be the bulk editing title.
// So replace the bulk editing title with the normal editing title.
document.title = pageTitle.replace(bulkEditTitle, editingTitle);
}
}
}
@@ -0,0 +1,219 @@
// 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/>.
/**
* Course section format component.
*
* @module core_courseformat/local/content/section
* @class core_courseformat/local/content/section
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Header from 'core_courseformat/local/content/section/header';
import DndSection from 'core_courseformat/local/courseeditor/dndsection';
import Templates from 'core/templates';
import Pending from "core/pending";
export default class extends DndSection {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'content_section';
// Default query selectors.
this.selectors = {
SECTION_ITEM: `[data-for='section_title']`,
CM: `[data-for="cmitem"]`,
SECTIONINFO: `[data-for="sectioninfo"]`,
SECTIONBADGES: `[data-region="sectionbadges"]`,
SHOWSECTION: `[data-action="sectionShow"]`,
HIDESECTION: `[data-action="sectionHide"]`,
ACTIONTEXT: `.menu-action-text`,
ICON: `.icon`,
};
// Most classes will be loaded later by DndCmItem.
this.classes = {
LOCKED: 'editinprogress',
HASDESCRIPTION: 'description',
HIDE: 'd-none',
HIDDEN: 'hidden',
CURRENT: 'current',
};
// We need our id to watch specific events.
this.id = this.element.dataset.id;
}
/**
* Initial state ready method.
*
* @param {Object} state the initial state
*/
stateReady(state) {
this.configState(state);
// Drag and drop is only available for components compatible course formats.
if (this.reactive.isEditing && this.reactive.supportComponents) {
// Section zero and other formats sections may not have a title to drag.
const sectionItem = this.getElement(this.selectors.SECTION_ITEM);
if (sectionItem) {
// Init the inner dragable element.
const headerComponent = new Header({
...this,
element: sectionItem,
fullregion: this.element,
});
this.configDragDrop(headerComponent);
}
}
this._openSectionIfNecessary();
}
/**
* Open the section if the anchored activity is inside.
*/
async _openSectionIfNecessary() {
const pageCmInfo = this.reactive.getPageAnchorCmInfo();
if (!pageCmInfo || pageCmInfo.sectionid !== this.id) {
return;
}
await this.reactive.dispatch('sectionContentCollapsed', [this.id], false);
const pendingOpen = new Pending(`courseformat/section:openSectionIfNecessary`);
this.element.scrollIntoView({block: "center"});
setTimeout(() => {
this.reactive.dispatch('setPageItem', 'cm', pageCmInfo.id);
pendingOpen.resolve();
}, 250);
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `section[${this.id}]:updated`, handler: this._refreshSection},
];
}
/**
* Validate if the drop data can be dropped over the component.
*
* @param {Object} dropdata the exported drop data.
* @returns {boolean}
*/
validateDropData(dropdata) {
// If the format uses one section per page sections dropping in the content is ignored.
if (dropdata?.type === 'section' && this.reactive.sectionReturn !== null) {
return false;
}
return super.validateDropData(dropdata);
}
/**
* Get the last CM element of that section.
*
* @returns {element|null}
*/
getLastCm() {
const cms = this.getElements(this.selectors.CM);
// DndUpload may add extra elements so :last-child selector cannot be used.
if (!cms || cms.length === 0) {
return null;
}
return cms[cms.length - 1];
}
/**
* Update a content section using the state information.
*
* @param {object} param
* @param {Object} param.element details the update details.
*/
_refreshSection({element}) {
// Update classes.
this.element.classList.toggle(this.classes.DRAGGING, element.dragging ?? false);
this.element.classList.toggle(this.classes.LOCKED, element.locked ?? false);
this.element.classList.toggle(this.classes.HIDDEN, !element.visible ?? false);
this.element.classList.toggle(this.classes.CURRENT, element.current ?? false);
this.locked = element.locked;
// The description box classes depends on the section state.
const sectioninfo = this.getElement(this.selectors.SECTIONINFO);
if (sectioninfo) {
sectioninfo.classList.toggle(this.classes.HASDESCRIPTION, element.hasrestrictions);
}
// Update section badges and menus.
this._updateBadges(element);
this._updateActionsMenu(element);
}
/**
* Update a section badges using the state information.
*
* @param {object} section the section state.
*/
_updateBadges(section) {
const current = this.getElement(`${this.selectors.SECTIONBADGES} [data-type='iscurrent']`);
current?.classList.toggle(this.classes.HIDE, !section.current);
const hiddenFromStudents = this.getElement(`${this.selectors.SECTIONBADGES} [data-type='hiddenfromstudents']`);
hiddenFromStudents?.classList.toggle(this.classes.HIDE, section.visible);
}
/**
* Update a section action menus.
*
* @param {object} section the section state.
*/
async _updateActionsMenu(section) {
let selector;
let newAction;
if (section.visible) {
selector = this.selectors.SHOWSECTION;
newAction = 'sectionHide';
} else {
selector = this.selectors.HIDESECTION;
newAction = 'sectionShow';
}
// Find the affected action.
const affectedAction = this.getElement(selector);
if (!affectedAction) {
return;
}
// Change action.
affectedAction.dataset.action = newAction;
// Change text.
const actionText = affectedAction.querySelector(this.selectors.ACTIONTEXT);
if (affectedAction.dataset?.swapname && actionText) {
const oldText = actionText?.innerText;
actionText.innerText = affectedAction.dataset.swapname;
affectedAction.dataset.swapname = oldText;
}
// Change icon.
const icon = affectedAction.querySelector(this.selectors.ICON);
if (affectedAction.dataset?.swapicon && icon) {
const newIcon = affectedAction.dataset.swapicon;
affectedAction.dataset.swapicon = affectedAction.dataset.icon;
affectedAction.dataset.icon = newIcon;
if (newIcon) {
const pixHtml = await Templates.renderPix(newIcon, 'core');
Templates.replaceNode(icon, pixHtml, '');
}
}
}
}
@@ -0,0 +1,184 @@
// 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/>.
/**
* Course course module item component.
*
* This component is used to control specific course modules interactions like drag and drop.
*
* @module core_courseformat/local/content/section/cmitem
* @class core_courseformat/local/content/section/cmitem
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import DndCmItem from 'core_courseformat/local/courseeditor/dndcmitem';
export default class extends DndCmItem {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'content_section_cmitem';
// Default query selectors.
this.selectors = {
BULKSELECT: `[data-for='cmBulkSelect']`,
BULKCHECKBOX: `[data-bulkcheckbox]`,
CARD: `[data-region='activity-card']`,
DRAGICON: `.editing_move`,
INPLACEEDITABLE: `[data-inplaceeditablelink]`,
};
// Most classes will be loaded later by DndCmItem.
this.classes = {
LOCKED: 'editinprogress',
HIDE: 'd-none',
SELECTED: 'selected',
};
// We need our id to watch specific events.
this.id = this.element.dataset.id;
}
/**
* Initial state ready method.
* @param {Object} state the state data
*/
stateReady(state) {
this.configDragDrop(this.id);
this.getElement(this.selectors.DRAGICON)?.classList.add(this.classes.DRAGICON);
this._refreshBulk({state});
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `cm[${this.id}]:deleted`, handler: this.unregister},
{watch: `cm[${this.id}]:updated`, handler: this._refreshCm},
{watch: `bulk:updated`, handler: this._refreshBulk},
];
}
/**
* Return the custom activity card drag shadow image.
*
* The element returned will be used when the user drags the card.
*
* @returns {HTMLElement}
*/
setDragImage() {
return this.getElement(this.selectors.CARD);
}
/**
* Update a course index cm using the state information.
*
* @param {object} param
* @param {Object} param.element details the update details.
*/
_refreshCm({element}) {
// Update classes.
this.element.classList.toggle(this.classes.DRAGGING, element.dragging ?? false);
this.element.classList.toggle(this.classes.LOCKED, element.locked ?? false);
this.locked = element.locked;
}
/**
* Update the bulk editing interface.
*
* @param {object} param
* @param {Object} param.state the state data
*/
_refreshBulk({state}) {
const bulk = state.bulk;
// For now, dragging elements in bulk is not possible.
this.setDraggable(!bulk.enabled);
// Convert the card into an active element in bulk mode.
if (bulk.enabled) {
this.element.dataset.action = 'toggleSelectionCm';
this.element.dataset.preventDefault = 1;
} else {
this.element.removeAttribute('data-action');
this.element.removeAttribute('data-preventDefault');
}
this.getElement(this.selectors.BULKSELECT)?.classList.toggle(this.classes.HIDE, !bulk.enabled);
const disabled = !this._isCmBulkEnabled(bulk);
const selected = this._isSelected(bulk);
this._refreshActivityCard(bulk, selected);
this._setCheckboxValue(selected, disabled);
}
/**
* Update the activity card depending on the bulk selection.
*
* @param {Object} bulk the current bulk state data
* @param {Boolean} selected if the activity is selected.
*/
_refreshActivityCard(bulk, selected) {
this.getElement(this.selectors.INPLACEEDITABLE)?.classList.toggle(this.classes.HIDE, bulk.enabled);
this.getElement(this.selectors.CARD)?.classList.toggle(this.classes.SELECTED, selected);
this.element.classList.toggle(this.classes.SELECTED, selected);
}
/**
* Modify the checkbox element.
* @param {Boolean} checked the new checked value
* @param {Boolean} disabled the new disabled value
*/
_setCheckboxValue(checked, disabled) {
const checkbox = this.getElement(this.selectors.BULKCHECKBOX);
if (!checkbox) {
return;
}
checkbox.checked = checked;
checkbox.disabled = disabled;
// Is selectable is used to easily scan the page for bulk checkboxes.
if (disabled) {
checkbox.removeAttribute('data-is-selectable');
} else {
checkbox.dataset.isSelectable = 1;
}
}
/**
* Check if cm bulk selection is available.
* @param {Object} bulk the current state bulk attribute
* @returns {Boolean}
*/
_isCmBulkEnabled(bulk) {
if (!bulk.enabled) {
return false;
}
return (bulk.selectedType === '' || bulk.selectedType === 'cm');
}
/**
* Check if the cm id is part of the current bulk selection.
* @param {Object} bulk the current state bulk attribute
* @returns {Boolean}
*/
_isSelected(bulk) {
if (bulk.selectedType !== 'cm') {
return false;
}
return bulk.selection.includes(this.id);
}
}
@@ -0,0 +1,186 @@
// 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/>.
/**
* Course section header component.
*
* This component is used to control specific course section interactions like drag and drop.
*
* @module core_courseformat/local/content/section/header
* @class core_courseformat/local/content/section/header
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import DndSectionItem from 'core_courseformat/local/courseeditor/dndsectionitem';
export default class extends DndSectionItem {
/**
* Constructor hook.
*
* @param {Object} descriptor
*/
create(descriptor) {
// Optional component name for debugging.
this.name = 'content_section_header';
// Default query selectors.
this.selectors = {
ACTIONSMENU: `.section_action_menu`,
BULKSELECT: `[data-for='sectionBulkSelect']`,
BULKCHECKBOX: `[data-bulkcheckbox]`,
CHEVRON: `[data-for='sectiontoggler']`,
};
this.classes = {
HIDE: 'd-none',
SELECTED: 'selected',
};
// Get main info from the descriptor.
this.id = descriptor.id;
this.section = descriptor.section;
this.course = descriptor.course;
this.fullregion = descriptor.fullregion;
}
/**
* Initial state ready method.
*
* @param {Object} state the initial state
*/
stateReady(state) {
this.configDragDrop(this.id, state, this.fullregion);
this._refreshBulk({state});
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `bulk:updated`, handler: this._refreshBulk},
{watch: `section[${this.id}].title:updated`, handler: this._refreshSectionTitle},
];
}
/**
* Update the section when the section name changes.
*
* The section header have several HTML that uses the section name
* for accessibility and behat tests. This method updates them all.
*
* @param {object} param
* @param {Object} param.element the section info
*/
_refreshSectionTitle(param) {
const element = param.element;
this.getElement(this.selectors.CHEVRON)?.setAttribute("aria-label", element.title);
this._refreshSectionBulkSelector(param);
}
/**
* Update the bulk checkbox when the section name changes.
*
* @param {object} param
* @param {Object} param.element the section info
*/
async _refreshSectionBulkSelector({element}) {
const checkbox = this.getElement(this.selectors.BULKCHECKBOX);
if (!checkbox) {
return;
}
const newLabel = await this.reactive.getFormatString('selectsection', element.title);
checkbox.title = newLabel;
const label = this.getElement(`label[for='${checkbox.id}']`);
if (label) {
label.innerText = newLabel;
}
}
/**
* Update a bulk options.
*
* @param {object} param
* @param {Object} param.state the state data
*/
_refreshBulk({state}) {
const bulk = state.bulk;
if (!this._isSectionBulkEditable()) {
return;
}
// For now, dragging elements in bulk is not possible.
this.setDraggable(!bulk.enabled);
this.getElement(this.selectors.BULKSELECT)?.classList.toggle(this.classes.HIDE, !bulk.enabled);
const disabled = !this._isSectionBulkEnabled(bulk);
const selected = this._isSelected(bulk);
this.element.classList.toggle(this.classes.SELECTED, selected);
this._setCheckboxValue(selected, disabled);
}
/**
* Modify the checkbox element.
* @param {Boolean} checked the new checked value
* @param {Boolean} disabled the new disabled value
*/
_setCheckboxValue(checked, disabled) {
const checkbox = this.getElement(this.selectors.BULKCHECKBOX);
if (!checkbox) {
return;
}
checkbox.checked = checked;
checkbox.disabled = disabled;
// Is selectable is used to easily scan the page for bulk checkboxes.
if (disabled) {
checkbox.removeAttribute('data-is-selectable');
} else {
checkbox.dataset.isSelectable = 1;
}
}
/**
* Check if cm bulk selection is available.
* @param {Object} bulk the current state bulk attribute
* @returns {Boolean}
*/
_isSectionBulkEnabled(bulk) {
if (!bulk.enabled) {
return false;
}
return (bulk.selectedType === '' || bulk.selectedType === 'section');
}
/**
* Check if the section is bulk editable.
* @return {Boolean}
*/
_isSectionBulkEditable() {
const section = this.reactive.get('section', this.id);
return section?.bulkeditable ?? false;
}
/**
* Check if the cm id is part of the current bulk selection.
* @param {Object} bulk the current state bulk attribute
* @returns {Boolean}
*/
_isSelected(bulk) {
if (bulk.selectedType !== 'section') {
return false;
}
return bulk.selection.includes(this.id);
}
}
@@ -0,0 +1,195 @@
// 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/>.
/**
* Course index keyboard navigation and aria-tree compatibility.
*
* Node tree and bootstrap collapsibles don't use the same HTML structure. However,
* all keybindings and logic is compatible. This class translate the primitive opetations
* to a bootstrap collapsible structure.
*
* @module core_courseformat/local/courseeditor/contenttree
* @class core_courseformat/local/courseeditor/contenttree
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// The core/tree uses jQuery to expand all nodes.
import jQuery from 'jquery';
import Tree from 'core/tree';
import {getList} from 'core/normalise';
export default class extends Tree {
/**
* Setup the core/tree keyboard navigation.
*
* @param {Element|undefined} mainElement an alternative main element in case it is not from the parent component
* @param {Object|undefined} selectors alternative selectors
* @param {boolean} preventcache if the elements cache must be disabled.
*/
constructor(mainElement, selectors, preventcache) {
// Init this value with the parent DOM element.
super(mainElement);
// Get selectors from parent.
this.selectors = {
SECTION: selectors.SECTION,
TOGGLER: selectors.TOGGLER,
COLLAPSE: selectors.COLLAPSE,
ENTER: selectors.ENTER ?? selectors.TOGGLER,
};
// The core/tree library saves the visible elements cache inside the main tree node.
// However, in edit mode content can change suddenly so we need to refresh caches when needed.
if (preventcache) {
this._getVisibleItems = this.getVisibleItems;
this.getVisibleItems = () => {
this.refreshVisibleItemsCache();
return this._getVisibleItems();
};
}
// All jQuery events can be replaced when MDL-71979 is integrated.
this.treeRoot.on('hidden.bs.collapse shown.bs.collapse', () => {
this.refreshVisibleItemsCache();
});
// Register a custom callback for pressing enter key.
this.registerEnterCallback(this.enterCallback.bind(this));
}
/**
* Return the current active node.
*
* @return {Element|undefined} the active item if any
*/
getActiveItem() {
const activeItem = this.treeRoot.data('activeItem');
if (activeItem) {
return getList(activeItem)[0];
}
return undefined;
}
/**
* Handle enter key on a collpasible node.
*
* @param {JQuery} jQueryItem the jQuery object
*/
enterCallback(jQueryItem) {
const item = getList(jQueryItem)[0];
if (this.isGroupItem(jQueryItem)) {
// Group elements is like clicking a topic but without loosing the focus.
const enter = item.querySelector(this.selectors.ENTER);
if (enter.getAttribute('href') !== '#') {
window.location.href = enter.getAttribute('href');
}
enter.click();
} else {
// Activity links just follow the link href.
const link = item.querySelector('a');
if (link.getAttribute('href') !== '#') {
window.location.href = link.getAttribute('href');
} else {
link.click();
}
return;
}
}
/**
* Handle an item click.
*
* @param {Event} event the click event
* @param {jQuery} jQueryItem the item clicked
*/
handleItemClick(event, jQueryItem) {
const isChevron = event.target.closest(this.selectors.COLLAPSE);
// Only chevron clicks toogle the sections always.
if (isChevron) {
super.handleItemClick(event, jQueryItem);
return;
}
// This is a title or activity name click.
jQueryItem.focus();
if (this.isGroupItem(jQueryItem)) {
this.expandGroup(jQueryItem);
}
}
/**
* Check if a gorup item is collapsed.
*
* @param {JQuery} jQueryItem the jQuery object
* @returns {boolean} if the element is collapsed
*/
isGroupCollapsed(jQueryItem) {
const item = getList(jQueryItem)[0];
const toggler = item.querySelector(`[aria-expanded]`);
return toggler.getAttribute('aria-expanded') === 'false';
}
/**
* Toggle a group item.
*
* @param {JQuery} item the jQuery object
*/
toggleGroup(item) {
// All jQuery in this segment of code can be replaced when MDL-71979 is integrated.
const toggler = item.find(this.selectors.COLLAPSE);
let collapsibleId = toggler.data('target') ?? toggler.attr('href');
if (!collapsibleId) {
return;
}
collapsibleId = collapsibleId.replace('#', '');
// Bootstrap 4 uses jQuery to interact with collapsibles.
const collapsible = jQuery(`#${collapsibleId}`);
if (collapsible.length) {
jQuery(`#${collapsibleId}`).collapse('toggle');
}
}
/**
* Expand a group item.
*
* @param {JQuery} item the jQuery object
*/
expandGroup(item) {
if (this.isGroupCollapsed(item)) {
this.toggleGroup(item);
}
}
/**
* Collpase a group item.
*
* @param {JQuery} item the jQuery object
*/
collapseGroup(item) {
if (!this.isGroupCollapsed(item)) {
this.toggleGroup(item);
}
}
/**
* Expand all groups.
*/
expandAllGroups() {
const togglers = getList(this.treeRoot)[0].querySelectorAll(this.selectors.SECTION);
togglers.forEach(item => {
this.expandGroup(jQuery(item));
});
}
}
@@ -0,0 +1,390 @@
// 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 {getString} from 'core/str';
import {Reactive} from 'core/reactive';
import notification from 'core/notification';
import Exporter from 'core_courseformat/local/courseeditor/exporter';
import log from 'core/log';
import ajax from 'core/ajax';
import * as Storage from 'core/sessionstorage';
import {uploadFilesToCourse} from 'core_courseformat/local/courseeditor/fileuploader';
/**
* Main course editor module.
*
* All formats can register new components on this object to create new reactive
* UI components that watch the current course state.
*
* @module core_courseformat/local/courseeditor/courseeditor
* @class core_courseformat/local/courseeditor/courseeditor
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class extends Reactive {
/**
* The current state cache key
*
* The state cache is considered dirty if the state changes from the last page or
* if the page has editing mode on.
*
* @attribute stateKey
* @type number|null
* @default 1
* @package
*/
stateKey = 1;
/**
* The current page section return
* @attribute sectionReturn
* @type number
* @default null
*/
sectionReturn = null;
/**
* Set up the course editor when the page is ready.
*
* The course can only be loaded once per instance. Otherwise an error is thrown.
*
* The backend can inform the module of the current state key. This key changes every time some
* update in the course affect the current user state. Some examples are:
* - The course content has been edited
* - The user marks some activity as completed
* - The user collapses or uncollapses a section (it is stored as a user preference)
*
* @param {number} courseId course id
* @param {string} serverStateKey the current backend course cache reference
*/
async loadCourse(courseId, serverStateKey) {
if (this.courseId) {
throw new Error(`Cannot load ${courseId}, course already loaded with id ${this.courseId}`);
}
if (!serverStateKey) {
// The server state key is not provided, we use a invalid statekey to force reloading.
serverStateKey = `invalidStateKey_${Date.now()}`;
}
// Default view format setup.
this._editing = false;
this._supportscomponents = false;
this._fileHandlers = null;
this.courseId = courseId;
let stateData;
const storeStateKey = Storage.get(`course/${courseId}/stateKey`);
try {
// Check if the backend state key is the same we have in our session storage.
if (!this.isEditing && serverStateKey == storeStateKey) {
stateData = JSON.parse(Storage.get(`course/${courseId}/staticState`));
}
if (!stateData) {
stateData = await this.getServerCourseState();
}
} catch (error) {
log.error("EXCEPTION RAISED WHILE INIT COURSE EDITOR");
log.error(error);
return;
}
// The bulk editing only applies to the frontend and the state data is not created in the backend.
stateData.bulk = {
enabled: false,
selectedType: '',
selection: [],
};
this.setInitialState(stateData);
// In editing mode, the session cache is considered dirty always.
if (this.isEditing) {
this.stateKey = null;
} else {
// Check if the last state is the same as the cached one.
const newState = JSON.stringify(stateData);
const previousState = Storage.get(`course/${courseId}/staticState`);
if (previousState !== newState || storeStateKey !== serverStateKey) {
Storage.set(`course/${courseId}/staticState`, newState);
Storage.set(`course/${courseId}/stateKey`, stateData?.course?.statekey ?? serverStateKey);
}
this.stateKey = Storage.get(`course/${courseId}/stateKey`);
}
this._loadFileHandlers();
this._pageAnchorCmInfo = this._scanPageAnchorCmInfo();
}
/**
* Load the file hanlders promise.
*/
_loadFileHandlers() {
// Load the course file extensions.
this._fileHandlersPromise = new Promise((resolve) => {
if (!this.isEditing) {
resolve([]);
return;
}
// Check the cache.
const handlersCacheKey = `course/${this.courseId}/fileHandlers`;
const cacheValue = Storage.get(handlersCacheKey);
if (cacheValue) {
try {
const cachedHandlers = JSON.parse(cacheValue);
resolve(cachedHandlers);
return;
} catch (error) {
log.error("ERROR PARSING CACHED FILE HANDLERS");
}
}
// Call file handlers webservice.
ajax.call([{
methodname: 'core_courseformat_file_handlers',
args: {
courseid: this.courseId,
}
}])[0].then((handlers) => {
Storage.set(handlersCacheKey, JSON.stringify(handlers));
resolve(handlers);
return;
}).catch(error => {
log.error(error);
resolve([]);
return;
});
});
}
/**
* Setup the current view settings
*
* @param {Object} setup format, page and course settings
* @param {boolean} setup.editing if the page is in edit mode
* @param {boolean} setup.supportscomponents if the format supports components for content
* @param {string} setup.cacherev the backend cached state revision
* @param {Array} setup.overriddenStrings optional overridden strings
*/
setViewFormat(setup) {
this._editing = setup.editing ?? false;
this._supportscomponents = setup.supportscomponents ?? false;
const overriddenStrings = setup.overriddenStrings ?? [];
this._overriddenStrings = overriddenStrings.reduce(
(indexed, currentValue) => indexed.set(currentValue.key, currentValue),
new Map()
);
}
/**
* Execute a get string for a possible format overriden editor string.
*
* Return the proper getString promise for an editor string using the core_courseformat
* of the format_PLUGINNAME compoment depending on the current view format setup.
* @param {String} key the string key
* @param {string|undefined} param The param for variable expansion in the string.
* @returns {Promise<String>} a getString promise
*/
getFormatString(key, param) {
if (this._overriddenStrings.has(key)) {
const override = this._overriddenStrings.get(key);
return getString(key, override.component ?? 'core_courseformat', param);
}
// All format overridable strings are from core_courseformat lang file.
return getString(key, 'core_courseformat', param);
}
/**
* Load the current course state from the server.
*
* @returns {Object} the current course state
*/
async getServerCourseState() {
const courseState = await ajax.call([{
methodname: 'core_courseformat_get_state',
args: {
courseid: this.courseId,
}
}])[0];
const stateData = JSON.parse(courseState);
return {
course: {},
section: [],
cm: [],
...stateData,
};
}
/**
* Return the current edit mode.
*
* Components should use this method to check if edit mode is active.
*
* @return {boolean} if edit is enabled
*/
get isEditing() {
return this._editing ?? false;
}
/**
* Return a data exporter to transform state part into mustache contexts.
*
* @return {Exporter} the exporter class
*/
getExporter() {
return new Exporter(this);
}
/**
* Return if the current course support components to refresh the content.
*
* @returns {boolean} if the current content support components
*/
get supportComponents() {
return this._supportscomponents ?? false;
}
/**
* Return the course file handlers promise.
* @returns {Promise} the promise for file handlers.
*/
async getFileHandlersPromise() {
return this._fileHandlersPromise ?? [];
}
/**
* Upload a file list to the course.
*
* This method is a wrapper to the course file uploader.
*
* @param {number} sectionId the section id
* @param {number} sectionNum the section number
* @param {Array} files and array of files
* @return {Promise} the file queue promise
*/
uploadFiles(sectionId, sectionNum, files) {
return uploadFilesToCourse(this.courseId, sectionId, sectionNum, files);
}
/**
* Get a value from the course editor static storage if any.
*
* The course editor static storage uses the sessionStorage to store values from the
* components. This is used to prevent unnecesary template loadings on every page. However,
* the storage does not work if no sessionStorage can be used (in debug mode for example),
* if the page is in editing mode or if the initial state change from the last page.
*
* @param {string} key the key to get
* @return {boolean|string} the storage value or false if cannot be loaded
*/
getStorageValue(key) {
if (this.isEditing || !this.stateKey) {
return false;
}
const dataJson = Storage.get(`course/${this.courseId}/${key}`);
if (!dataJson) {
return false;
}
// Check the stateKey.
try {
const data = JSON.parse(dataJson);
if (data?.stateKey !== this.stateKey) {
return false;
}
return data.value;
} catch (error) {
return false;
}
}
/**
* Stores a value into the course editor static storage if available
*
* @param {String} key the key to store
* @param {*} value the value to store (must be compatible with JSON,stringify)
* @returns {boolean} true if the value is stored
*/
setStorageValue(key, value) {
// Values cannot be stored on edit mode.
if (this.isEditing) {
return false;
}
const data = {
stateKey: this.stateKey,
value,
};
return Storage.set(`course/${this.courseId}/${key}`, JSON.stringify(data));
}
/**
* Convert a file dragging event into a proper dragging file list.
* @param {DataTransfer} dataTransfer the event to convert
* @return {Array} of file list info.
*/
getFilesDraggableData(dataTransfer) {
const exporter = this.getExporter();
return exporter.fileDraggableData(this.state, dataTransfer);
}
/**
* Dispatch a change in the state.
*
* Usually reactive modules throw an error directly to the components when something
* goes wrong. However, course editor can directly display a notification.
*
* @method dispatch
* @param {mixed} args any number of params the mutation needs.
*/
async dispatch(...args) {
try {
await super.dispatch(...args);
} catch (error) {
// Display error modal.
notification.exception(error);
// Force unlock all elements.
super.dispatch('unlockAll');
}
}
/**
* Calculate the cm info from the current page anchor.
*
* @returns {Object|null} the cm info or null if not found.
*/
_scanPageAnchorCmInfo() {
const anchor = new URL(window.location.href).hash;
if (!anchor.startsWith('#module-')) {
return null;
}
// The anchor is always #module-CMID.
const cmid = anchor.split('-')[1];
return this.stateManager.get('cm', parseInt(cmid));
}
/**
* Return the current page anchor cm info.
*/
getPageAnchorCmInfo() {
return this._pageAnchorCmInfo;
}
}
@@ -0,0 +1,153 @@
// 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/>.
/**
* Course index cm component.
*
* This component is used to control specific course modules interactions like drag and drop
* in both course index and course content.
*
* @module core_courseformat/local/courseeditor/dndcmitem
* @class core_courseformat/local/courseeditor/dndcmitem
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent, DragDrop} from 'core/reactive';
export default class extends BaseComponent {
/**
* Configure the component drag and drop.
*
* @param {number} cmid course module id
*/
configDragDrop(cmid) {
this.id = cmid;
// Drag and drop is only available for components compatible course formats.
if (this.reactive.isEditing && this.reactive.supportComponents) {
// Init element drag and drop.
this.dragdrop = new DragDrop(this);
// Save dropzone classes.
this.classes = this.dragdrop.getClasses();
}
}
/**
* Remove all subcomponents dependencies.
*/
destroy() {
if (this.dragdrop !== undefined) {
this.dragdrop.unregister();
}
}
/**
* Enable or disable the draggable property.
*
* @param {bool} value the new draggable value
*/
setDraggable(value) {
this.dragdrop?.setDraggable(value);
}
// Drag and drop methods.
/**
* The element drop start hook.
*
* @param {Object} dropdata the dropdata
*/
dragStart(dropdata) {
this.reactive.dispatch('cmDrag', [dropdata.id], true);
}
/**
* The element drop end hook.
*
* @param {Object} dropdata the dropdata
*/
dragEnd(dropdata) {
this.reactive.dispatch('cmDrag', [dropdata.id], false);
}
/**
* Get the draggable data of this component.
*
* @returns {Object} exported course module drop data
*/
getDraggableData() {
const exporter = this.reactive.getExporter();
return exporter.cmDraggableData(this.reactive.state, this.id);
}
/**
* Validate if the drop data can be dropped over the component.
*
* @param {Object} dropdata the exported drop data.
* @returns {boolean}
*/
validateDropData(dropdata) {
if (dropdata?.type !== 'cm') {
return false;
}
// Prevent delegated sections loops.
if (dropdata?.delegatesection === true) {
const mycminfo = this.reactive.get('cm', this.id);
const mysection = this.reactive.get('section', mycminfo.sectionid);
if (mysection?.component !== null) {
return false;
}
}
return true;
}
/**
* Display the component dropzone.
*
* @param {Object} dropdata the accepted drop data
*/
showDropZone(dropdata) {
// If we are the next cmid of the dragged element we accept the drop because otherwise it
// will get captured by the section. However, we won't trigger any mutation.
if (dropdata.nextcmid != this.id && dropdata.id != this.id) {
this.element.classList.add(this.classes.DROPUP);
}
}
/**
* Hide the component dropzone.
*/
hideDropZone() {
this.element.classList.remove(this.classes.DROPUP);
}
/**
* Drop event handler.
*
* @param {Object} dropdata the accepted drop data
* @param {Event} event the drop event
*/
drop(dropdata, event) {
// Call the move mutation if necessary.
if (dropdata.id != this.id && dropdata.nextcmid != this.id) {
const mutation = (event.altKey) ? 'cmDuplicate' : 'cmMove';
this.reactive.dispatch(mutation, [dropdata.id], null, this.id);
}
}
}
@@ -0,0 +1,196 @@
// 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/>.
/**
* Course index section component.
*
* This component is used to control specific course section interactions like drag and drop
* in both course index and course content.
*
* @module core_courseformat/local/courseeditor/dndsection
* @class core_courseformat/local/courseeditor/dndsection
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent, DragDrop} from 'core/reactive';
import {getString} from 'core/str';
import {prefetchStrings} from 'core/prefetch';
import Templates from 'core/templates';
// Load global strings.
prefetchStrings('core', ['addfilehere']);
export default class extends BaseComponent {
/**
* Save some values form the state.
*
* @param {Object} state the current state
*/
configState(state) {
this.id = this.element.dataset.id;
this.section = state.section.get(this.id);
this.course = state.course;
}
/**
* Register state values and the drag and drop subcomponent.
*
* @param {BaseComponent} sectionitem section item component
*/
configDragDrop(sectionitem) {
// Drag and drop is only available for components compatible course formats.
if (this.reactive.isEditing && this.reactive.supportComponents) {
// Init the inner dragable element.
this.sectionitem = sectionitem;
// Init the dropzone.
this.dragdrop = new DragDrop(this);
// Save dropzone classes.
this.classes = this.dragdrop.getClasses();
}
}
/**
* Remove all subcomponents dependencies.
*/
destroy() {
if (this.sectionitem !== undefined) {
this.sectionitem.unregister();
}
if (this.dragdrop !== undefined) {
this.dragdrop.unregister();
}
}
/**
* Get the last CM element of that section.
*
* @returns {element|null} the las course module element of the section.
*/
getLastCm() {
return null;
}
// Drag and drop methods.
/**
* The element drop start hook.
*
* @param {Object} dropdata the dropdata
*/
dragStart(dropdata) {
this.reactive.dispatch('sectionDrag', [dropdata.id], true);
}
/**
* The element drop end hook.
*
* @param {Object} dropdata the dropdata
*/
dragEnd(dropdata) {
this.reactive.dispatch('sectionDrag', [dropdata.id], false);
}
/**
* Validate if the drop data can be dropped over the component.
*
* @param {Object} dropdata the exported drop data.
* @returns {boolean}
*/
validateDropData(dropdata) {
// We accept files.
if (dropdata?.type === 'files') {
return true;
}
// We accept any course module unless it can form a subsection loop.
if (dropdata?.type === 'cm') {
if (this.section?.component && dropdata?.delegatesection === true) {
return false;
}
return true;
}
// We accept any section but yourself and the next one.
if (dropdata?.type === 'section') {
return dropdata?.id != this.id && dropdata?.number != this.section.number + 1;
}
return false;
}
/**
* Display the component dropzone.
*
* @param {Object} dropdata the accepted drop data
*/
showDropZone(dropdata) {
if (dropdata.type == 'files') {
this.addOverlay({
content: getString('addfilehere', 'core'),
icon: Templates.renderPix('t/download', 'core'),
}).then(() => {
// Check if we still need the file dropzone.
if (!this.dragdrop?.isDropzoneVisible()) {
this.removeOverlay();
}
return;
}).catch((error) => {
throw error;
});
}
if (dropdata.type == 'cm') {
this.getLastCm()?.classList.add(this.classes.DROPDOWN);
}
if (dropdata.type == 'section') {
this.element.classList.remove(this.classes.DROPUP);
this.element.classList.add(this.classes.DROPDOWN);
}
}
/**
* Hide the component dropzone.
*/
hideDropZone() {
this.getLastCm()?.classList.remove(this.classes.DROPDOWN);
this.element.classList.remove(this.classes.DROPUP);
this.element.classList.remove(this.classes.DROPDOWN);
this.removeOverlay();
}
/**
* Drop event handler.
*
* @param {Object} dropdata the accepted drop data
* @param {Event} event the drop event
*/
drop(dropdata, event) {
// File handling.
if (dropdata.type == 'files') {
this.reactive.uploadFiles(
this.section.id,
this.section.number,
dropdata.files
);
return;
}
// Call the move mutation.
if (dropdata.type == 'cm') {
const mutation = (event.altKey) ? 'cmDuplicate' : 'cmMove';
this.reactive.dispatch(mutation, [dropdata.id], this.id);
}
if (dropdata.type == 'section') {
this.reactive.dispatch('sectionMoveAfter', [dropdata.id], this.id);
}
}
}
@@ -0,0 +1,162 @@
// 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/>.
/**
* Course index section title draggable component.
*
* This component is used to control specific course section interactions like drag and drop
* in both course index and course content.
*
* @module core_courseformat/local/courseeditor/dndsectionitem
* @class core_courseformat/local/courseeditor/dndsectionitem
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent, DragDrop} from 'core/reactive';
export default class extends BaseComponent {
/**
* Initial state ready method.
*
* @param {number} sectionid the section id
* @param {Object} state the initial state
* @param {Element} fullregion the complete section region to mark as dragged
*/
configDragDrop(sectionid, state, fullregion) {
this.id = sectionid;
if (this.section === undefined) {
this.section = state.section.get(this.id);
}
if (this.course === undefined) {
this.course = state.course;
}
// Prevent topic zero from being draggable.
if (this.section.number > 0) {
this.getDraggableData = this._getDraggableData;
}
this.fullregion = fullregion;
// Drag and drop is only available for components compatible course formats.
if (this.reactive.isEditing && this.reactive.supportComponents) {
// Init the dropzone.
this.dragdrop = new DragDrop(this);
// Save dropzone classes.
this.classes = this.dragdrop.getClasses();
}
}
/**
* Remove all subcomponents dependencies.
*/
destroy() {
if (this.dragdrop !== undefined) {
this.dragdrop.unregister();
}
}
/**
* Enable or disable the draggable property.
*
* @param {bool} value the new draggable value
*/
setDraggable(value) {
if (this.getDraggableData) {
this.dragdrop?.setDraggable(value);
}
}
// Drag and drop methods.
/**
* The element drop start hook.
*
* @param {Object} dropdata the dropdata
*/
dragStart(dropdata) {
this.reactive.dispatch('sectionDrag', [dropdata.id], true);
}
/**
* The element end start hook.
*
* @param {Object} dropdata the dropdata
*/
dragEnd(dropdata) {
this.reactive.dispatch('sectionDrag', [dropdata.id], false);
}
/**
* Get the draggable data of this component.
*
* @returns {Object} exported course module drop data
*/
_getDraggableData() {
const exporter = this.reactive.getExporter();
return exporter.sectionDraggableData(this.reactive.state, this.id);
}
/**
* Validate if the drop data can be dropped over the component.
*
* @param {Object} dropdata the exported drop data.
* @returns {boolean}
*/
validateDropData(dropdata) {
// Course module validation.
if (dropdata?.type === 'cm') {
// Prevent content loops with subsections.
if (this.section?.component && dropdata?.delegatesection === true) {
return false;
}
// The first section element is already there so we can ignore it.
const firstcmid = this.section?.cmlist[0];
return dropdata.id !== firstcmid;
}
return false;
}
/**
* Display the component dropzone.
*/
showDropZone() {
this.element.classList.add(this.classes.DROPZONE);
}
/**
* Hide the component dropzone.
*/
hideDropZone() {
this.element.classList.remove(this.classes.DROPZONE);
}
/**
* Drop event handler.
*
* @param {Object} dropdata the accepted drop data
* @param {Event} event the drop event
*/
drop(dropdata, event) {
// Call the move mutation.
if (dropdata.type == 'cm') {
const mutation = (event.altKey) ? 'cmDuplicate' : 'cmMove';
this.reactive.dispatch(mutation, [dropdata.id], this.id, this.section?.cmlist[0]);
}
}
}
@@ -0,0 +1,258 @@
// 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 export parts of the state and transform them to be used in templates
* and as draggable data.
*
* @module core_courseformat/local/courseeditor/exporter
* @class core_courseformat/local/courseeditor/exporter
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class {
/**
* Class constructor.
*
* @param {CourseEditor} reactive the course editor object
*/
constructor(reactive) {
this.reactive = reactive;
// Completions states are defined in lib/completionlib.php. There are 4 different completion
// state values, however, the course index uses the same state for complete and complete_pass.
// This is the reason why completed appears twice in the array.
this.COMPLETIONS = ['incomplete', 'complete', 'complete', 'fail'];
}
/**
* Generate the course export data from the state.
*
* @param {Object} state the current state.
* @returns {Object}
*/
course(state) {
// Collect section information from the state.
const data = {
sections: [],
editmode: this.reactive.isEditing,
highlighted: state.course.highlighted ?? '',
};
const sectionlist = this.listedSectionIds(state);
sectionlist.forEach(sectionid => {
const sectioninfo = state.section.get(sectionid) ?? {};
const section = this.section(state, sectioninfo);
data.sections.push(section);
});
data.hassections = (data.sections.length != 0);
return data;
}
/**
* Get the IDs of the sections that are listed as regular sections.
* @param {Object} state the current state.
* @returns {Number[]} the list of section ids that are listed.
*/
listedSectionIds(state) {
const fullSectionList = state.course.sectionlist ?? [];
return fullSectionList.filter(sectionid => {
const sectioninfo = state.section.get(sectionid) ?? {};
// Delegated sections (controlled by a component) are not listed in course.
return sectioninfo.component === null;
});
}
/**
* Generate a section export data from the state.
*
* @param {Object} state the current state.
* @param {Object} sectioninfo the section state data.
* @returns {Object}
*/
section(state, sectioninfo) {
const section = {
...sectioninfo,
highlighted: state.course.highlighted ?? '',
cms: [],
};
const cmlist = sectioninfo.cmlist ?? [];
cmlist.forEach(cmid => {
const cminfo = state.cm.get(cmid);
const cm = this.cm(state, cminfo);
section.cms.push(cm);
});
section.hascms = (section.cms.length != 0);
return section;
}
/**
* Generate a cm export data from the state.
*
* @param {Object} state the current state.
* @param {Object} cminfo the course module state data.
* @returns {Object}
*/
cm(state, cminfo) {
const cm = {
...cminfo,
isactive: false,
};
return cm;
}
/**
* Generate a dragable cm data structure.
*
* This method is used by any draggable course module element to generate drop data
* for its reactive/dragdrop instance.
*
* @param {*} state the state object
* @param {*} cmid the cours emodule id
* @returns {Object|null}
*/
cmDraggableData(state, cmid) {
const cminfo = state.cm.get(cmid);
if (!cminfo) {
return null;
}
// Drop an activity over the next activity is the same as doing anything.
let nextcmid;
const section = state.section.get(cminfo.sectionid);
const currentindex = section?.cmlist.indexOf(cminfo.id);
if (currentindex !== undefined) {
nextcmid = section?.cmlist[currentindex + 1];
}
return {
type: 'cm',
id: cminfo.id,
name: cminfo.name,
sectionid: cminfo.sectionid,
delegatesection: cminfo.delegatesection,
nextcmid,
};
}
/**
* Generate a dragable cm data structure.
*
* This method is used by any draggable section element to generate drop data
* for its reactive/dragdrop instance.
*
* @param {*} state the state object
* @param {*} sectionid the cours section id
* @returns {Object|null}
*/
sectionDraggableData(state, sectionid) {
const sectioninfo = state.section.get(sectionid);
if (!sectioninfo) {
return null;
}
return {
type: 'section',
id: sectioninfo.id,
name: sectioninfo.name,
number: sectioninfo.number,
};
}
/**
* Generate a file draggable structure.
*
* This method is used when files are dragged on the browser.
*
* @param {*} state the state object
* @param {*} dataTransfer the current data tranfer data
* @returns {Object|null}
*/
fileDraggableData(state, dataTransfer) {
const files = [];
// Browsers do not provide the file list until the drop event.
if (dataTransfer.files?.length > 0) {
dataTransfer.files.forEach(file => {
files.push(file);
});
}
return {
type: 'files',
files,
};
}
/**
* Generate a completion export data from the cm element.
*
* @param {Object} state the current state.
* @param {Object} cminfo the course module state data.
* @returns {Object}
*/
cmCompletion(state, cminfo) {
const data = {
statename: '',
state: 'NaN',
};
if (cminfo.completionstate !== undefined) {
data.state = cminfo.completionstate;
data.hasstate = true;
let statename = this.COMPLETIONS[cminfo.completionstate] ?? 'NaN';
if (cminfo.isoverallcomplete !== undefined && cminfo.isoverallcomplete === true) {
statename = 'complete';
}
data[`is${statename}`] = true;
}
return data;
}
/**
* Return a sorted list of all sections and cms items in the state.
*
* @param {Object} state the current state.
* @returns {Array} all sections and cms items in the state.
*/
allItemsArray(state) {
const items = [];
const sectionlist = state.course.sectionlist ?? [];
// Add sections.
sectionlist.forEach(sectionid => {
const sectioninfo = state.section.get(sectionid);
items.push({type: 'section', id: sectioninfo.id, url: sectioninfo.sectionurl});
// Add cms.
const cmlist = sectioninfo.cmlist ?? [];
cmlist.forEach(cmid => {
const cminfo = state.cm.get(cmid);
items.push({type: 'cm', id: cminfo.id, url: cminfo.url});
});
});
return items;
}
/**
* Check is some activities of a list can be stealth.
*
* @param {Object} state the current state.
* @param {Number[]} cmIds the module ids to check
* @returns {Boolean} if any of the activities can be stealth.
*/
canUseStealth(state, cmIds) {
return cmIds.some(cmId => {
const cminfo = state.cm.get(cmId);
return cminfo?.allowstealth ?? false;
});
}
}
@@ -0,0 +1,548 @@
// 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/>.
/**
* The course file uploader.
*
* This module is used to upload files directly into the course.
*
* @module core_courseformat/local/courseeditor/fileuploader
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* @typedef {Object} Handler
* @property {String} extension the handled extension or * for any
* @property {String} message the handler message
* @property {String} module the module name
*/
import Config from 'core/config';
import ModalSaveCancel from 'core/modal_save_cancel';
import ModalEvents from 'core/modal_events';
import Templates from 'core/templates';
import {getFirst} from 'core/normalise';
import {prefetchStrings} from 'core/prefetch';
import {getString, getStrings} from 'core/str';
import {getCourseEditor} from 'core_courseformat/courseeditor';
import {processMonitor} from 'core/process_monitor';
import {debounce} from 'core/utils';
// Uploading url.
const UPLOADURL = Config.wwwroot + '/course/dndupload.php';
const DEBOUNCETIMER = 500;
const USERCANIGNOREFILESIZELIMITS = -1;
/** @var {ProcessQueue} uploadQueue the internal uploadQueue instance. */
let uploadQueue = null;
/** @var {Object} handlerManagers the courseId indexed loaded handler managers. */
let handlerManagers = {};
/** @var {Map} courseUpdates the pending course sections updates. */
let courseUpdates = new Map();
/** @var {Object} errors the error messages. */
let errors = null;
// Load global strings.
prefetchStrings('moodle', ['addresourceoractivity', 'upload']);
prefetchStrings('core_error', ['dndmaxbytes', 'dndread', 'dndupload', 'dndunkownfile']);
/**
* Class to upload a file into the course.
* @private
*/
class FileUploader {
/**
* Class constructor.
*
* @param {number} courseId the course id
* @param {number} sectionId the section id
* @param {number} sectionNum the section number
* @param {File} fileInfo the file information object
* @param {Handler} handler the file selected file handler
*/
constructor(courseId, sectionId, sectionNum, fileInfo, handler) {
this.courseId = courseId;
this.sectionId = sectionId;
this.sectionNum = sectionNum;
this.fileInfo = fileInfo;
this.handler = handler;
}
/**
* Execute the file upload and update the state in the given process.
*
* @param {LoadingProcess} process the process to store the upload result
*/
execute(process) {
const fileInfo = this.fileInfo;
const xhr = this._createXhrRequest(process);
const formData = this._createUploadFormData();
// Try reading the file to check it is not a folder, before sending it to the server.
const reader = new FileReader();
reader.onload = function() {
// File was read OK - send it to the server.
xhr.open("POST", UPLOADURL, true);
xhr.send(formData);
};
reader.onerror = function() {
// Unable to read the file (it is probably a folder) - display an error message.
process.setError(errors.dndread);
};
if (fileInfo.size > 0) {
// If this is a non-empty file, try reading the first few bytes.
// This will trigger reader.onerror() for folders and reader.onload() for ordinary, readable files.
reader.readAsText(fileInfo.slice(0, 5));
} else {
// If you call slice() on a 0-byte folder, before calling readAsText, then Firefox triggers reader.onload(),
// instead of reader.onerror().
// So, for 0-byte files, just call readAsText on the whole file (and it will trigger load/error functions as expected).
reader.readAsText(fileInfo);
}
}
/**
* Returns the bind version of execute function.
*
* This method is used to queue the process into a ProcessQueue instance.
*
* @returns {Function} the bind function to execute the process
*/
getExecutionFunction() {
return this.execute.bind(this);
}
/**
* Generate a upload XHR file request.
*
* @param {LoadingProcess} process the current process
* @return {XMLHttpRequest} the XHR request
*/
_createXhrRequest(process) {
const xhr = new XMLHttpRequest();
// Update the progress bar as the file is uploaded.
xhr.upload.addEventListener(
'progress',
(event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded * 100) / event.total);
process.setPercentage(percent);
}
},
false
);
// Wait for the AJAX call to complete.
xhr.onreadystatechange = () => {
if (xhr.readyState == 1) {
// Add a 1% just to indicate that it is uploading.
process.setPercentage(1);
}
// State 4 is DONE. Otherwise the connection is still ongoing.
if (xhr.readyState != 4) {
return;
}
if (xhr.status == 200) {
var result = JSON.parse(xhr.responseText);
if (result && result.error == 0) {
// All OK.
this._finishProcess(process);
} else {
process.setError(result.error);
}
} else {
process.setError(errors.dndupload);
}
};
return xhr;
}
/**
* Upload a file into the course.
*
* @return {FormData|null} the new form data object
*/
_createUploadFormData() {
const formData = new FormData();
try {
formData.append('repo_upload_file', this.fileInfo);
} catch (error) {
throw Error(error.dndread);
}
formData.append('sesskey', Config.sesskey);
formData.append('course', this.courseId);
formData.append('section', this.sectionNum);
formData.append('module', this.handler.module);
formData.append('type', 'Files');
return formData;
}
/**
* Finishes the current process.
* @param {LoadingProcess} process the process
*/
_finishProcess(process) {
addRefreshSection(this.courseId, this.sectionId);
process.setPercentage(100);
process.finish();
}
}
/**
* The file handler manager class.
*
* @private
*/
class HandlerManager {
/** @var {Object} lastHandlers the last handlers selected per each file extension. */
lastHandlers = {};
/** @var {Handler[]|null} allHandlers all the available handlers. */
allHandlers = null;
/**
* Class constructor.
*
* @param {Number} courseId
*/
constructor(courseId) {
this.courseId = courseId;
this.lastUploadId = 0;
this.courseEditor = getCourseEditor(courseId);
if (!this.courseEditor) {
throw Error('Unkown course editor');
}
this.maxbytes = this.courseEditor.get('course')?.maxbytes ?? 0;
}
/**
* Load the course file handlers.
*/
async loadHandlers() {
this.allHandlers = await this.courseEditor.getFileHandlersPromise();
}
/**
* Extract the file extension from a fileInfo.
*
* @param {File} fileInfo
* @returns {String} the file extension or an empty string.
*/
getFileExtension(fileInfo) {
let extension = '';
const dotpos = fileInfo.name.lastIndexOf('.');
if (dotpos != -1) {
extension = fileInfo.name.substring(dotpos + 1, fileInfo.name.length).toLowerCase();
}
return extension;
}
/**
* Check if the file is valid.
*
* @param {File} fileInfo the file info
*/
validateFile(fileInfo) {
if (this.maxbytes !== USERCANIGNOREFILESIZELIMITS && fileInfo.size > this.maxbytes) {
throw Error(errors.dndmaxbytes);
}
}
/**
* Get the file handlers of an specific file.
*
* @param {File} fileInfo the file indo
* @return {Array} Array of handlers
*/
filterHandlers(fileInfo) {
const extension = this.getFileExtension(fileInfo);
return this.allHandlers.filter(handler => handler.extension == '*' || handler.extension == extension);
}
/**
* Get the Handler to upload a specific file.
*
* It will ask the used if more than one handler is available.
*
* @param {File} fileInfo the file info
* @returns {Promise<Handler|null>} the selected handler or null if the user cancel
*/
async getFileHandler(fileInfo) {
const fileHandlers = this.filterHandlers(fileInfo);
if (fileHandlers.length == 0) {
throw Error(errors.dndunkownfile);
}
let fileHandler = null;
if (fileHandlers.length == 1) {
fileHandler = fileHandlers[0];
} else {
fileHandler = await this.askHandlerToUser(fileHandlers, fileInfo);
}
return fileHandler;
}
/**
* Ask the user to select a specific handler.
*
* @param {Handler[]} fileHandlers
* @param {File} fileInfo the file info
* @return {Promise<Handler>} the selected handler
*/
async askHandlerToUser(fileHandlers, fileInfo) {
const extension = this.getFileExtension(fileInfo);
// Build the modal parameters from the event data.
const modalParams = {
title: getString('addresourceoractivity', 'moodle'),
body: Templates.render(
'core_courseformat/fileuploader',
this.getModalData(
fileHandlers,
fileInfo,
this.lastHandlers[extension] ?? null
)
),
saveButtonText: getString('upload', 'moodle'),
};
// Create the modal.
const modal = await this.modalBodyRenderedPromise(modalParams);
const selectedHandler = await this.modalUserAnswerPromise(modal, fileHandlers);
// Cancel action.
if (selectedHandler === null) {
return null;
}
// Save last selected handler.
this.lastHandlers[extension] = selectedHandler.module;
return selectedHandler;
}
/**
* Generated the modal template data.
*
* @param {Handler[]} fileHandlers
* @param {File} fileInfo the file info
* @param {String|null} defaultModule the default module if any
* @return {Object} the modal template data.
*/
getModalData(fileHandlers, fileInfo, defaultModule) {
const data = {
filename: fileInfo.name,
uploadid: ++this.lastUploadId,
handlers: [],
};
let hasDefault = false;
fileHandlers.forEach((handler, index) => {
const isDefault = (defaultModule == handler.module);
data.handlers.push({
...handler,
selected: isDefault,
labelid: `fileuploader_${data.uploadid}`,
value: index,
});
hasDefault = hasDefault || isDefault;
});
if (!hasDefault && data.handlers.length > 0) {
const lastHandler = data.handlers.pop();
lastHandler.selected = true;
data.handlers.push(lastHandler);
}
return data;
}
/**
* Get the user handler choice.
*
* Wait for the user answer in the modal and resolve with the selected index.
*
* @param {Modal} modal the modal instance
* @param {Handler[]} fileHandlers the availabvle file handlers
* @return {Promise} with the option selected by the user.
*/
modalUserAnswerPromise(modal, fileHandlers) {
const modalBody = getFirst(modal.getBody());
return new Promise((resolve, reject) => {
modal.getRoot().on(
ModalEvents.save,
event => {
// Get the selected option.
const index = modalBody.querySelector('input:checked').value;
event.preventDefault();
modal.destroy();
if (!fileHandlers[index]) {
reject('Invalid handler selected');
}
resolve(fileHandlers[index]);
}
);
modal.getRoot().on(
ModalEvents.cancel,
() => {
resolve(null);
}
);
});
}
/**
* Create a new modal and return a Promise to the body rendered.
*
* @param {Object} modalParams the modal params
* @returns {Promise} the modal body rendered promise
*/
modalBodyRenderedPromise(modalParams) {
return new Promise((resolve, reject) => {
ModalSaveCancel.create(modalParams).then((modal) => {
modal.setRemoveOnClose(true);
// Handle body loading event.
modal.getRoot().on(ModalEvents.bodyRendered, () => {
resolve(modal);
});
// Configure some extra modal params.
if (modalParams.saveButtonText !== undefined) {
modal.setSaveButtonText(modalParams.saveButtonText);
}
modal.show();
return;
}).catch(() => {
reject(`Cannot load modal content`);
});
});
}
}
/**
* Add a section to refresh.
*
* @param {number} courseId the course id
* @param {number} sectionId the seciton id
*/
function addRefreshSection(courseId, sectionId) {
let refresh = courseUpdates.get(courseId);
if (!refresh) {
refresh = new Set();
}
refresh.add(sectionId);
courseUpdates.set(courseId, refresh);
refreshCourseEditors();
}
/**
* Debounced processing all pending course refreshes.
* @private
*/
const refreshCourseEditors = debounce(
() => {
const refreshes = courseUpdates;
courseUpdates = new Map();
refreshes.forEach((sectionIds, courseId) => {
const courseEditor = getCourseEditor(courseId);
if (!courseEditor) {
return;
}
courseEditor.dispatch('sectionState', [...sectionIds]);
});
},
DEBOUNCETIMER
);
/**
* Load and return the course handler manager instance.
*
* @param {Number} courseId the course Id to load
* @returns {Promise<HandlerManager>} promise of the the loaded handleManager
*/
async function loadCourseHandlerManager(courseId) {
if (handlerManagers[courseId] !== undefined) {
return handlerManagers[courseId];
}
const handlerManager = new HandlerManager(courseId);
await handlerManager.loadHandlers();
handlerManagers[courseId] = handlerManager;
return handlerManagers[courseId];
}
/**
* Load all the erros messages at once in the module "errors" variable.
* @param {Number} courseId the course id
*/
async function loadErrorStrings(courseId) {
if (errors !== null) {
return;
}
const courseEditor = getCourseEditor(courseId);
const maxbytestext = courseEditor.get('course')?.maxbytestext ?? '0';
errors = {};
const allStrings = [
{key: 'dndmaxbytes', component: 'core_error', param: {size: maxbytestext}},
{key: 'dndread', component: 'core_error'},
{key: 'dndupload', component: 'core_error'},
{key: 'dndunkownfile', component: 'core_error'},
];
const loadedStrings = await getStrings(allStrings);
allStrings.forEach(({key}, index) => {
errors[key] = loadedStrings[index];
});
}
/**
* Start a batch file uploading into the course.
*
* @private
* @param {number} courseId the course id.
* @param {number} sectionId the section id.
* @param {number} sectionNum the section number.
* @param {File} fileInfo the file information object
* @param {HandlerManager} handlerManager the course handler manager
*/
const queueFileUpload = async function(courseId, sectionId, sectionNum, fileInfo, handlerManager) {
let handler;
uploadQueue = await processMonitor.createProcessQueue();
try {
handlerManager.validateFile(fileInfo);
handler = await handlerManager.getFileHandler(fileInfo);
} catch (error) {
uploadQueue.addError(fileInfo.name, error.message);
return;
}
// If we don't have a handler means the user cancel the upload.
if (!handler) {
return;
}
const fileProcessor = new FileUploader(courseId, sectionId, sectionNum, fileInfo, handler);
uploadQueue.addPending(fileInfo.name, fileProcessor.getExecutionFunction());
};
/**
* Upload a file to the course.
*
* This method will show any necesary modal to handle the request.
*
* @param {number} courseId the course id
* @param {number} sectionId the section id
* @param {number} sectionNum the section number
* @param {Array} files and array of files
*/
export const uploadFilesToCourse = async function(courseId, sectionId, sectionNum, files) {
// Get the course handlers.
const handlerManager = await loadCourseHandlerManager(courseId);
await loadErrorStrings(courseId);
for (let index = 0; index < files.length; index++) {
const fileInfo = files[index];
await queueFileUpload(courseId, sectionId, sectionNum, fileInfo, handlerManager);
}
};
@@ -0,0 +1,808 @@
// 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 ajax from 'core/ajax';
import {getString} from "core/str";
import log from 'core/log';
import SRLogger from "core/local/reactive/srlogger";
/**
* Flag to determine whether the screen reader-only logger has already been set, so we only need to set it once.
*
* @type {boolean}
*/
let isLoggerSet = false;
/**
* Default mutation manager
*
* @module core_courseformat/local/courseeditor/mutations
* @class core_courseformat/local/courseeditor/mutations
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class {
// All course editor mutations for Moodle 4.0 will be located in this file.
/**
* Private method to call core_courseformat_update_course webservice.
*
* @method _callEditWebservice
* @param {string} action
* @param {number} courseId
* @param {array} ids
* @param {number} targetSectionId optional target section id (for moving actions)
* @param {number} targetCmId optional target cm id (for moving actions)
*/
async _callEditWebservice(action, courseId, ids, targetSectionId, targetCmId) {
const args = {
action,
courseid: courseId,
ids,
};
if (targetSectionId) {
args.targetsectionid = targetSectionId;
}
if (targetCmId) {
args.targetcmid = targetCmId;
}
let ajaxresult = await ajax.call([{
methodname: 'core_courseformat_update_course',
args,
}])[0];
return JSON.parse(ajaxresult);
}
/**
* Execute a basic section state action.
* @param {StateManager} stateManager the current state manager
* @param {string} action the action name
* @param {array} sectionIds the section ids
* @param {number} targetSectionId optional target section id (for moving actions)
* @param {number} targetCmId optional target cm id (for moving actions)
*/
async _sectionBasicAction(stateManager, action, sectionIds, targetSectionId, targetCmId) {
const logEntry = this._getLoggerEntry(stateManager, action, sectionIds, {
targetSectionId,
targetCmId,
itemType: 'section',
});
const course = stateManager.get('course');
this.sectionLock(stateManager, sectionIds, true);
const updates = await this._callEditWebservice(
action,
course.id,
sectionIds,
targetSectionId,
targetCmId
);
this.bulkReset(stateManager);
stateManager.processUpdates(updates);
this.sectionLock(stateManager, sectionIds, false);
stateManager.addLoggerEntry(await logEntry);
}
/**
* Execute a basic course module state action.
* @param {StateManager} stateManager the current state manager
* @param {string} action the action name
* @param {array} cmIds the cm ids
* @param {number} targetSectionId optional target section id (for moving actions)
* @param {number} targetCmId optional target cm id (for moving actions)
*/
async _cmBasicAction(stateManager, action, cmIds, targetSectionId, targetCmId) {
const logEntry = this._getLoggerEntry(stateManager, action, cmIds, {
targetSectionId,
targetCmId,
itemType: 'cm',
});
const course = stateManager.get('course');
this.cmLock(stateManager, cmIds, true);
const updates = await this._callEditWebservice(
action,
course.id,
cmIds,
targetSectionId,
targetCmId
);
this.bulkReset(stateManager);
stateManager.processUpdates(updates);
this.cmLock(stateManager, cmIds, false);
stateManager.addLoggerEntry(await logEntry);
}
/**
* Get log entry for the current action.
* @param {StateManager} stateManager the current state manager
* @param {string} action the action name
* @param {int[]|null} itemIds the element ids
* @param {Object|undefined} data extra params for the log entry
* @param {string|undefined} data.itemType the element type (will be taken from action if none)
* @param {int|null|undefined} data.targetSectionId the target section id
* @param {int|null|undefined} data.targetCmId the target cm id
* @param {String|null|undefined} data.component optional component (for format plugins)
* @return {Object} the log entry
*/
async _getLoggerEntry(stateManager, action, itemIds, data = {}) {
if (!isLoggerSet) {
// In case the logger has not been set from init(), ensure we set the logger.
stateManager.setLogger(new SRLogger());
isLoggerSet = true;
}
const feedbackParams = {
action,
itemType: data.itemType ?? action.split('_')[0],
};
let batch = '';
if (itemIds.length > 1) {
feedbackParams.count = itemIds.length;
batch = '_batch';
} else if (itemIds.length === 1) {
const itemInfo = stateManager.get(feedbackParams.itemType, itemIds[0]);
feedbackParams.name = itemInfo.title ?? itemInfo.name;
// Apply shortener for modules like label.
}
if (data.targetSectionId) {
feedbackParams.targetSectionName = stateManager.get('section', data.targetSectionId).title;
}
if (data.targetCmId) {
feedbackParams.targetCmName = stateManager.get('cm', data.targetCmId).name;
}
const message = await getString(
`${action.toLowerCase()}_feedback${batch}`,
data.component ?? 'core_courseformat',
feedbackParams
);
return {
feedbackMessage: message,
};
}
/**
* Mutation module initialize.
*
* The reactive instance will execute this method when addMutations or setMutation is invoked.
*
* @param {StateManager} stateManager the state manager
*/
init(stateManager) {
// Add a method to prepare the fields when some update is coming from the server.
stateManager.addUpdateTypes({
prepareFields: this._prepareFields,
});
// Use the screen reader-only logger (SRLogger) to handle the feedback messages from the mutations.
stateManager.setLogger(new SRLogger());
isLoggerSet = true;
}
/**
* Add default values to state elements.
*
* This method is called every time a webservice returns a update state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
* @returns {Object} final fields data
*/
_prepareFields(stateManager, updateName, fields) {
// Any update should unlock the element.
fields.locked = false;
return fields;
}
/**
* Hides sections.
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of section ids
*/
async sectionHide(stateManager, sectionIds) {
await this._sectionBasicAction(stateManager, 'section_hide', sectionIds);
}
/**
* Show sections.
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of section ids
*/
async sectionShow(stateManager, sectionIds) {
await this._sectionBasicAction(stateManager, 'section_show', sectionIds);
}
/**
* Show cms.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmShow(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_show', cmIds);
}
/**
* Hide cms.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmHide(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_hide', cmIds);
}
/**
* Stealth cms.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmStealth(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_stealth', cmIds);
}
/**
* Duplicate course modules
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of course modules ids
* @param {number|undefined} targetSectionId the optional target sectionId
* @param {number|undefined} targetCmId the target course module id
*/
async cmDuplicate(stateManager, cmIds, targetSectionId, targetCmId) {
const logEntry = this._getLoggerEntry(stateManager, 'cm_duplicate', cmIds);
const course = stateManager.get('course');
// Lock all target sections.
const sectionIds = new Set();
if (targetSectionId) {
sectionIds.add(targetSectionId);
} else {
cmIds.forEach((cmId) => {
const cm = stateManager.get('cm', cmId);
sectionIds.add(cm.sectionid);
});
}
this.sectionLock(stateManager, Array.from(sectionIds), true);
const updates = await this._callEditWebservice('cm_duplicate', course.id, cmIds, targetSectionId, targetCmId);
this.bulkReset(stateManager);
stateManager.processUpdates(updates);
this.sectionLock(stateManager, Array.from(sectionIds), false);
stateManager.addLoggerEntry(await logEntry);
}
/**
* Move course modules to specific course location.
*
* Note that one of targetSectionId or targetCmId should be provided in order to identify the
* new location:
* - targetCmId: the activities will be located avobe the target cm. The targetSectionId
* value will be ignored in this case.
* - targetSectionId: the activities will be appended to the section. In this case
* targetSectionId should not be present.
*
* @param {StateManager} stateManager the current state manager
* @param {array} cmids the list of cm ids to move
* @param {number} targetSectionId the target section id
* @param {number} targetCmId the target course module id
*/
async cmMove(stateManager, cmids, targetSectionId, targetCmId) {
if (!targetSectionId && !targetCmId) {
throw new Error(`Mutation cmMove requires targetSectionId or targetCmId`);
}
const course = stateManager.get('course');
this.cmLock(stateManager, cmids, true);
const updates = await this._callEditWebservice('cm_move', course.id, cmids, targetSectionId, targetCmId);
this.bulkReset(stateManager);
stateManager.processUpdates(updates);
this.cmLock(stateManager, cmids, false);
}
/**
* Move course modules to specific course location.
*
* @deprecated since Moodle 4.4 MDL-77038.
* @todo MDL-80116 This will be deleted in Moodle 4.8.
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of section ids to move
* @param {number} targetSectionId the target section id
*/
async sectionMove(stateManager, sectionIds, targetSectionId) {
log.debug('sectionMove() is deprecated. Use sectionMoveAfter() instead');
if (!targetSectionId) {
throw new Error(`Mutation sectionMove requires targetSectionId`);
}
const course = stateManager.get('course');
this.sectionLock(stateManager, sectionIds, true);
const updates = await this._callEditWebservice('section_move', course.id, sectionIds, targetSectionId);
this.bulkReset(stateManager);
stateManager.processUpdates(updates);
this.sectionLock(stateManager, sectionIds, false);
}
/**
* Move course modules after a specific course location.
*
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of section ids to move
* @param {number} targetSectionId the target section id
*/
async sectionMoveAfter(stateManager, sectionIds, targetSectionId) {
if (!targetSectionId) {
throw new Error(`Mutation sectionMoveAfter requires targetSectionId`);
}
const course = stateManager.get('course');
this.sectionLock(stateManager, sectionIds, true);
const updates = await this._callEditWebservice('section_move_after', course.id, sectionIds, targetSectionId);
this.bulkReset(stateManager);
stateManager.processUpdates(updates);
this.sectionLock(stateManager, sectionIds, false);
}
/**
* Add a new section to a specific course location.
*
* @param {StateManager} stateManager the current state manager
* @param {number} targetSectionId optional the target section id
*/
async addSection(stateManager, targetSectionId) {
if (!targetSectionId) {
targetSectionId = 0;
}
const course = stateManager.get('course');
const updates = await this._callEditWebservice('section_add', course.id, [], targetSectionId);
stateManager.processUpdates(updates);
}
/**
* Delete sections.
*
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of course modules ids
*/
async sectionDelete(stateManager, sectionIds) {
const course = stateManager.get('course');
const updates = await this._callEditWebservice('section_delete', course.id, sectionIds);
this.bulkReset(stateManager);
stateManager.processUpdates(updates);
}
/**
* Delete cms.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of section ids
*/
async cmDelete(stateManager, cmIds) {
const course = stateManager.get('course');
this.cmLock(stateManager, cmIds, true);
const updates = await this._callEditWebservice('cm_delete', course.id, cmIds);
this.bulkReset(stateManager);
this.cmLock(stateManager, cmIds, false);
stateManager.processUpdates(updates);
}
/**
* Mark or unmark course modules as dragging.
*
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of course modules ids
* @param {bool} dragValue the new dragging value
*/
cmDrag(stateManager, cmIds, dragValue) {
this.setPageItem(stateManager);
this._setElementsValue(stateManager, 'cm', cmIds, 'dragging', dragValue);
}
/**
* Mark or unmark course sections as dragging.
*
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of section ids
* @param {bool} dragValue the new dragging value
*/
sectionDrag(stateManager, sectionIds, dragValue) {
this.setPageItem(stateManager);
this._setElementsValue(stateManager, 'section', sectionIds, 'dragging', dragValue);
}
/**
* Mark or unmark course modules as complete.
*
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of course modules ids
* @param {bool} complete the new completion value
*/
cmCompletion(stateManager, cmIds, complete) {
const newValue = (complete) ? 1 : 0;
this._setElementsValue(stateManager, 'cm', cmIds, 'completionstate', newValue);
}
/**
* Move cms to the right: indent = 1.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmMoveRight(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_moveright', cmIds);
}
/**
* Move cms to the left: indent = 0.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmMoveLeft(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_moveleft', cmIds);
}
/**
* Set cms group mode to NOGROUPS.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmNoGroups(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_nogroups', cmIds);
}
/**
* Set cms group mode to VISIBLEGROUPS.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmVisibleGroups(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_visiblegroups', cmIds);
}
/**
* Set cms group mode to SEPARATEGROUPS.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
async cmSeparateGroups(stateManager, cmIds) {
await this._cmBasicAction(stateManager, 'cm_separategroups', cmIds);
}
/**
* Lock or unlock course modules.
*
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of course modules ids
* @param {bool} lockValue the new locked value
*/
cmLock(stateManager, cmIds, lockValue) {
this._setElementsValue(stateManager, 'cm', cmIds, 'locked', lockValue);
}
/**
* Lock or unlock course sections.
*
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of section ids
* @param {bool} lockValue the new locked value
*/
sectionLock(stateManager, sectionIds, lockValue) {
this._setElementsValue(stateManager, 'section', sectionIds, 'locked', lockValue);
}
_setElementsValue(stateManager, name, ids, fieldName, newValue) {
stateManager.setReadOnly(false);
ids.forEach((id) => {
const element = stateManager.get(name, id);
if (element) {
element[fieldName] = newValue;
}
});
stateManager.setReadOnly(true);
}
/**
* Set the page current item.
*
* Only one element of the course state can be the page item at a time.
*
* There are several actions that can alter the page current item. For example, when the user is in an activity
* page, the page item is always the activity one. However, in a course page, when the user scrolls to an element,
* this element get the page item.
*
* If the page item is static means that it is not meant to change. This is important because
* static page items has some special logic. For example, if a cm is the static page item
* and it is inside a collapsed section, the course index will expand the section to make it visible.
*
* @param {StateManager} stateManager the current state manager
* @param {String|undefined} type the element type (section or cm). Undefined will remove the current page item.
* @param {Number|undefined} id the element id
* @param {boolean|undefined} isStatic if the page item is static
*/
setPageItem(stateManager, type, id, isStatic) {
let newPageItem;
if (type !== undefined) {
newPageItem = stateManager.get(type, id);
if (!newPageItem) {
return;
}
}
stateManager.setReadOnly(false);
// Remove the current page item.
const course = stateManager.get('course');
course.pageItem = null;
// Save the new page item.
if (newPageItem) {
course.pageItem = {
id,
type,
sectionId: (type == 'section') ? newPageItem.id : newPageItem.sectionid,
isStatic,
};
}
stateManager.setReadOnly(true);
}
/**
* Unlock all course elements.
*
* @param {StateManager} stateManager the current state manager
*/
unlockAll(stateManager) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.section.forEach((section) => {
section.locked = false;
});
state.cm.forEach((cm) => {
cm.locked = false;
});
stateManager.setReadOnly(true);
}
/**
* Update the course index collapsed attribute of some sections.
*
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the affected section ids
* @param {boolean} collapsed the new collapsed value
*/
async sectionIndexCollapsed(stateManager, sectionIds, collapsed) {
const affectedSections = this._updateStateSectionPreference(stateManager, 'indexcollapsed', sectionIds, collapsed);
if (!affectedSections) {
return;
}
const course = stateManager.get('course');
let actionName = 'section_index_collapsed';
if (!collapsed) {
actionName = 'section_index_expanded';
}
await this._callEditWebservice(actionName, course.id, affectedSections);
}
/**
* Update the course index collapsed attribute of all sections.
*
* @param {StateManager} stateManager the current state manager
* @param {boolean} collapsed the new collapsed value
*/
async allSectionsIndexCollapsed(stateManager, collapsed) {
const sectionIds = stateManager.getIds('section');
this.sectionIndexCollapsed(stateManager, sectionIds, collapsed);
}
/**
* Update the course content collapsed attribute of some sections.
*
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the affected section ids
* @param {boolean} collapsed the new collapsed value
*/
async sectionContentCollapsed(stateManager, sectionIds, collapsed) {
const affectedSections = this._updateStateSectionPreference(stateManager, 'contentcollapsed', sectionIds, collapsed);
if (!affectedSections) {
return;
}
const course = stateManager.get('course');
let actionName = 'section_content_collapsed';
if (!collapsed) {
actionName = 'section_content_expanded';
}
await this._callEditWebservice(actionName, course.id, affectedSections);
}
/**
* Private batch update for a section preference attribute.
*
* @param {StateManager} stateManager the current state manager
* @param {string} preferenceName the preference name
* @param {array} sectionIds the affected section ids
* @param {boolean} preferenceValue the new preferenceValue value
* @return {Number[]|null} sections ids with the preference value true or null if no update is required
*/
_updateStateSectionPreference(stateManager, preferenceName, sectionIds, preferenceValue) {
stateManager.setReadOnly(false);
const affectedSections = [];
// Check if we need to update preferences.
sectionIds.forEach(sectionId => {
const section = stateManager.get('section', sectionId);
if (section === undefined) {
stateManager.setReadOnly(true);
return null;
}
const newValue = preferenceValue ?? section[preferenceName];
if (section[preferenceName] != newValue) {
section[preferenceName] = newValue;
affectedSections.push(section.id);
}
});
stateManager.setReadOnly(true);
return affectedSections;
}
/**
* Enable/disable bulk editing.
*
* Note: reenabling the bulk will clean the current selection.
*
* @param {StateManager} stateManager the current state manager
* @param {Boolean} enabled the new bulk state.
*/
bulkEnable(stateManager, enabled) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.bulk.enabled = enabled;
state.bulk.selectedType = '';
state.bulk.selection = [];
stateManager.setReadOnly(true);
}
/**
* Reset the current selection.
* @param {StateManager} stateManager the current state manager
*/
bulkReset(stateManager) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.bulk.selectedType = '';
state.bulk.selection = [];
stateManager.setReadOnly(true);
}
/**
* Select a list of cms.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
cmSelect(stateManager, cmIds) {
this._addIdsToSelection(stateManager, 'cm', cmIds);
}
/**
* Unselect a list of cms.
* @param {StateManager} stateManager the current state manager
* @param {array} cmIds the list of cm ids
*/
cmUnselect(stateManager, cmIds) {
this._removeIdsFromSelection(stateManager, 'cm', cmIds);
}
/**
* Select a list of sections.
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of cm ids
*/
sectionSelect(stateManager, sectionIds) {
this._addIdsToSelection(stateManager, 'section', sectionIds);
}
/**
* Unselect a list of sections.
* @param {StateManager} stateManager the current state manager
* @param {array} sectionIds the list of cm ids
*/
sectionUnselect(stateManager, sectionIds) {
this._removeIdsFromSelection(stateManager, 'section', sectionIds);
}
/**
* Add some ids to the current bulk selection.
* @param {StateManager} stateManager the current state manager
* @param {String} typeName the type name (section/cm)
* @param {array} ids the list of ids
*/
_addIdsToSelection(stateManager, typeName, ids) {
const bulk = stateManager.state.bulk;
if (!bulk?.enabled) {
throw new Error(`Bulk is not enabled`);
}
if (bulk?.selectedType !== "" && bulk?.selectedType !== typeName) {
throw new Error(`Cannot add ${typeName} to the current selection`);
}
// Stored ids are strings for compatability with HTML data attributes.
ids = ids.map(value => value.toString());
stateManager.setReadOnly(false);
bulk.selectedType = typeName;
const newSelection = new Set([...bulk.selection, ...ids]);
bulk.selection = [...newSelection];
stateManager.setReadOnly(true);
}
/**
* Remove some ids to the current bulk selection.
*
* The method resets the selection type if the current selection is empty.
*
* @param {StateManager} stateManager the current state manager
* @param {String} typeName the type name (section/cm)
* @param {array} ids the list of ids
*/
_removeIdsFromSelection(stateManager, typeName, ids) {
const bulk = stateManager.state.bulk;
if (!bulk?.enabled) {
throw new Error(`Bulk is not enabled`);
}
if (bulk?.selectedType !== "" && bulk?.selectedType !== typeName) {
throw new Error(`Cannot remove ${typeName} from the current selection`);
}
// Stored ids are strings for compatability with HTML data attributes.
ids = ids.map(value => value.toString());
stateManager.setReadOnly(false);
const IdsToFilter = new Set(ids);
bulk.selection = bulk.selection.filter(current => !IdsToFilter.has(current));
if (bulk.selection.length === 0) {
bulk.selectedType = '';
}
stateManager.setReadOnly(true);
}
/**
* Get updated state data related to some cm ids.
*
* @method cmState
* @param {StateManager} stateManager the current state
* @param {array} cmids the list of cm ids to update
*/
async cmState(stateManager, cmids) {
this.cmLock(stateManager, cmids, true);
const course = stateManager.get('course');
const updates = await this._callEditWebservice('cm_state', course.id, cmids);
stateManager.processUpdates(updates);
this.cmLock(stateManager, cmids, false);
}
/**
* Get updated state data related to some section ids.
*
* @method sectionState
* @param {StateManager} stateManager the current state
* @param {array} sectionIds the list of section ids to update
*/
async sectionState(stateManager, sectionIds) {
this.sectionLock(stateManager, sectionIds, true);
const course = stateManager.get('course');
const updates = await this._callEditWebservice('section_state', course.id, sectionIds);
stateManager.processUpdates(updates);
this.sectionLock(stateManager, sectionIds, false);
}
/**
* Get the full updated state data of the course.
*
* @param {StateManager} stateManager the current state
*/
async courseState(stateManager) {
const course = stateManager.get('course');
const updates = await this._callEditWebservice('course_state', course.id);
stateManager.processUpdates(updates);
}
}
@@ -0,0 +1,218 @@
// 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/>.
/**
* Course index cm component.
*
* This component is used to control specific course modules interactions like drag and drop.
*
* @module core_courseformat/local/courseindex/cm
* @class core_courseformat/local/courseindex/cm
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import DndCmItem from 'core_courseformat/local/courseeditor/dndcmitem';
import Templates from 'core/templates';
import Prefetch from 'core/prefetch';
import Config from 'core/config';
import Pending from "core/pending";
// Prefetch the completion icons template.
const completionTemplate = 'core_courseformat/local/courseindex/cmcompletion';
Prefetch.prefetchTemplate(completionTemplate);
export default class Component extends DndCmItem {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'courseindex_cm';
// Default query selectors.
this.selectors = {
CM_NAME: `[data-for='cm_name']`,
CM_COMPLETION: `[data-for='cm_completion']`,
};
// Default classes to toggle on refresh.
this.classes = {
CMHIDDEN: 'dimmed',
LOCKED: 'editinprogress',
RESTRICTIONS: 'restrictions',
PAGEITEM: 'pageitem',
INDENTED: 'indented',
};
// We need our id to watch specific events.
this.id = this.element.dataset.id;
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.getElementById(target),
selectors,
});
}
/**
* Initial state ready method.
*
* @param {Object} state the course state.
*/
stateReady(state) {
this.configDragDrop(this.id);
const cm = state.cm.get(this.id);
const course = state.course;
// Refresh completion icon.
this._refreshCompletion({
state,
element: cm,
});
const url = new URL(window.location.href);
const anchor = url.hash.replace('#', '');
// Check if the current url is the cm url.
if (window.location.href == cm.url
|| (window.location.href.includes(course.baseurl) && anchor == cm.anchor)
) {
this.element.scrollIntoView({block: "center"});
}
// Check if this we are displaying this activity page.
if (Config.contextid != Config.courseContextId && Config.contextInstanceId == this.id) {
this.reactive.dispatch('setPageItem', 'cm', this.id, true);
this.element.scrollIntoView({block: "center"});
}
// Add anchor logic if the element is not user visible or the element hasn't URL.
if (!cm.uservisible || !cm.url) {
this.addEventListener(
this.getElement(this.selectors.CM_NAME),
'click',
this._activityAnchor,
);
}
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `cm[${this.id}]:deleted`, handler: this.remove},
{watch: `cm[${this.id}]:updated`, handler: this._refreshCm},
{watch: `cm[${this.id}].completionstate:updated`, handler: this._refreshCompletion},
{watch: `course.pageItem:updated`, handler: this._refreshPageItem},
];
}
/**
* Update a course index cm using the state information.
*
* @param {object} param
* @param {Object} param.element details the update details.
*/
_refreshCm({element}) {
// Update classes.
this.element.classList.toggle(this.classes.CMHIDDEN, !element.visible);
this.getElement(this.selectors.CM_NAME).innerHTML = element.name;
this.element.classList.toggle(this.classes.DRAGGING, element.dragging ?? false);
this.element.classList.toggle(this.classes.LOCKED, element.locked ?? false);
this.element.classList.toggle(this.classes.RESTRICTIONS, element.hascmrestrictions ?? false);
this.element.classList.toggle(this.classes.INDENTED, element.indent);
this.locked = element.locked;
}
/**
* Handle a page item update.
*
* @param {Object} details the update details
* @param {Object} details.element the course state data.
*/
_refreshPageItem({element}) {
if (!element.pageItem) {
return;
}
const isPageId = (element.pageItem.type == 'cm' && element.pageItem.id == this.id);
this.element.classList.toggle(this.classes.PAGEITEM, isPageId);
if (isPageId && !this.reactive.isEditing) {
this.element.scrollIntoView({block: "nearest"});
}
}
/**
* Update the activity completion icon.
*
* @param {Object} details the update details
* @param {Object} details.state the state data
* @param {Object} details.element the element data
*/
async _refreshCompletion({state, element}) {
// No completion icons are displayed in edit mode.
if (this.reactive.isEditing || !element.istrackeduser) {
return;
}
// Check if the completion value has changed.
const completionElement = this.getElement(this.selectors.CM_COMPLETION);
if (completionElement.dataset.value == element.completionstate) {
return;
}
// Collect section information from the state.
const exporter = this.reactive.getExporter();
const data = exporter.cmCompletion(state, element);
const {html, js} = await Templates.renderForPromise(completionTemplate, data);
Templates.replaceNode(completionElement, html, js);
}
/**
* The activity anchor event.
*
* @param {Event} event
*/
_activityAnchor(event) {
const cm = this.reactive.get('cm', this.id);
// If the user cannot access the element but the element is present in the page
// the new url should be an anchor link.
const element = document.getElementById(cm.anchor);
if (element) {
// Make sure the section is expanded.
this.reactive.dispatch('sectionContentCollapsed', [cm.sectionid], false);
// Marc the element as page item once the event is handled.
const pendingAnchor = new Pending(`courseformat/activity:openAnchor`);
setTimeout(() => {
this.reactive.dispatch('setPageItem', 'cm', cm.id);
pendingAnchor.resolve();
}, 50);
return;
}
// If the element is not present in the page we need to go to the specific section.
const course = this.reactive.get('course');
const section = this.reactive.get('section', cm.sectionid);
if (!section) {
return;
}
const url = `${course.baseurl}&section=${section.number}#${cm.anchor}`;
event.preventDefault();
window.location = url;
}
}
@@ -0,0 +1,378 @@
// 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/>.
/**
* Course index main component.
*
* @module core_courseformat/local/courseindex/courseindex
* @class core_courseformat/local/courseindex/courseindex
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
import jQuery from 'jquery';
import ContentTree from 'core_courseformat/local/courseeditor/contenttree';
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'courseindex';
// Default query selectors.
this.selectors = {
SECTION: `[data-for='section']`,
SECTION_CMLIST: `[data-for='cmlist']`,
CM: `[data-for='cm']`,
TOGGLER: `[data-action="togglecourseindexsection"]`,
COLLAPSE: `[data-toggle="collapse"]`,
DRAWER: `.drawer`,
};
// Default classes to toggle on refresh.
this.classes = {
SECTIONHIDDEN: 'dimmed',
CMHIDDEN: 'dimmed',
SECTIONCURRENT: 'current',
COLLAPSED: `collapsed`,
SHOW: `show`,
};
// Arrays to keep cms and sections elements.
this.sections = {};
this.cms = {};
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.getElementById(target),
reactive: getCurrentCourseEditor(),
selectors,
});
}
/**
* Initial state ready method.
*
* @param {Object} state the state data
*/
stateReady(state) {
// Activate section togglers.
this.addEventListener(this.element, 'click', this._sectionTogglers);
// Get cms and sections elements.
const sections = this.getElements(this.selectors.SECTION);
sections.forEach((section) => {
this.sections[section.dataset.id] = section;
});
const cms = this.getElements(this.selectors.CM);
cms.forEach((cm) => {
this.cms[cm.dataset.id] = cm;
});
this._expandPageCmSectionIfNecessary(state);
this._refreshPageItem({element: state.course, state});
// Configure Aria Tree.
this.contentTree = new ContentTree(this.element, this.selectors, this.reactive.isEditing);
}
getWatchers() {
return [
{watch: `section.indexcollapsed:updated`, handler: this._refreshSectionCollapsed},
{watch: `cm:created`, handler: this._createCm},
{watch: `cm:deleted`, handler: this._deleteCm},
{watch: `section:created`, handler: this._createSection},
{watch: `section:deleted`, handler: this._deleteSection},
{watch: `course.pageItem:created`, handler: this._refreshPageItem},
{watch: `course.pageItem:updated`, handler: this._refreshPageItem},
// Sections and cm sorting.
{watch: `course.sectionlist:updated`, handler: this._refreshCourseSectionlist},
{watch: `section.cmlist:updated`, handler: this._refreshSectionCmlist},
];
}
/**
* Setup sections toggler.
*
* Toggler click is delegated to the main course index element because new sections can
* appear at any moment and this way we prevent accidental double bindings.
*
* @param {Event} event the triggered event
*/
_sectionTogglers(event) {
const sectionlink = event.target.closest(this.selectors.TOGGLER);
const isChevron = event.target.closest(this.selectors.COLLAPSE);
if (sectionlink || isChevron) {
const section = event.target.closest(this.selectors.SECTION);
const toggler = section.querySelector(this.selectors.COLLAPSE);
const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;
// Update the state.
const sectionId = section.getAttribute('data-id');
if (!sectionlink || isCollapsed) {
this.reactive.dispatch(
'sectionIndexCollapsed',
[sectionId],
!isCollapsed
);
}
}
}
/**
* Update section collapsed.
*
* @param {object} args
* @param {object} args.element The leement to be expanded
*/
_refreshSectionCollapsed({element}) {
const target = this.getElement(this.selectors.SECTION, element.id);
if (!target) {
throw new Error(`Unkown section with ID ${element.id}`);
}
// Check if it is already done.
const toggler = target.querySelector(this.selectors.COLLAPSE);
const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;
if (element.indexcollapsed !== isCollapsed) {
this._expandSectionNode(element);
}
}
/**
* Expand a section node.
*
* By default the method will use element.indexcollapsed to decide if the
* section is opened or closed. However, using forceValue it is possible
* to open or close a section independant from the indexcollapsed attribute.
*
* @param {Object} element the course module state element
* @param {boolean} forceValue optional forced expanded value
*/
_expandSectionNode(element, forceValue) {
const target = this.getElement(this.selectors.SECTION, element.id);
const toggler = target.querySelector(this.selectors.COLLAPSE);
let collapsibleId = toggler.dataset.target ?? toggler.getAttribute("href");
if (!collapsibleId) {
return;
}
collapsibleId = collapsibleId.replace('#', '');
const collapsible = document.getElementById(collapsibleId);
if (!collapsible) {
return;
}
if (forceValue === undefined) {
forceValue = (element.indexcollapsed) ? false : true;
}
// Course index is based on Bootstrap 4 collapsibles. To collapse them we need jQuery to
// interact with collapsibles methods. Hopefully, this will change in Bootstrap 5 because
// it does not require jQuery anymore (when MDL-71979 is integrated).
const togglerValue = (forceValue) ? 'show' : 'hide';
jQuery(collapsible).collapse(togglerValue);
}
/**
* Handle a page item update.
*
* @param {Object} details the update details
* @param {Object} details.state the state data.
* @param {Object} details.element the course state data.
*/
_refreshPageItem({element, state}) {
if (!element?.pageItem?.isStatic || element.pageItem.type != 'cm') {
return;
}
// Check if we need to uncollapse the section and scroll to the element.
const section = state.section.get(element.pageItem.sectionId);
if (section.indexcollapsed) {
this._expandSectionNode(section, true);
setTimeout(
() => this.cms[element.pageItem.id]?.scrollIntoView({block: "nearest"}),
250
);
}
}
/**
* Expand a section if the current page is a section's cm.
*
* @private
* @param {Object} state the course state.
*/
_expandPageCmSectionIfNecessary(state) {
const pageCmInfo = this.reactive.getPageAnchorCmInfo();
if (!pageCmInfo) {
return;
}
this._expandSectionNode(state.section.get(pageCmInfo.sectionid), true);
}
/**
* Create a newcm instance.
*
* @param {object} param
* @param {Object} param.state
* @param {Object} param.element
*/
async _createCm({state, element}) {
// Create a fake node while the component is loading.
const fakeelement = document.createElement('li');
fakeelement.classList.add('bg-pulse-grey', 'w-100');
fakeelement.innerHTML = '&nbsp;';
this.cms[element.id] = fakeelement;
// Place the fake node on the correct position.
this._refreshSectionCmlist({
state,
element: state.section.get(element.sectionid),
});
// Collect render data.
const exporter = this.reactive.getExporter();
const data = exporter.cm(state, element);
// Create the new content.
const newcomponent = await this.renderComponent(fakeelement, 'core_courseformat/local/courseindex/cm', data);
// Replace the fake node with the real content.
const newelement = newcomponent.getElement();
this.cms[element.id] = newelement;
fakeelement.parentNode.replaceChild(newelement, fakeelement);
}
/**
* Create a new section instance.
*
* @param {Object} details the update details.
* @param {Object} details.state the state data.
* @param {Object} details.element the element data.
*/
async _createSection({state, element}) {
// Create a fake node while the component is loading.
const fakeelement = document.createElement('div');
fakeelement.classList.add('bg-pulse-grey', 'w-100');
fakeelement.innerHTML = '&nbsp;';
this.sections[element.id] = fakeelement;
// Place the fake node on the correct position.
this._refreshCourseSectionlist({
state,
element: state.course,
});
// Collect render data.
const exporter = this.reactive.getExporter();
const data = exporter.section(state, element);
// Create the new content.
const newcomponent = await this.renderComponent(fakeelement, 'core_courseformat/local/courseindex/section', data);
// Replace the fake node with the real content.
const newelement = newcomponent.getElement();
this.sections[element.id] = newelement;
fakeelement.parentNode.replaceChild(newelement, fakeelement);
}
/**
* Refresh a section cm list.
*
* @param {object} param
* @param {Object} param.element
*/
_refreshSectionCmlist({element}) {
const cmlist = element.cmlist ?? [];
const listparent = this.getElement(this.selectors.SECTION_CMLIST, element.id);
this._fixOrder(listparent, cmlist, this.cms);
}
/**
* Refresh the section list.
*
* @param {object} param
* @param {Object} param.state
*/
_refreshCourseSectionlist({state}) {
const sectionlist = this.reactive.getExporter().listedSectionIds(state);
this._fixOrder(this.element, sectionlist, this.sections);
}
/**
* Fix/reorder the section or cms order.
*
* @param {Element} container the HTML element to reorder.
* @param {Array} neworder an array with the ids order
* @param {Array} allitems the list of html elements that can be placed in the container
*/
_fixOrder(container, neworder, allitems) {
// Empty lists should not be visible.
if (!neworder.length) {
container.classList.add('hidden');
container.innerHTML = '';
return;
}
// Grant the list is visible (in case it was empty).
container.classList.remove('hidden');
// Move the elements in order at the beginning of the list.
neworder.forEach((itemid, index) => {
const item = allitems[itemid];
// Get the current element at that position.
const currentitem = container.children[index];
if (currentitem === undefined) {
container.append(item);
return;
}
if (currentitem !== item && item) {
container.insertBefore(item, currentitem);
}
});
// Remove the remaining elements.
while (container.children.length > neworder.length) {
container.removeChild(container.lastChild);
}
}
/**
* Remove a cm from the list.
*
* The actual DOM element removal is delegated to the cm component.
*
* @param {object} param
* @param {Object} param.element
*/
_deleteCm({element}) {
delete this.cms[element.id];
}
/**
* Remove a section from the list.
*
* The actual DOM element removal is delegated to the section component.
*
* @param {Object} details the update details.
* @param {Object} details.element the element data.
*/
_deleteSection({element}) {
delete this.sections[element.id];
}
}
@@ -0,0 +1,55 @@
// 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/>.
/**
* Course index drawer wrap.
*
* This component is mostly used to ensure all subcomponents find a parent
* compoment with a reactive instance defined.
*
* @module core_courseformat/local/courseindex/drawer
* @class core_courseformat/local/courseindex/drawer
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'courseindex-drawer';
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.getElementById(target),
reactive: getCurrentCourseEditor(),
selectors,
});
}
}
@@ -0,0 +1,111 @@
// 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/>.
/**
* Course index placeholder replacer.
*
* @module core_courseformat/local/courseindex/placeholder
* @class core_courseformat/local/courseindex/placeholder
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import Templates from 'core/templates';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
import Pending from 'core/pending';
export default class Component extends BaseComponent {
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.getElementById(target),
reactive: getCurrentCourseEditor(),
selectors,
});
}
/**
* Component creation hook.
*/
create() {
// Add a pending operation waiting for the initial content.
this.pendingContent = new Pending(`core_courseformat/placeholder:loadcourseindex`);
}
/**
* Initial state ready method.
*
* This stateReady to be async because it loads the real courseindex.
*
* @param {object} state the initial state
*/
async stateReady(state) {
// Check if we have a static course index already loded from a previous page.
if (!this.loadStaticContent()) {
await this.loadTemplateContent(state);
}
}
/**
* Load the course index from the session storage if any.
*
* @return {boolean} true if the static version is loaded form the session
*/
loadStaticContent() {
// Load the previous static course index from the session cache.
const index = this.reactive.getStorageValue(`courseIndex`);
if (index.html && index.js) {
Templates.replaceNode(this.element, index.html, index.js);
this.pendingContent.resolve();
return true;
}
return false;
}
/**
* Load the course index template.
*
* @param {Object} state the initial state
*/
async loadTemplateContent(state) {
// Collect section information from the state.
const exporter = this.reactive.getExporter();
const data = exporter.course(state);
try {
// To render an HTML into our component we just use the regular Templates module.
const {html, js} = await Templates.renderForPromise(
'core_courseformat/local/courseindex/courseindex',
data,
);
Templates.replaceNode(this.element, html, js);
this.pendingContent.resolve();
// Save the rendered template into the session cache.
this.reactive.setStorageValue(`courseIndex`, {html, js});
} catch (error) {
this.pendingContent.resolve(error);
throw error;
}
}
}
@@ -0,0 +1,189 @@
// 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/>.
/**
* Course index section component.
*
* This component is used to control specific course section interactions like drag and drop.
*
* @module core_courseformat/local/courseindex/section
* @class core_courseformat/local/courseindex/section
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import SectionTitle from 'core_courseformat/local/courseindex/sectiontitle';
import DndSection from 'core_courseformat/local/courseeditor/dndsection';
export default class Component extends DndSection {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'courseindex_section';
// Default query selectors.
this.selectors = {
SECTION_ITEM: `[data-for='section_item']`,
SECTION_TITLE: `[data-for='section_title']`,
CM_LAST: `[data-for="cm"]:last-child`,
};
// Default classes to toggle on refresh.
this.classes = {
SECTIONHIDDEN: 'dimmed',
SECTIONCURRENT: 'current',
LOCKED: 'editinprogress',
RESTRICTIONS: 'restrictions',
PAGEITEM: 'pageitem',
OVERLAYBORDERS: 'overlay-preview-borders',
};
// We need our id to watch specific events.
this.id = this.element.dataset.id;
this.isPageItem = false;
}
/**
* Static method to create a component instance form the mustahce template.
*
* @param {string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.getElementById(target),
selectors,
});
}
/**
* Initial state ready method.
*
* @param {Object} state the initial state
*/
stateReady(state) {
this.configState(state);
const sectionItem = this.getElement(this.selectors.SECTION_ITEM);
// Drag and drop is only available for components compatible course formats.
if (this.reactive.isEditing && this.reactive.supportComponents) {
// Init the inner dragable element passing the full section as affected region.
const titleitem = new SectionTitle({
...this,
element: sectionItem,
fullregion: this.element,
});
this.configDragDrop(titleitem);
}
// Check if the current url is the section url.
const section = state.section.get(this.id);
if (window.location.href == section.sectionurl.replace(/&amp;/g, "&")) {
this.reactive.dispatch('setPageItem', 'section', this.id);
sectionItem.scrollIntoView();
}
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `section[${this.id}]:deleted`, handler: this.remove},
{watch: `section[${this.id}]:updated`, handler: this._refreshSection},
{watch: `course.pageItem:updated`, handler: this._refreshPageItem},
];
}
/**
* Get the last CM element of that section.
*
* @returns {element|null}
*/
getLastCm() {
return this.getElement(this.selectors.CM_LAST);
}
/**
* Update a course index section using the state information.
*
* @param {Object} param details the update details.
* @param {Object} param.element the section element
*/
_refreshSection({element}) {
// Update classes.
const sectionItem = this.getElement(this.selectors.SECTION_ITEM);
sectionItem.classList.toggle(this.classes.SECTIONHIDDEN, !element.visible);
sectionItem.classList.toggle(this.classes.RESTRICTIONS, element.hasrestrictions ?? false);
this.element.classList.toggle(this.classes.SECTIONCURRENT, element.current);
this.element.classList.toggle(this.classes.DRAGGING, element.dragging ?? false);
this.element.classList.toggle(this.classes.LOCKED, element.locked ?? false);
this.locked = element.locked;
// Update title.
this.getElement(this.selectors.SECTION_TITLE).innerHTML = element.title;
}
/**
* Handle a page item update.
*
* @param {Object} details the update details
* @param {Object} details.state the state data.
* @param {Object} details.element the course state data.
*/
_refreshPageItem({element, state}) {
if (!element.pageItem) {
return;
}
const section = state.section.get(this.id);
const isRelevantPageItem = element.pageItem.sectionId === this.id || !this.isPageItem;
const isSectionOrCollapsed = element.pageItem.type === 'section' || section.indexcollapsed;
if (!(isRelevantPageItem && isSectionOrCollapsed)) {
this.pageItem = false;
this.getElement(this.selectors.SECTION_ITEM).classList.remove(this.classes.PAGEITEM);
return;
}
if (section.indexcollapsed && !element.pageItem?.isStatic) {
this.pageItem = (element.pageItem?.sectionId == this.id);
} else {
this.pageItem = (element.pageItem.type == 'section' && element.pageItem.id == this.id);
}
const sectionItem = this.getElement(this.selectors.SECTION_ITEM);
sectionItem.classList.toggle(this.classes.PAGEITEM, this.pageItem ?? false);
if (this.pageItem && !this.reactive.isEditing) {
this.element.scrollIntoView({block: "nearest"});
}
}
/**
* Overridden version of the component addOverlay async method.
*
* The course index is not compatible with overlay elements.
*/
async addOverlay() {
this.element.classList.add(this.classes.OVERLAYBORDERS);
}
/**
* Overridden version of the component removeOverlay.
*
* The course index is not compatible with overlay elements.
*/
removeOverlay() {
this.element.classList.remove(this.classes.OVERLAYBORDERS);
}
}
@@ -0,0 +1,73 @@
// 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/>.
/**
* Course index section title component.
*
* This component is used to control specific course section interactions like drag and drop.
*
* @module core_courseformat/local/courseindex/sectiontitle
* @class core_courseformat/local/courseindex/sectiontitle
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import DndSectionItem from 'core_courseformat/local/courseeditor/dndsectionitem';
export default class Component extends DndSectionItem {
/**
* Constructor hook.
*
* @param {Object} descriptor
*/
create(descriptor) {
// Optional component name for debugging.
this.name = 'courseindex_sectiontitle';
this.id = descriptor.id;
this.section = descriptor.section;
this.course = descriptor.course;
this.fullregion = descriptor.fullregion;
// Prevent topic zero from being draggable.
if (this.section.number > 0) {
this.getDraggableData = this._getDraggableData;
}
}
/**
* Static method to create a component instance form the mustahce template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new this({
element: document.getElementById(target),
selectors,
});
}
/**
* Initial state ready method.
*
* @param {Object} state the initial state
*/
stateReady(state) {
this.configDragDrop(this.id, state, this.fullregion);
}
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
<?php
// 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/>.
namespace core_courseformat\external;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/course/dnduploadlib.php');
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_multiple_structure;
use core_external\external_single_structure;
use core_external\external_value;
use dndupload_handler;
/**
* Class for exporting a course file handlers.
*
* @package core_courseformat
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 4.2
*/
class file_handlers extends external_api {
/**
* Webservice parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters(
[
'courseid' => new external_value(PARAM_INT, 'course id', VALUE_REQUIRED),
]
);
}
/**
* Return the list of available file handlers.
*
* @param int $courseid the course id
* @return array of file hanlders.
*/
public static function execute(int $courseid): array {
global $CFG;
require_once($CFG->dirroot . '/course/lib.php');
$params = external_api::validate_parameters(self::execute_parameters(), [
'courseid' => $courseid,
]);
$courseid = $params['courseid'];
self::validate_context(\context_course::instance($courseid));
$format = course_get_format($courseid);
$course = $format->get_course();
$handler = new dndupload_handler($course, null);
$data = $handler->get_js_data();
return $data->filehandlers ?? [];
}
/**
* Webservice returns.
*
* @return external_multiple_structure
*/
public static function execute_returns(): external_multiple_structure {
return new external_multiple_structure(
new external_single_structure([
'extension' => new external_value(PARAM_TEXT, 'File extension'),
'module' => new external_value(PARAM_TEXT, 'Target module'),
'message' => new external_value(PARAM_TEXT, 'Output message'),
])
);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
// 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/>.
namespace core_courseformat\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_value;
/**
* Class for exporting a course state.
*
* @package core_course
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 4.0
*/
class get_state extends external_api {
/**
* Webservice parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'courseid' => new external_value(PARAM_INT, 'course id', VALUE_REQUIRED),
]);
}
/**
* This method will load all course, sections and cm states needed to initialize the frontend
* course editor module. The state data of every individual course, section and cm is
* build using the specifics "state" output components.
*
* By default, the states are generated by:
* - core_courseformat\output\state\course
* - core_courseformat\output\state\section
* - core_courseformat\output\state\cm
*
* As the other main course outputs, format plugins can override those output components
* to send more information to the frontend course editor. These extended classes should
* be located in format_XXX\output\courseformat\state\course, format_XXX\output\courseformat\state\section
* or format_XXX\output\courseformat\state\cm.
*
* @param int $courseid the course id
* @return string Course state in JSON
*/
public static function execute(int $courseid): string {
global $PAGE, $CFG, $USER;
require_once($CFG->dirroot.'/course/lib.php');
$params = external_api::validate_parameters(self::execute_parameters(), [
'courseid' => $courseid,
]);
$courseid = $params['courseid'];
self::validate_context(\context_course::instance($courseid));
$courseformat = course_get_format($courseid);
$modinfo = $courseformat->get_modinfo();
$completioninfo = new \completion_info(get_course($courseid));
$istrackeduser = $completioninfo->is_tracked_user($USER->id);
// Get the proper renderer.
$renderer = $courseformat->get_renderer($PAGE);
$result = (object)[
'course' => (object)[],
'section' => [],
'cm' => [],
];
// Load the output class names.
$courseclass = $courseformat->get_output_classname('state\\course');
$sectionclass = $courseformat->get_output_classname('state\\section');
$cmclass = $courseformat->get_output_classname('state\\cm');
// General state.
$coursestate = new $courseclass($courseformat);
$result->course = $coursestate->export_for_template($renderer);
// Sections and course modules state.
$sections = $modinfo->get_section_info_all();
foreach ($sections as $section) {
if ($courseformat->is_section_visible($section)) {
// Only return this section data if it's visible by current user on the course page.
$sectionstate = new $sectionclass($courseformat, $section);
$result->section[] = $sectionstate->export_for_template($renderer);
}
}
foreach ($modinfo->cms as $cm) {
if ($cm->is_visible_on_course_page()) {
// Only return this course module data if it's visible by current user on the course page.
$section = $sections[$cm->sectionnum];
$cmstate = new $cmclass($courseformat, $section, $cm, istrackeduser: $istrackeduser);
$result->cm[] = $cmstate->export_for_template($renderer);
}
}
return json_encode($result);
}
/**
* Webservice returns.
*
* @return external_value
*/
public static function execute_returns(): external_value {
return new external_value(PARAM_RAW, 'Encoded course state JSON');
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
// 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/>.
namespace core_courseformat\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_multiple_structure;
use core_external\external_value;
use moodle_exception;
use coding_exception;
use context_course;
use core_courseformat\base as course_format;
/**
* External secrvie to update the course from the course editor components.
*
* @package core_course
* @copyright 2021 Ferran Recio <moodle@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 4.0
*/
class update_course extends external_api {
/**
* Webservice parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters(
[
'action' => new external_value(
PARAM_ALPHANUMEXT,
'action: cm_hide, cm_show, section_hide, section_show, cm_moveleft...',
VALUE_REQUIRED
),
'courseid' => new external_value(PARAM_INT, 'course id', VALUE_REQUIRED),
'ids' => new external_multiple_structure(
new external_value(PARAM_INT, 'Target id'),
'Affected ids',
VALUE_DEFAULT,
[]
),
'targetsectionid' => new external_value(
PARAM_INT, 'Optional target section id', VALUE_DEFAULT, null
),
'targetcmid' => new external_value(
PARAM_INT, 'Optional target cm id', VALUE_DEFAULT, null
),
]
);
}
/**
* This webservice will execute any action from the course editor. The default actions
* are located in {@see \core_courseformat\stateactions} but the format plugin can extend that class
* in format_XXX\course.
*
* The specific action methods will register in a {@see \core_courseformat\stateupdates} all the affected
* sections, cms and course attribute. This object (in JSON) will be sent back to the
* frontend editor to refresh the updated state elements.
*
* By default, {@see \core_courseformat\stateupdates} will register only create, delete and update events
* on cms, sections and the general course data. However, if some plugin needs adhoc messages for
* its own mutation module, extend this class in format_XXX\course.
*
* @param string $action the action name to execute
* @param int $courseid the course id
* @param int[] $ids the affected ids (section or cm depending on the action)
* @param int|null $targetsectionid optional target section id (for move action)
* @param int|null $targetcmid optional target cm id (for move action)
* @return string Course state in JSON
*/
public static function execute(string $action, int $courseid, array $ids = [],
?int $targetsectionid = null, ?int $targetcmid = null): string {
global $CFG;
require_once($CFG->dirroot . '/course/lib.php');
$params = external_api::validate_parameters(self::execute_parameters(), [
'action' => $action,
'courseid' => $courseid,
'ids' => $ids,
'targetsectionid' => $targetsectionid,
'targetcmid' => $targetcmid,
]);
$action = $params['action'];
$courseid = $params['courseid'];
$ids = $params['ids'];
$targetsectionid = $params['targetsectionid'];
$targetcmid = $params['targetcmid'];
self::validate_context(context_course::instance($courseid));
$courseformat = course_get_format($courseid);
// Create a course changes tracker object.
$defaultupdatesclass = 'core_courseformat\\stateupdates';
$updatesclass = 'format_' . $courseformat->get_format() . '\\courseformat\\stateupdates';
if (!class_exists($updatesclass)) {
$updatesclass = $defaultupdatesclass;
}
$updates = new $updatesclass($courseformat);
if (!is_a($updates, $defaultupdatesclass)) {
throw new coding_exception("The \"$updatesclass\" class must extend \"$defaultupdatesclass\"");
}
// Get the actions class from the course format.
$actionsclass = 'format_'. $courseformat->get_format().'\\courseformat\\stateactions';
if (!class_exists($actionsclass)) {
$actionsclass = 'core_courseformat\\stateactions';
}
$actions = new $actionsclass();
if (!is_callable([$actions, $action])) {
throw new moodle_exception("Invalid course state action $action in ".get_class($actions));
}
$course = $courseformat->get_course();
// Execute the action.
$actions->$action($updates, $course, $ids, $targetsectionid, $targetcmid);
// Any state action mark the state cache as dirty.
course_format::session_cache_reset($course);
return json_encode($updates);
}
/**
* Webservice returns.
*
* @return external_value
*/
public static function execute_returns(): external_value {
return new external_value(PARAM_RAW, 'Encoded course update JSON');
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
// 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/>.
namespace core_courseformat;
use core_courseformat\local\courseactions;
use core_courseformat\local\sectionactions;
use core_courseformat\local\cmactions;
use coding_exception;
use stdClass;
/**
* Class to instantiate course format actions.
*
* This class is used to access course content actions.
*
* All course actions are divided into three main clases:
* - course: actions related to the course.
* - section: actions related to the sections.
* - cm: actions related to the course modules.
*
* Format plugin can provide their own actions classes by extending the actions classes
* with the following namespaces:
* - course: format_{PLUGINNAME}\courseformat\courseactions
* - section: format_{PLUGINNAME}\courseformat\sectionactions
* - cm: format_{PLUGINNAME}\courseformat\cmactions
*
* There a static method to get the general formatactions instance:
* - formatactions::instance($courseorid): returns an instance to access all available actions.
*
* The class also provides some convenience methods to get specific actions level on a specific course:
* - formatactions::course($courseorid): returns an instance of the course actions class.
* - formatactions::section($courseorid): returns an instance of the section actions class.
* - formatactions::cm($courseorid): returns an instance of the cm actions class.
*
* There are two ways of executing actions. For example, to execute a section action
* called "move_after" the options are:
*
* Option A: ideal for executing only one action.
*
* formatactions::section($courseid)->move_after($sectioninfo, $aftersectioninfo);
*
* Option B: when actions in the same course are going to be executed at different levels.
*
* $actions = formatactions::instance($courseid);
* $actions->section->move_after($sectioninfo, $aftersectioninfo);
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class formatactions {
/**
* @var courseactions|null courseactions instance.
*/
public courseactions $course;
/**
* @var sectionactions sectionactions instance.
*/
public sectionactions $section;
/**
* @var cmactions cmactions instance.
*/
public cmactions $cm;
/**
* Returns an instance of the actions class for the given course format.
*
* @param base $format the course format.
*/
protected function __construct(base $format) {
$actionclasses = [
'course' => courseactions::class,
'section' => sectionactions::class,
'cm' => cmactions::class,
];
foreach ($actionclasses as $action => $classname) {
$formatalternative = 'format_' . $format->get_format() . '\\courseformat\\' . $action . 'actions';
if (class_exists($formatalternative)) {
if (!is_subclass_of($formatalternative, $classname)) {
throw new coding_exception("The \"$formatalternative\" must extend \"$classname\"");
}
$actionclasses[$action] = $formatalternative;
}
$this->$action = new $actionclasses[$action]($format->get_course());
}
}
/**
* Returns an instance of the actions class for the given course format.
* @param int|stdClass $courseorid course id or record.
* @return courseactions
*/
public static function course($courseorid): courseactions {
return self::instance($courseorid)->course;
}
/**
* Returns an instance of the actions class for the given course format.
*
* @param int|stdClass $courseorid course id or record.
* @return sectionactions
*/
public static function section($courseorid): sectionactions {
return self::instance($courseorid)->section;
}
/**
* Returns an instance of the actions class for the given course format.
* @param int|stdClass $courseorid course id or record.
* @return cmactions
*/
public static function cm($courseorid): cmactions {
return self::instance($courseorid)->cm;
}
/**
* Get a course action loader instance.
* @param int|stdClass $courseorid course id or course.
* @return self
*/
public static function instance(int|stdClass $courseorid): self {
$coursesectionscache = \cache::make('core', 'courseactionsinstances');
$format = base::instance($courseorid);
$courseid = $format->get_courseid();
$cachekey = "{$courseid}_{$format->get_format()}";
$cachedinstance = $coursesectionscache->get($cachekey);
if ($cachedinstance) {
return $cachedinstance;
}
$result = new self($format);
$coursesectionscache->set($cachekey, $result);
return $result;
}
}
@@ -0,0 +1,79 @@
<?php
// 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/>.
namespace core_courseformat\hook;
use core\hook\described_hook;
use cm_info;
/**
* Hook for course-module name edited.
*
* @package core_courseformat
* @copyright 2024 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class after_cm_name_edited implements described_hook {
/**
* Constructor.
*
* @param cm_info $cm the course module
* @param string $newname the new name
*/
public function __construct(
/** @var cm_info the course module */
protected cm_info $cm,
/** @var string the new name */
protected string $newname,
) {
}
/**
* Describes the hook purpose.
*
* @return string
*/
public static function get_hook_description(): string {
return 'This hook is triggered when a course module name is edited.';
}
/**
* List of tags that describe this hook.
*
* @return string[]
*/
public static function get_hook_tags(): array {
return ['cm_name_edited'];
}
/**
* Get course module instance.
*
* @return cm_info
*/
public function get_cm(): cm_info {
return $this->cm;
}
/**
* Get new name.
* @return string
*/
public function get_newname(): string {
return $this->newname;
}
}
@@ -0,0 +1,87 @@
<?php
// 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/>.
namespace core_courseformat\local;
use core_courseformat\base as course_format;
use section_info;
use cm_info;
use stdClass;
/**
* Format base actions.
*
* This class defined the format actions base class extended by the course, section and cm actions.
*
* It also provides helpers to get the most recent modinfo and format information. Those
* convenience methods are meant to improve the actions readability and prevent excessive
* message chains.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class baseactions {
/**
* @var stdClass the course object.
*/
protected stdClass $course;
/**
* Constructor.
* @param stdClass $course the course object.
*/
public function __construct(stdClass $course) {
$this->course = $course;
}
/**
* Get the course.
* @return stdClass the course object.
*/
protected function get_course(): stdClass {
return $this->course;
}
/**
* Get the course format.
* @return course_format the course format.
*/
protected function get_format(): course_format {
return course_format::instance($this->course);
}
/**
* Get the section info.
* @param int $sectionid the section id.
* @param int $strictness Use MUST_EXIST to throw exception if it doesn't
* @return section_info|null Information for numbered section or null if not found
*/
protected function get_section_info($sectionid, int $strictness = IGNORE_MISSING): ?section_info {
// Course actions must always get the most recent version of the section info.
return get_fast_modinfo($this->course->id)->get_section_info_by_id($sectionid, $strictness);
}
/**
* Get the cm info.
* @param int $cmid the cm id.
* @return cm_info|null Information for numbered cm or null if not found
*/
protected function get_cm_info($cmid): ?cm_info {
// Course actions must always get the most recent version of the cm info.
return get_fast_modinfo($this->course->id)->get_cm($cmid);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
// 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/>.
namespace core_courseformat\local;
use course_modinfo;
/**
* Course module course format actions.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class cmactions extends baseactions {
/**
* Rename a course module.
* @param int $cmid the course module id.
* @param string $name the new name.
* @return bool true if the course module was renamed, false otherwise.
*/
public function rename(int $cmid, string $name): bool {
global $CFG, $DB;
require_once($CFG->libdir . '/gradelib.php');
$paramcleaning = empty($CFG->formatstringstriptags) ? PARAM_CLEANHTML : PARAM_TEXT;
$name = clean_param($name, $paramcleaning);
if (empty($name)) {
return false;
}
if (\core_text::strlen($name) > 255) {
throw new \moodle_exception('maximumchars', 'moodle', '', 255);
}
// The name is stored in the activity instance record.
// However, events, gradebook and calendar API uses a legacy
// course module data extraction from the DB instead of a section_info.
$cm = get_coursemodule_from_id('', $cmid, 0, false, MUST_EXIST);
if ($name === $cm->name) {
return false;
}
$DB->update_record(
$cm->modname,
(object)[
'id' => $cm->instance,
'name' => $name,
'timemodified' => time(),
]
);
$cm->name = $name;
\core\event\course_module_updated::create_from_cm($cm)->trigger();
course_modinfo::purge_course_module_cache($cm->course, $cm->id);
rebuild_course_cache($cm->course, false, true);
// Modules may add some logic to renaming.
$modinfo = get_fast_modinfo($cm->course);
\core\di::get(\core\hook\manager::class)->dispatch(
new \core_courseformat\hook\after_cm_name_edited($modinfo->get_cm($cm->id), $name),
);
// Attempt to update the grade item if relevant.
$grademodule = $DB->get_record($cm->modname, ['id' => $cm->instance]);
$grademodule->cmidnumber = $cm->idnumber;
$grademodule->modname = $cm->modname;
grade_update_mod_grades($grademodule);
// Update calendar events with the new name.
course_module_update_calendar_events($cm->modname, $grademodule, $cm);
return true;
}
}
@@ -0,0 +1,28 @@
<?php
// 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/>.
namespace core_courseformat\local;
/**
* Course actions.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class courseactions extends baseactions {
// All general course actions will go here.
}
@@ -0,0 +1,452 @@
<?php
// 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/>.
namespace core_courseformat\local;
use section_info;
use stdClass;
use core\event\course_module_updated;
use core\event\course_section_deleted;
/**
* Section course format actions.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class sectionactions extends baseactions {
/**
* Create a course section using a record object.
*
* If $fields->section is not set, the section is added to the end of the course.
*
* @param stdClass $fields the fields to set on the section
* @param bool $skipcheck the position check has already been made and we know it can be used
* @return stdClass the created section record
*/
protected function create_from_object(stdClass $fields, bool $skipcheck = false): stdClass {
global $DB;
[
'position' => $position,
'lastsection' => $lastsection,
] = $this->calculate_positions($fields, $skipcheck);
// First add section to the end.
$sectionrecord = (object) [
'course' => $this->course->id,
'section' => $lastsection + 1,
'summary' => $fields->summary ?? '',
'summaryformat' => $fields->summaryformat ?? FORMAT_HTML,
'sequence' => '',
'name' => $fields->name ?? null,
'visible' => $fields->visible ?? 1,
'availability' => null,
'component' => $fields->component ?? null,
'itemid' => $fields->itemid ?? null,
'timemodified' => time(),
];
$sectionrecord->id = $DB->insert_record("course_sections", $sectionrecord);
// Now move it to the specified position.
if ($position > 0 && $position <= $lastsection) {
move_section_to($this->course, $sectionrecord->section, $position, true);
$sectionrecord->section = $position;
}
\core\event\course_section_created::create_from_section($sectionrecord)->trigger();
rebuild_course_cache($this->course->id, true);
return $sectionrecord;
}
/**
* Calculate the position and lastsection values.
*
* Each section number must be unique inside a course. However, the section creation is not always
* explicit about the final position. By default, regular sections are created at the last position.
* However, delegated section can alter that order, because all delegated sections should have higher
* numbers. Apart, restore operations can also create sections with a forced specific number.
*
* This method returns what is the best position for a new section data and, also, what is the current
* last section number. The last section is needed to decide if the new section must be moved or not after
* insertion.
*
* @param stdClass $fields the fields to set on the section
* @param bool $skipcheck the position check has already been made and we know it can be used
* @return array with the new section position (position key) and the course last section value (lastsection key)
*/
private function calculate_positions($fields, $skipcheck): array {
if (!isset($fields->section)) {
$skipcheck = false;
}
if ($skipcheck) {
return [
'position' => $fields->section,
'lastsection' => $fields->section - 1,
];
}
$lastsection = $this->get_last_section_number();
if (!empty($fields->component)) {
return [
'position' => $fields->section ?? $lastsection + 1,
'lastsection' => $lastsection,
];
}
return [
'position' => $fields->section ?? $this->get_last_section_number(false) + 1,
'lastsection' => $lastsection,
];
}
/**
* Get the last section number in the course.
* @param bool $includedelegated whether to include delegated sections
* @return int
*/
protected function get_last_section_number(bool $includedelegated = true): int {
global $DB;
$delegtadefilter = $includedelegated ? '' : ' AND component IS NULL';
return (int) $DB->get_field_sql(
'SELECT max(section) from {course_sections} WHERE course = ?' . $delegtadefilter,
[$this->course->id]
);
}
/**
* Create a delegated section.
*
* @param string $component the name of the plugin
* @param int|null $itemid the id of the delegated section
* @param stdClass|null $fields the fields to set on the section
* @return section_info the created section
*/
public function create_delegated(
string $component,
?int $itemid = null,
?stdClass $fields = null
): section_info {
$record = ($fields) ? clone $fields : new stdClass();
$record->component = $component;
$record->itemid = $itemid;
$record = $this->create_from_object($record);
return $this->get_section_info($record->id);
}
/**
* Creates a course section and adds it to the specified position
*
* This method returns a section record, not a section_info object. This prevents the regeneration
* of the modinfo object each time we create a section.
*
* If position is greater than number of existing sections, the section is added to the end.
* This will become sectionnum of the new section. All existing sections at this or bigger
* position will be shifted down.
*
* @param int $position The position to add to, 0 means to the end.
* @param bool $skipcheck the check has already been made and we know that the section with this position does not exist
* @return stdClass created section object
*/
public function create(int $position = 0, bool $skipcheck = false): stdClass {
$record = (object) [
'section' => ($position == 0 && !$skipcheck) ? null : $position,
];
return $this->create_from_object($record, $skipcheck);
}
/**
* Create course sections if they are not created yet.
*
* The calculations will ignore sections delegated to components.
* If the section is created, all delegated sections will be pushed down.
*
* @param int[] $sectionnums the section numbers to create
* @return bool whether any section was created
*/
public function create_if_missing(array $sectionnums): bool {
$result = false;
$modinfo = get_fast_modinfo($this->course);
// Ensure we add the sections in order.
sort($sectionnums);
// Delegated sections must be displaced when creating a regular section.
$skipcheck = !$modinfo->has_delegated_sections();
$sections = $modinfo->get_section_info_all();
foreach ($sectionnums as $sectionnum) {
if (isset($sections[$sectionnum]) && empty($sections[$sectionnum]->component)) {
continue;
}
$this->create($sectionnum, $skipcheck);
$result = true;
}
return $result;
}
/**
* Delete a course section.
* @param section_info $sectioninfo the section to delete.
* @param bool $forcedeleteifnotempty whether to force section deletion if it contains modules.
* @param bool $async whether or not to try to delete the section using an adhoc task. Async also depends on a plugin hook.
* @return bool whether section was deleted
*/
public function delete(section_info $sectioninfo, bool $forcedeleteifnotempty = true, bool $async = false): bool {
// Check the 'course_module_background_deletion_recommended' hook first.
// Only use asynchronous deletion if at least one plugin returns true and if async deletion has been requested.
// Both are checked because plugins should not be allowed to dictate the deletion behaviour, only support/decline it.
// It's up to plugins to handle things like whether or not they are enabled.
if ($async && $pluginsfunction = get_plugins_with_function('course_module_background_deletion_recommended')) {
foreach ($pluginsfunction as $plugintype => $plugins) {
foreach ($plugins as $pluginfunction) {
if ($pluginfunction()) {
return $this->delete_async($sectioninfo, $forcedeleteifnotempty);
}
}
}
}
return $this->delete_format_data(
$sectioninfo,
$forcedeleteifnotempty,
$this->get_delete_event($sectioninfo)
);
}
/**
* Get the event to trigger when deleting a section.
* @param section_info $sectioninfo the section to delete.
* @return course_section_deleted the event to trigger
*/
protected function get_delete_event(section_info $sectioninfo): course_section_deleted {
global $DB;
// Section record is needed for the event snapshot.
$sectionrecord = $DB->get_record('course_sections', ['id' => $sectioninfo->id]);
$format = course_get_format($this->course);
$sectionname = $format->get_section_name($sectioninfo);
$context = \context_course::instance($this->course->id);
$event = course_section_deleted::create(
[
'objectid' => $sectioninfo->id,
'courseid' => $this->course->id,
'context' => $context,
'other' => [
'sectionnum' => $sectioninfo->section,
'sectionname' => $sectionname,
],
]
);
$event->add_record_snapshot('course_sections', $sectionrecord);
return $event;
}
/**
* Delete a course section.
* @param section_info $sectioninfo the section to delete.
* @param bool $forcedeleteifnotempty whether to force section deletion if it contains modules.
* @param course_section_deleted $event the event to trigger
* @return bool whether section was deleted
*/
protected function delete_format_data(
section_info $sectioninfo,
bool $forcedeleteifnotempty,
course_section_deleted $event
): bool {
$format = course_get_format($this->course);
$result = $format->delete_section($sectioninfo, $forcedeleteifnotempty);
if ($result) {
$event->trigger();
}
rebuild_course_cache($this->course->id, true);
return $result;
}
/**
* Course section deletion, using an adhoc task for deletion of the modules it contains.
* 1. Schedule all modules within the section for adhoc removal.
* 2. Move all modules to course section 0.
* 3. Delete the resulting empty section.
*
* @param section_info $sectioninfo the section to schedule for deletion.
* @param bool $forcedeleteifnotempty whether to force section deletion if it contains modules.
* @return bool true if the section was scheduled for deletion, false otherwise.
*/
protected function delete_async(section_info $sectioninfo, bool $forcedeleteifnotempty = true): bool {
global $DB, $USER;
if (!$forcedeleteifnotempty && (!empty($sectioninfo->sequence) || !empty($sectioninfo->summary))) {
return false;
}
// Event needs to be created before the section activities are moved to section 0.
$event = $this->get_delete_event($sectioninfo);
$affectedmods = $DB->get_records_select(
'course_modules',
'course = ? AND section = ? AND deletioninprogress <> ?',
[$this->course->id, $sectioninfo->id, 1],
'',
'id'
);
// Flag those modules having no existing deletion flag. Some modules may have been
// scheduled for deletion manually, and we don't want to create additional adhoc deletion
// tasks for these. Moving them to section 0 will suffice.
$DB->set_field(
'course_modules',
'deletioninprogress',
'1',
['course' => $this->course->id, 'section' => $sectioninfo->id]
);
// Move all modules to section 0.
$sectionzero = $DB->get_record('course_sections', ['course' => $this->course->id, 'section' => '0']);
$modules = $DB->get_records('course_modules', ['section' => $sectioninfo->id], '');
foreach ($modules as $mod) {
moveto_module($mod, $sectionzero);
}
$removaltask = new \core_course\task\course_delete_modules();
$data = [
'cms' => $affectedmods,
'userid' => $USER->id,
'realuserid' => \core\session\manager::get_realuser()->id,
];
$removaltask->set_custom_data($data);
\core\task\manager::queue_adhoc_task($removaltask);
// Ensure we have the latest section info.
$sectioninfo = $this->get_section_info($sectioninfo->id);
return $this->delete_format_data($sectioninfo, $forcedeleteifnotempty, $event);
}
/**
* Update a course section.
*
* @param section_info $sectioninfo the section info or database record to update.
* @param array|stdClass $fields the fields to update.
* @return bool whether section was updated
*/
public function update(section_info $sectioninfo, array|stdClass $fields): bool {
global $DB;
$courseid = $this->course->id;
// Some fields can not be updated using this method.
$fields = array_diff_key((array) $fields, array_flip(['id', 'course', 'section', 'sequence']));
if (array_key_exists('name', $fields) && \core_text::strlen($fields['name']) > 255) {
throw new \moodle_exception('maximumchars', 'moodle', '', 255);
}
// If the section is delegated to a component, it may control some section values.
$fields = $this->preprocess_delegated_section_fields($sectioninfo, $fields);
if (empty($fields)) {
return false;
}
$fields['id'] = $sectioninfo->id;
$fields['timemodified'] = time();
$DB->update_record('course_sections', $fields);
// We need to update the section cache before the format options are updated.
\course_modinfo::purge_course_section_cache_by_id($courseid, $sectioninfo->id);
rebuild_course_cache($courseid, false, true);
course_get_format($courseid)->update_section_format_options($fields);
$event = \core\event\course_section_updated::create(
[
'objectid' => $sectioninfo->id,
'courseid' => $courseid,
'context' => \context_course::instance($courseid),
'other' => ['sectionnum' => $sectioninfo->section],
]
);
$event->trigger();
if (isset($fields['visible'])) {
$this->transfer_visibility_to_cms($sectioninfo, (bool) $fields['visible']);
}
return true;
}
/**
* Transfer the visibility of the section to the course modules.
*
* @param section_info $sectioninfo the section info or database record to update.
* @param bool $visibility the new visibility of the section.
*/
protected function transfer_visibility_to_cms(section_info $sectioninfo, bool $visibility): void {
global $DB;
if (empty($sectioninfo->sequence) || $visibility == (bool) $sectioninfo->visible) {
return;
}
$modules = explode(',', $sectioninfo->sequence);
$cmids = [];
foreach ($modules as $moduleid) {
$cm = get_coursemodule_from_id(null, $moduleid, $this->course->id);
if (!$cm) {
continue;
}
$modupdated = false;
if ($visibility) {
// As we unhide the section, we use the previously saved visibility stored in visibleold.
$modupdated = set_coursemodule_visible($moduleid, $cm->visibleold, $cm->visibleoncoursepage, false);
} else {
// We hide the section, so we hide the module but we store the original state in visibleold.
$modupdated = set_coursemodule_visible($moduleid, 0, $cm->visibleoncoursepage, false);
if ($modupdated) {
$DB->set_field('course_modules', 'visibleold', $cm->visible, ['id' => $moduleid]);
}
}
if ($modupdated) {
$cmids[] = $cm->id;
course_module_updated::create_from_cm($cm)->trigger();
}
}
\course_modinfo::purge_course_modules_cache($this->course->id, $cmids);
rebuild_course_cache($this->course->id, false, true);
}
/**
* Preprocess the section fields before updating a delegated section.
*
* @param section_info $sectioninfo the section info or database record to update.
* @param array $fields the fields to update.
* @return array the updated fields
*/
protected function preprocess_delegated_section_fields(section_info $sectioninfo, array $fields): array {
$delegated = $sectioninfo->get_component_instance();
if (!$delegated) {
return $fields;
}
if (array_key_exists('name', $fields)) {
$fields['name'] = $delegated->preprocess_section_name($sectioninfo, $fields['name']);
}
return $fields;
}
}
@@ -0,0 +1,129 @@
<?php
// 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/>.
namespace core_courseformat\output;
use cm_info;
use core_courseformat\output\local\courseformat_named_templatable;
use core\output\named_templatable;
use renderer_base;
use stdClass;
/**
* Base class to render an activity badge.
*
* Plugins can extend this class and override some methods to customize the content to be displayed in the activity badge.
*
* @package core_courseformat
* @copyright 2023 Sara Arjona <sara@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class activitybadge implements named_templatable, \renderable {
use courseformat_named_templatable;
/** @var array Badge defined styles. */
public const STYLES = [
'none' => 'badge-none',
'dark' => 'bg-dark text-white',
'danger' => 'bg-danger text-white',
'warning' => 'bg-warning text-dark',
'info' => 'bg-info text-white',
];
/** @var cm_info The course module information. */
protected $cminfo = null;
/** @var string The content to be displayed in the activity badge. */
protected $content = null;
/** @var string The style for the activity badge. */
protected $style = self::STYLES['none'];
/** @var \moodle_url An optional URL to redirect the user when the activity badge is clicked. */
protected $url = null;
/** @var string An optional element id in case the module wants to add some code for the activity badge (events, CSS...). */
protected $elementid = null;
/**
* @var array An optional array of extra HTML attributes to add to the badge element (for example, data attributes).
* The format for this array is [['name' => 'attr1', 'value' => 'attrval1'], ['name' => 'attr2', 'value' => 'attrval2']].
*/
protected $extraattributes = [];
/**
* Constructor.
*
* @param cm_info $cminfo The course module information.
*/
public function __construct(cm_info $cminfo) {
$this->cminfo = $cminfo;
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
final public function export_for_template(renderer_base $output): stdClass {
$this->update_content();
if (empty($this->content)) {
return new stdClass();
}
$data = (object)[
'badgecontent' => $this->content,
'badgestyle' => $this->style,
];
if (!empty($this->url)) {
$data->badgeurl = $this->url->out();
}
if (!empty($this->elementid)) {
$data->badgeelementid = $this->elementid;
}
if (!empty($this->extraattributes)) {
$data->badgeextraattributes = $this->extraattributes;
}
return $data;
}
/**
* Creates an instance of activityclass for the given course module, in case it implements it.
*
* @param cm_info $cminfo
* @return self|null An instance of activityclass for the given module or null if the module doesn't implement it.
*/
final public static function create_instance(cm_info $cminfo): ?self {
$classname = '\mod_' . $cminfo->modname . '\output\courseformat\activitybadge';
if (!class_exists($classname)) {
return null;
}
return new $classname($cminfo);
}
/**
* This method will be called before exporting the template.
*
* It should be implemented by any module extending this class and will be in charge of updating any of the class attributes
* with the proper information that will be displayed in the activity badge (like the content or the badge style).
*/
abstract protected function update_content(): void;
}
@@ -0,0 +1,34 @@
<?php
// 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/>.
namespace core_courseformat\output;
use core_courseformat\output\section_renderer;
/**
* Legacy course format renderer.
*
* Since Moodle 4.0, renderer.php file was optional (although highly recommended) for course formats. From Moodle 4.0 onwards,
* renderer is required to support the new course editor implementation.
* This legacy class has been created for backward compatibility, to avoid some errors with course formats (such as social)
* without this renderer.php file.
*
* @package core_courseformat
* @copyright 2021 Sara Arjona (sara@moodle.com)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class legacy_renderer extends section_renderer {
}
@@ -0,0 +1,206 @@
<?php
// 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/>.
namespace core_courseformat\output\local;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use course_modinfo;
use renderable;
/**
* Base class to render a course format.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class content implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var \core_courseformat\base the course format class */
protected $format;
/** @var string the section format class */
protected $sectionclass;
/** @var string the add section output class name */
protected $addsectionclass;
/** @var string section navigation class name */
protected $sectionnavigationclass;
/** @var string section selector class name */
protected $sectionselectorclass;
/** @var string the section control menu class */
protected $sectioncontrolmenuclass;
/** @var string bulk editor bar toolbox */
protected $bulkedittoolsclass;
/** @var bool if uses add section */
protected $hasaddsection = true;
/**
* Constructor.
*
* @param course_format $format the coruse format
*/
public function __construct(course_format $format) {
$this->format = $format;
// Load output classes names from format.
$this->sectionclass = $format->get_output_classname('content\\section');
$this->addsectionclass = $format->get_output_classname('content\\addsection');
$this->sectionnavigationclass = $format->get_output_classname('content\\sectionnavigation');
$this->sectionselectorclass = $format->get_output_classname('content\\sectionselector');
$this->bulkedittoolsclass = $format->get_output_classname('content\\bulkedittools');
$this->sectioncontrolmenuclass = $format->get_output_classname('content\\section\\controlmenu');
}
/**
* Export this data so it can be used as the context for a mustache template (core/inplace_editable).
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return \stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output) {
global $PAGE;
$format = $this->format;
$sections = $this->export_sections($output);
$initialsection = '';
$data = (object)[
'title' => $format->page_title(), // This method should be in the course_format class.
'initialsection' => $initialsection,
'sections' => $sections,
'format' => $format->get_format(),
'sectionreturn' => null,
];
// The single section format has extra navigation.
if ($this->format->get_sectionid()) {
$singlesectionnum = $this->format->get_sectionnum();
if (!$PAGE->theme->usescourseindex) {
$sectionnavigation = new $this->sectionnavigationclass($format, $singlesectionnum);
$data->sectionnavigation = $sectionnavigation->export_for_template($output);
$sectionselector = new $this->sectionselectorclass($format, $sectionnavigation);
$data->sectionselector = $sectionselector->export_for_template($output);
}
$data->hasnavigation = true;
$data->singlesection = array_shift($data->sections);
$data->sectionreturn = $singlesectionnum;
}
if ($this->hasaddsection) {
$addsection = new $this->addsectionclass($format);
$data->numsections = $addsection->export_for_template($output);
}
if ($format->show_editor()) {
$bulkedittools = new $this->bulkedittoolsclass($format);
$data->bulkedittools = $bulkedittools->export_for_template($output);
}
return $data;
}
/**
* Retrieves the action menu for the page header of the local content section.
*
* @param \renderer_base $output The renderer object used for rendering the action menu.
* @return string|null The rendered action menu HTML, null if page no action menu is available.
*/
public function get_page_header_action(\renderer_base $output): ?string {
$sectionid = $this->format->get_sectionid();
if ($sectionid !== null) {
$modinfo = $this->format->get_modinfo();
$sectioninfo = $modinfo->get_section_info_by_id($sectionid);
/** @var \core_courseformat\output\local\content\section\controlmenu */
$controlmenu = new $this->sectioncontrolmenuclass($this->format, $sectioninfo);
return $output->render($controlmenu->get_action_menu($output));
}
return null;
}
/**
* Export sections array data.
*
* @param renderer_base $output typically, the renderer that's calling this function
* @return array data context for a mustache template
*/
protected function export_sections(\renderer_base $output): array {
$format = $this->format;
$course = $format->get_course();
$modinfo = $this->format->get_modinfo();
// Generate section list.
$sections = [];
$stealthsections = [];
$numsections = $format->get_last_section_number();
foreach ($this->get_sections_to_display($modinfo) as $sectionnum => $thissection) {
// The course/view.php check the section existence but the output can be called
// from other parts so we need to check it.
if (!$thissection) {
throw new \moodle_exception('unknowncoursesection', 'error', course_get_url($course),
format_string($course->fullname));
}
$section = new $this->sectionclass($format, $thissection);
if ($sectionnum > $numsections) {
// Activities inside this section are 'orphaned', this section will be printed as 'stealth' below.
if (!empty($modinfo->sections[$sectionnum])) {
$stealthsections[] = $section->export_for_template($output);
}
continue;
}
if (!$format->is_section_visible($thissection)) {
continue;
}
$sections[] = $section->export_for_template($output);
}
if (!empty($stealthsections)) {
$sections = array_merge($sections, $stealthsections);
}
return $sections;
}
/**
* Return an array of sections to display.
*
* This method is used to differentiate between display a specific section
* or a list of them.
*
* @param course_modinfo $modinfo the current course modinfo object
* @return section_info[] an array of section_info to display
*/
private function get_sections_to_display(course_modinfo $modinfo): array {
$singlesectionid = $this->format->get_sectionid();
if ($singlesectionid) {
return [
$modinfo->get_section_info_by_id($singlesectionid),
];
}
return $modinfo->get_listed_section_info_all();
}
}
@@ -0,0 +1,164 @@
<?php
// 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/>.
/**
* Contains the default section course format output class.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use moodle_url;
use renderable;
use stdClass;
/**
* Base class to render a course add section buttons.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class addsection implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format class */
protected $format;
/**
* Constructor.
*
* @param course_format $format the course format
*/
public function __construct(course_format $format) {
$this->format = $format;
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): stdClass {
// If no editor must be displayed, just return an empty structure.
if (!$this->format->show_editor(['moodle/course:update'])) {
return new stdClass();
}
$format = $this->format;
$course = $format->get_course();
$options = $format->get_format_options();
$lastsection = $format->get_last_section_number();
$maxsections = $format->get_max_sections();
// Component based formats handle add section button in the frontend.
$show = ($lastsection < $maxsections) || $format->supports_components();
$supportsnumsections = array_key_exists('numsections', $options);
if ($supportsnumsections) {
$data = $this->get_num_sections_data($output, $lastsection, $maxsections);
} else if (course_get_format($course)->uses_sections() && $show) {
$data = $this->get_add_section_data($output, $lastsection, $maxsections);
}
if (count((array)$data)) {
$data->showaddsection = true;
}
return $data;
}
/**
* Get the legacy num section add/remove section buttons data.
*
* Current course format has 'numsections' option, which is very confusing and we suggest course format
* developers to get rid of it (see MDL-57769 on how to do it).
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @param int $lastsection the last section number
* @param int $maxsections the maximum number of sections
* @return stdClass data context for a mustache template
*/
protected function get_num_sections_data(\renderer_base $output, int $lastsection, int $maxsections): stdClass {
$format = $this->format;
$course = $format->get_course();
$data = new stdClass();
if ($lastsection < $maxsections) {
$data->increase = (object) [
'url' => new moodle_url(
'/course/changenumsections.php',
['courseid' => $course->id, 'increase' => true, 'sesskey' => sesskey()]
),
];
}
if ($course->numsections > 0) {
$data->decrease = (object) [
'url' => new moodle_url(
'/course/changenumsections.php',
['courseid' => $course->id, 'increase' => false, 'sesskey' => sesskey()]
),
];
}
return $data;
}
/**
* Get the add section button data.
*
* Current course format does not have 'numsections' option but it has multiple sections suppport.
* Display the "Add section" link that will insert a section in the end.
* Note to course format developers: inserting sections in the other positions should check both
* capabilities 'moodle/course:update' and 'moodle/course:movesections'.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @param int $lastsection the last section number
* @param int $maxsections the maximum number of sections
* @return stdClass data context for a mustache template
*/
protected function get_add_section_data(\renderer_base $output, int $lastsection, int $maxsections): stdClass {
$format = $this->format;
$course = $format->get_course();
$data = new stdClass();
$addstring = $format->get_format_string('addsection');
$params = ['courseid' => $course->id, 'insertsection' => 0, 'sesskey' => sesskey()];
$singlesection = $this->format->get_sectionnum();
if ($singlesection) {
$params['sectionreturn'] = $singlesection;
}
$data->addsections = (object) [
'url' => new moodle_url('/course/changenumsections.php', $params),
'title' => $addstring,
'newsection' => $maxsections - $lastsection,
];
return $data;
}
}
@@ -0,0 +1,69 @@
<?php
// 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/>.
namespace core_courseformat\output\local\content;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use renderable;
/**
* Course bulk edit mode toggler button.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class bulkedittoggler implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var core_courseformat\base the course format class */
protected $format;
/**
* Constructor.
*
* @param course_format $format the course format
*/
public function __construct(course_format $format) {
$this->format = $format;
}
/**
* Export this data so it can be used as the context for a mustache template (core/inplace_editable).
*
* @param renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output) {
$section = optional_param('section', 0, PARAM_INT);
$format = $this->format;
$course = $format->get_course();
$data = (object)[
'id' => $course->id,
'coursename' => format_string($course->fullname),
];
if ($section) {
$data->sectionname = get_string('sectionname', "format_$course->format");
$data->sectiontitle = get_section_name($course, $section);
}
return $data;
}
}
@@ -0,0 +1,200 @@
<?php
// 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/>.
namespace core_courseformat\output\local\content;
use core\moodlenet\utilities;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use renderable;
use stdClass;
/**
* Contains the bulk editor tools bar.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class bulkedittools implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var core_courseformat\base the course format class */
protected $format;
/**
* Constructor.
*
* @param course_format $format the course format
*/
public function __construct(course_format $format) {
$this->format = $format;
}
/**
* Export this data so it can be used as the context for a mustache template (core/inplace_editable).
*
* @param renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): stdClass {
$format = $this->format;
$course = $format->get_course();
$data = (object)[
'id' => $course->id,
'actions' => $this->get_toolbar_actions(),
];
$data->hasactions = !empty($data->actions);
return $data;
}
/**
* Get the toolbar actions.
* @return array the array of buttons
*/
protected function get_toolbar_actions(): array {
return array_merge(
array_values($this->section_control_items()),
array_values($this->cm_control_items()),
);
}
/**
* Generate the bulk edit control items of a course module.
*
* Format plugins can override the method to add or remove elements
* from the toolbar.
*
* @return array of edit control items
*/
protected function cm_control_items(): array {
global $CFG, $USER;
$format = $this->format;
$context = $format->get_context();
$user = $USER;
$controls = [];
if (has_capability('moodle/course:activityvisibility', $context, $user)) {
$controls['availability'] = [
'icon' => 't/show',
'action' => 'cmAvailability',
'name' => get_string('availability'),
'title' => get_string('cmavailability', 'core_courseformat'),
'bulk' => 'cm',
];
}
$duplicatecapabilities = ['moodle/backup:backuptargetimport', 'moodle/restore:restoretargetimport'];
if (has_all_capabilities($duplicatecapabilities, $context, $user)) {
$controls['duplicate'] = [
'icon' => 't/copy',
'action' => 'cmDuplicate',
'name' => get_string('duplicate'),
'title' => get_string('cmsduplicate', 'core_courseformat'),
'bulk' => 'cm',
];
}
$hasmanageactivities = has_capability('moodle/course:manageactivities', $context, $user);
if ($hasmanageactivities) {
$controls['move'] = [
'icon' => 'i/dragdrop',
'action' => 'moveCm',
'name' => get_string('move'),
'title' => get_string('cmsmove', 'core_courseformat'),
'bulk' => 'cm',
];
$controls['delete'] = [
'icon' => 'i/delete',
'action' => 'cmDelete',
'name' => get_string('delete'),
'title' => get_string('cmsdelete', 'core_courseformat'),
'bulk' => 'cm',
];
}
$usercanshare = utilities::can_user_share($context, $user->id, 'course');
if ($CFG->enablesharingtomoodlenet && $usercanshare) {
$controls['sharetomoodlenet'] = [
'id' => 'cmShareToMoodleNet',
'icon' => 'i/share',
'action' => 'cmShareToMoodleNet',
'name' => get_string('moodlenet:sharetomoodlenet'),
'title' => get_string('moodlenet:sharetomoodlenet'),
'bulk' => 'cm',
];
}
return $controls;
}
/**
* Generate the bulk edit control items of a section.
*
* Format plugins can override the method to add or remove elements
* from the toolbar.
*
* @return array of edit control items
*/
protected function section_control_items(): array {
global $USER;
$format = $this->format;
$context = $format->get_context();
$sectionreturn = $format->get_sectionnum();
$user = $USER;
$controls = [];
if (has_capability('moodle/course:sectionvisibility', $context, $user)) {
$controls['availability'] = [
'icon' => 't/show',
'action' => 'sectionAvailability',
'name' => get_string('availability'),
'title' => $this->format->get_format_string('sectionsavailability'),
'bulk' => 'section',
];
}
if (!$sectionreturn && has_capability('moodle/course:movesections', $context, $user)) {
$controls['move'] = [
'icon' => 'i/dragdrop',
'action' => 'moveSection',
'name' => get_string('move', 'moodle'),
'title' => $this->format->get_format_string('sectionsmove'),
'bulk' => 'section',
];
}
$deletecapabilities = ['moodle/course:movesections', 'moodle/course:update'];
if (!$sectionreturn && has_all_capabilities($deletecapabilities, $context, $user)) {
$controls['delete'] = [
'icon' => 'i/delete',
'action' => 'deleteSection',
'name' => get_string('delete'),
'title' => $this->format->get_format_string('sectionsdelete'),
'bulk' => 'section',
];
}
return $controls;
}
}
@@ -0,0 +1,410 @@
<?php
// 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/>.
/**
* Contains the default activity list from a section.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content;
use cm_info;
use context_course;
use core\output\named_templatable;
use core_availability\info_module;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use renderable;
use renderer_base;
use section_info;
use stdClass;
/**
* Base class to render a course module inside a course format.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class cm implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format */
protected $format;
/** @var section_info the section object */
private $section;
/** @var cm_info the course module instance */
protected $mod;
/** @var array optional display options */
protected $displayoptions;
/** @var string the activity name output class name */
protected $cmnameclass;
/** @var string the activity control menu class name */
protected $controlmenuclass;
/** @var string the activity availability class name */
protected $availabilityclass;
/** @var string the activity completion class name */
protected $completionclass;
/** @var string the activity visibility class name */
protected $visibilityclass;
/** @var string the activity groupmode badge class name */
protected $groupmodeclass;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module ionfo
* @param array $displayoptions optional extra display options
*/
public function __construct(course_format $format, section_info $section, cm_info $mod, array $displayoptions = []) {
$this->format = $format;
$this->section = $section;
$this->mod = $mod;
// Add extra display options.
$this->displayoptions = $displayoptions;
$this->load_classes();
// Get the necessary classes.
$this->cmnameclass = $format->get_output_classname('content\\cm\\cmname');
$this->controlmenuclass = $format->get_output_classname('content\\cm\\controlmenu');
$this->availabilityclass = $format->get_output_classname('content\\cm\\availability');
$this->completionclass = $format->get_output_classname('content\\cm\\completion');
$this->visibilityclass = $format->get_output_classname('content\\cm\\visibility');
$this->groupmodeclass = $format->get_output_classname('content\\cm\\groupmode');
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(renderer_base $output): stdClass {
global $PAGE;
$mod = $this->mod;
$displayoptions = $this->displayoptions;
$data = (object)[
'grouping' => $mod->get_grouping_label($displayoptions['textclasses']),
'modname' => get_string('pluginname', 'mod_' . $mod->modname),
'url' => $mod->url,
'activityname' => $mod->get_formatted_name(),
'textclasses' => $displayoptions['textclasses'],
'classlist' => [],
'cmid' => $mod->id,
'editing' => $PAGE->user_is_editing(),
'sectionnum' => $this->section->section,
];
// Add partial data segments.
$haspartials = [];
$haspartials['cmname'] = $this->add_cm_name_data($data, $output);
$haspartials['availability'] = $this->add_availability_data($data, $output);
$haspartials['alternative'] = $this->add_alternative_content_data($data, $output);
$haspartials['completion'] = $this->add_completion_data($data, $output);
$haspartials['dates'] = $this->add_dates_data($data, $output);
$haspartials['editor'] = $this->add_editor_data($data, $output);
$haspartials['groupmode'] = $this->add_groupmode_data($data, $output);
$haspartials['visibility'] = $this->add_visibility_data($data, $output);
$this->add_format_data($data, $haspartials, $output);
// Calculated fields.
if (!empty($data->url)) {
$data->hasurl = true;
}
return $data;
}
/**
* Add course module name attributes to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has name data
*/
protected function add_cm_name_data(stdClass &$data, renderer_base $output): bool {
// Mod inplace name editable.
$cmname = new $this->cmnameclass(
$this->format,
$this->section,
$this->mod,
null,
$this->displayoptions
);
$data->cmname = $cmname->export_for_template($output);
$data->hasname = $cmname->has_name();
return $data->hasname;
}
/**
* Add the module availability to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has mod availability
*/
protected function add_availability_data(stdClass &$data, renderer_base $output): bool {
if (!$this->mod->visible) {
$data->modavailability = null;
return false;
}
// Mod availability output class.
$availability = new $this->availabilityclass(
$this->format,
$this->section,
$this->mod,
$this->displayoptions
);
$modavailability = $availability->export_for_template($output);
$data->modavailability = $modavailability;
return $availability->has_availability($output);
}
/**
* Add the alternative content to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has alternative content
*/
protected function add_alternative_content_data(stdClass &$data, renderer_base $output): bool {
$altcontent = $this->mod->get_formatted_content(
['overflowdiv' => true, 'noclean' => true]
);
$data->altcontent = (empty($altcontent)) ? false : $altcontent;
$data->afterlink = $this->mod->afterlink;
$activitybadgedata = $this->mod->get_activitybadge($output);
if (!empty($activitybadgedata)) {
$data->activitybadge = $activitybadgedata;
}
return !empty($data->altcontent);
}
/**
* Add activity dates information to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool the module has completion information
*/
protected function add_dates_data(stdClass &$data, renderer_base $output): bool {
global $USER;
$course = $this->mod->get_course();
if (!$course->showactivitydates) {
return false;
}
$activitydates = \core\activity_dates::get_dates_for_module($this->mod, $USER->id);
$templatedata = new \core_course\output\activity_dates($activitydates);
$data->dates = $templatedata->export_for_template($output);
return $data->dates->hasdates;
}
/**
* Add activity completion information to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool the module has completion information
*/
protected function add_completion_data(stdClass &$data, renderer_base $output): bool {
$completion = new $this->completionclass($this->format, $this->section, $this->mod);
$templatedata = $completion->export_for_template($output);
if ($templatedata) {
$data->completion = $templatedata;
return true;
}
return false;
}
/**
* Add activity information to the data structure.
*
* @param stdClass $data the current cm data reference
* @param bool[] $haspartials the result of loading partial data elements
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has format data
*/
protected function add_format_data(stdClass &$data, array $haspartials, renderer_base $output): bool {
$result = false;
// Legacy indentation.
if (!empty($this->mod->indent) && $this->format->uses_indentation()) {
$data->indent = $this->mod->indent;
if ($this->mod->indent > 15) {
$data->hugeindent = true;
$result = true;
}
}
// Stealth and hidden from student.
if (!$this->mod->visible) {
// This module is hidden but current user has capability to see it.
$data->modhiddenfromstudents = true;
$result = true;
} else if ($this->mod->is_stealth()) {
// This module is available but is normally not displayed on the course page
// (this user can see it because they can manage it).
$data->modstealth = true;
$result = true;
}
// Special inline activity format.
if (
$this->mod->has_custom_cmlist_item() &&
!$haspartials['availability'] &&
!$haspartials['completion'] &&
!$haspartials['dates'] &&
!$haspartials['groupmode'] &&
!isset($data->modhiddenfromstudents) &&
!isset($data->modstealth) &&
!$this->format->show_editor()
) {
$data->modinline = true;
$result = true;
}
return $result;
}
/**
* Add course editor attributes to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has editor data
*/
protected function add_editor_data(stdClass &$data, renderer_base $output): bool {
$course = $this->format->get_course();
$coursecontext = context_course::instance($course->id);
$editcaps = [];
if (has_capability('moodle/course:activityvisibility', $coursecontext)) {
$editcaps = ['moodle/course:activityvisibility'];
}
if (!$this->format->show_editor($editcaps)) {
return false;
}
$returnsection = $this->format->get_sectionnum();
// Edit actions.
$controlmenu = new $this->controlmenuclass(
$this->format,
$this->section,
$this->mod,
$this->displayoptions
);
$data->controlmenu = $controlmenu->export_for_template($output);
if (!$this->format->supports_components()) {
// Add the legacy YUI move link.
$data->moveicon = course_get_cm_move($this->mod, $returnsection);
}
return true;
}
/**
* Add group mode information to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool the module has group mode information
*/
protected function add_groupmode_data(stdClass &$data, renderer_base $output): bool {
$groupmode = new $this->groupmodeclass($this->format, $this->section, $this->mod);
$data->groupmodeinfo = $groupmode->export_for_template($output);
return !empty($data->groupmodeinfo);
}
/**
* Add visibility information to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has visibility data
*/
protected function add_visibility_data(stdClass &$data, renderer_base $output): bool {
$visibility = new $this->visibilityclass($this->format, $this->section, $this->mod);
$templatedata = $visibility->export_for_template($output);
if ($templatedata) {
$data->visibility = $templatedata;
return true;
}
return false;
}
/**
* Returns the CSS classes for the activity name/content
*
*/
protected function load_classes() {
$mod = $this->mod;
$linkclasses = '';
$textclasses = '';
if ($mod->uservisible) {
$info = new info_module($mod);
$conditionalhidden = !$info->is_available_for_all();
$accessiblebutdim = (!$mod->visible || $conditionalhidden) &&
has_capability('moodle/course:viewhiddenactivities', $mod->context);
if ($accessiblebutdim && $conditionalhidden) {
$linkclasses .= ' conditionalhidden';
$textclasses .= ' conditionalhidden';
}
}
$this->displayoptions['linkclasses'] = $linkclasses;
$this->displayoptions['textclasses'] = $textclasses;
$this->displayoptions['onclick'] = htmlspecialchars_decode($mod->onclick, ENT_QUOTES);;
}
/**
* Get the activity link classes.
*
* @return string the activity link classes.
*/
public function get_link_classes(): string {
return $this->displayoptions['linkclasses'] ?? '';
}
/**
* Get the activity text/description classes.
*
* @return string the activity text classes.
*/
public function get_text_classes(): string {
return $this->displayoptions['textclasses'] ?? '';
}
/**
* Get the activity onclick code.
*
* @return string the activity onclick.
*/
public function get_onclick_code(): string {
return $this->displayoptions['onclick'];
}
}
@@ -0,0 +1,159 @@
<?php
// 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/>.
/**
* Contains the default activity availability information.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content\cm;
use core_courseformat\output\local\content\section\availability as section_avalability;
use cm_info;
use core_courseformat\base as course_format;
use section_info;
use stdClass;
use core_availability\info_module;
use core_availability\info;
/**
* Base class to render a course module availability inside a course format.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class availability extends section_avalability {
/** @var course_format the course format */
protected $format;
/** @var section_info the section object */
protected $section;
/** @var cm_info the course module instance */
protected $mod;
/** @var array optional display options */
protected $displayoptions;
/** @var bool the has availability attribute name */
protected $hasavailabilityname;
/** @var stdClass|null the instance export data */
protected $data = null;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module ionfo
* @param array $displayoptions optional extra display options
*/
public function __construct(course_format $format, section_info $section, cm_info $mod, array $displayoptions = []) {
$this->format = $format;
$this->section = $section;
$this->mod = $mod;
$this->displayoptions = $displayoptions;
$this->hasavailabilityname = 'hasmodavailability';
}
/**
* Get the availability data to be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return array the availability data.
*/
protected function get_info(\renderer_base $output): array {
if (!$this->mod->is_visible_on_course_page()) {
// Nothing to be displayed to the user.
return [];
}
if (!$this->mod->uservisible) {
return ['info' => $this->user_availability_info($output)];
}
$editurl = new \moodle_url(
'/course/modedit.php',
['update' => $this->mod->id, 'showonly' => 'availabilityconditionsheader']
);
return ['editurl' => $editurl->out(false), 'info' => $this->conditional_availability_info($output)];
}
/**
* Get the current user availability data.
*
* This is a student who is not allowed to see the module but might be allowed
* to see availability info (i.e. "Available from ...").
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return array the availability data.
*/
protected function user_availability_info(\renderer_base $output): array {
if (empty($this->mod->availableinfo)) {
return [];
}
$info = [];
$info[] = $this->get_availability_data($output, $this->mod->availableinfo, 'isrestricted');
return $info;
}
/**
* Get the activity availability data to display.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return array the availability data.
*/
protected function conditional_availability_info(\renderer_base $output): array {
global $CFG;
// This is a teacher who is allowed to see module but still should see the
// information that module is not available to all/some students.
$mod = $this->mod;
$modcontext = $mod->context;
$canviewhidden = has_capability('moodle/course:viewhiddenactivities', $modcontext);
if (!$canviewhidden || empty($CFG->enableavailability)) {
return [];
}
// Display information about conditional availability.
// Don't add availability information if user is not editing and activity is hidden.
if (!$mod->visible && !$this->format->show_editor()) {
return [];
}
$ci = new info_module($mod);
$fullinfo = $ci->get_full_information();
if (!$fullinfo) {
return [];
}
$info = [];
$hidinfoclass = 'isrestricted isfullinfo';
if (!$mod->visible) {
$hidinfoclass .= ' hide';
}
$info[] = $this->get_availability_data($output, $fullinfo, $hidinfoclass);
return $info;
}
}
@@ -0,0 +1,106 @@
<?php
// 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/>.
/**
* Contains the default activity icon.
*
* @package core_courseformat
* @copyright 2023 Mikel Martin <mikel@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content\cm;
use cm_info;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use renderable;
use stdClass;
/**
* Base class to render a course module icon.
*
* @package core_courseformat
* @copyright 2023 Mikel Martin <mikel@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class cmicon implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format */
protected $format;
/** @var cm_info the course module instance */
protected $mod;
/**
* Constructor.
*
* @param course_format $format the course format
* @param cm_info $mod the course module ionfo
*/
public function __construct(
course_format $format,
cm_info $mod,
) {
$this->format = $format;
$this->mod = $mod;
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): array {
$mod = $this->mod;
if (!$this->is_icon_visible()) {
// Nothing to be displayed to the user.
return [];
}
$iconurl = $mod->get_icon_url();
$iconclass = $iconurl->get_param('filtericon') ? '' : 'nofilter';
$isbranded = component_callback('mod_' . $mod->modname, 'is_branded', [], false);
$data = [
'uservisible' => $mod->uservisible,
'url' => $mod->url,
'icon' => $iconurl,
'iconclass' => $iconclass,
'modname' => $mod->modname,
'pluginname' => get_string('pluginname', 'mod_' . $mod->modname),
'showtooltip' => $this->format->show_editor(),
'purpose' => plugin_supports('mod', $mod->modname, FEATURE_MOD_PURPOSE, MOD_PURPOSE_OTHER),
'branded' => $isbranded,
];
return $data;
}
/**
* Return if the activity has a visible icon.
*
* @return bool if the icon should be shown.
*/
public function is_icon_visible(): bool {
return $this->mod->is_visible_on_course_page() && $this->mod->url;
}
}
@@ -0,0 +1,158 @@
<?php
// 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/>.
/**
* Contains the default activity name inplace editable.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content\cm;
use cm_info;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use renderable;
use section_info;
use stdClass;
/**
* Base class to render a course module inplace editable header.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class cmname implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format */
protected $format;
/** @var section_info the section object */
private $section;
/** @var cm_info the course module instance */
protected $mod;
/** @var array optional display options */
protected $displayoptions;
/** @var string the activity title output class name */
protected $titleclass;
/** @var string the activity icon output class name */
protected $iconclass;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module ionfo
* @param null $unused This parameter has been deprecated since 4.1 and should not be used anymore.
* @param array $displayoptions optional extra display options
*/
public function __construct(
course_format $format,
section_info $section,
cm_info $mod,
?bool $unused = null,
array $displayoptions = []
) {
if ($unused !== null) {
debugging('Deprecated argument passed to ' . __FUNCTION__, DEBUG_DEVELOPER);
}
$this->format = $format;
$this->section = $section;
$this->mod = $mod;
$this->displayoptions = $displayoptions;
// Get the necessary classes.
$this->titleclass = $format->get_output_classname('content\\cm\\title');
$this->iconclass = $format->get_output_classname('content\\cm\\cmicon');
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): array {
$mod = $this->mod;
$displayoptions = $this->displayoptions;
if (!$this->has_name()) {
// Nothing to be displayed to the user.
return [];
}
$data = [
'url' => $mod->url,
'modname' => $mod->modname,
'textclasses' => $displayoptions['textclasses'] ?? '',
'activityicon' => $this->get_icon_data($output),
'activityname' => $this->get_title_data($output),
];
return $data;
}
/**
* Get the title data.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return array data context for a mustache template
*/
protected function get_title_data(\renderer_base $output): array {
$title = new $this->titleclass(
$this->format,
$this->section,
$this->mod,
$this->displayoptions
);
return (array) $title->export_for_template($output);
}
/**
* Get the icon data.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return array data context for a mustache template
*/
protected function get_icon_data(\renderer_base $output): array {
$icon = new $this->iconclass(
$this->format,
$this->mod,
);
return (array) $icon->export_for_template($output);
}
/**
* Return if the activity has a visible name.
*
* @return bool if the title is visible.
*/
public function has_name(): bool {
return $this->mod->is_visible_on_course_page() && $this->mod->url;
}
}
@@ -0,0 +1,122 @@
<?php
// 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/>.
namespace core_courseformat\output\local\content\cm;
use cm_info;
use core_course\output\activity_completion;
use section_info;
use renderable;
use stdClass;
use core\output\named_templatable;
use core\output\local\dropdown\dialog as dropdown_dialog;
use core_completion\cm_completion_details;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
/**
* Base class to render course module completion.
*
* @package core_courseformat
* @copyright 2023 Mikel Martin <mikel@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class completion implements named_templatable, renderable {
use courseformat_named_templatable;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module ionfo
*/
public function __construct(
protected course_format $format,
protected section_info $section,
protected cm_info $mod,
) {
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): ?stdClass {
global $USER;
$course = $this->mod->get_course();
$showcompletionconditions = $course->showcompletionconditions == COMPLETION_SHOW_CONDITIONS;
$completiondetails = cm_completion_details::get_instance($this->mod, $USER->id, $showcompletionconditions);
$showcompletioninfo = $completiondetails->has_completion() &&
($showcompletionconditions || $completiondetails->show_manual_completion());
if (!$showcompletioninfo) {
return null;
}
$completion = new activity_completion($this->mod, $completiondetails);
$completiondata = $completion->export_for_template($output);
if ($completiondata->isautomatic || ($completiondata->ismanual && !$completiondata->istrackeduser)) {
$completiondata->completiondialog = $this->get_completion_dialog($output, $completiondata);
}
return $completiondata;
}
/**
* Get the completion dialog.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @param stdClass $completioninfo the completion info
* @return array the completion dialog exported for template
*/
private function get_completion_dialog(\renderer_base $output, stdClass $completioninfo): array {
global $PAGE;
$editurl = new \moodle_url(
'/course/modedit.php',
['update' => $this->mod->id, 'showonly' => 'activitycompletionheader']
);
$completioninfo->editurl = $editurl->out(false);
$completioninfo->editing = $PAGE->user_is_editing();
$completioninfo->hasconditions = $completioninfo->ismanual || count($completioninfo->completiondetails) > 0;
$dialogcontent = $output->render_from_template('core_courseformat/local/content/cm/completion_dialog', $completioninfo);
$buttoncontent = get_string('completionmenuitem', 'completion');
$buttonclass = '';
if ($completioninfo->istrackeduser) {
$buttoncontent = get_string('todo', 'completion');
if ($completioninfo->overallcomplete) {
$buttoncontent = $output->pix_icon('i/checked', '') . " " . get_string('completion_manual:done', 'core_course');
$buttonclass = 'btn-success';
}
}
$completiondialog = new dropdown_dialog($buttoncontent, $dialogcontent, [
'classes' => 'completion-dropdown',
'buttonclasses' => 'btn btn-sm dropdown-toggle icon-no-margin ' . $buttonclass,
'dropdownposition' => dropdown_dialog::POSITION['end'],
]);
return $completiondialog->export_for_template($output);
}
}
@@ -0,0 +1,172 @@
<?php
// 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/>.
/**
* Contains the default activity control menu.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content\cm;
use action_menu;
use action_menu_link;
use cm_info;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use renderable;
use section_info;
use stdClass;
/**
* Base class to render a course module menu inside a course format.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class controlmenu implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format */
protected $format;
/** @var section_info the section object */
private $section;
/** @var action_menu the activity aciton menu */
protected $menu;
/** @var cm_info the course module instance */
protected $mod;
/** @var array optional display options */
protected $displayoptions;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module info
* @param array $displayoptions optional extra display options
*/
public function __construct(course_format $format, section_info $section, cm_info $mod, array $displayoptions = []) {
$this->format = $format;
$this->section = $section;
$this->mod = $mod;
$this->displayoptions = $displayoptions;
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): stdClass {
$mod = $this->mod;
$menu = $this->get_action_menu($output);
if (empty($menu)) {
return new stdClass();
}
$data = (object)[
'menu' => $menu->export_for_template($output),
'hasmenu' => true,
'id' => $mod->id,
];
// After icons.
if (!empty($mod->afterediticons)) {
$data->afterediticons = $mod->afterediticons;
}
return $data;
}
/**
* Generate the action menu element.
*
* This method is public in case some block needs to modify the menu before output it.
* @param \renderer_base $output typically, the renderer that's calling this function
* @return action_menu|null the activity action menu
*/
public function get_action_menu(\renderer_base $output): ?action_menu {
if (!empty($this->menu)) {
return $this->menu;
}
$mod = $this->mod;
$controls = $this->cm_control_items();
if (empty($controls)) {
return null;
}
// Convert control array into an action_menu.
$menu = new action_menu();
$menu->set_kebab_trigger(get_string('edit'));
$menu->attributes['class'] .= ' section-cm-edit-actions commands';
// Prioritise the menu ahead of all other actions.
$menu->prioritise = true;
$ownerselector = $this->displayoptions['ownerselector'] ?? '#module-' . $mod->id;
$menu->set_owner_selector($ownerselector);
foreach ($controls as $control) {
if ($control instanceof action_menu_link) {
$control->add_class('cm-edit-action');
}
$menu->add($control);
}
$this->menu = $menu;
return $menu;
}
/**
* Generate the edit control items of a course module.
*
* This method uses course_get_cm_edit_actions function to get the cm actions.
* However, format plugins can override the method to add or remove elements
* from the menu.
*
* @return array of edit control items
*/
protected function cm_control_items() {
$format = $this->format;
$mod = $this->mod;
$sectionreturn = $format->get_sectionnum();
if (!empty($this->displayoptions['disableindentation']) || !$format->uses_indentation()) {
$indent = -1;
} else {
$indent = $mod->indent;
}
return course_get_cm_edit_actions($mod, $indent, $sectionreturn);
}
}
@@ -0,0 +1,210 @@
<?php
// 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/>.
namespace core_courseformat\output\local\content\cm;
use cm_info;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use core\output\named_templatable;
use core\output\choicelist;
use core\output\local\dropdown\status;
use pix_icon;
use renderable;
use section_info;
use stdClass;
/**
* Base class to render an activity group mode badge.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class groupmode implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format */
protected $format;
/** @var section_info the section object */
private $section;
/** @var cm_info the course module instance */
protected $mod;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module ionfo
*/
public function __construct(
course_format $format,
section_info $section,
cm_info $mod,
) {
$this->format = $format;
$this->section = $section;
$this->mod = $mod;
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass|null data context for a mustache template
*/
public function export_for_template(\renderer_base $output): ?stdClass {
if (!$this->format->show_groupmode($this->mod)) {
return null;
}
$usecomponents = $this->format->supports_components();
if ($this->format->show_editor() && $usecomponents && !$this->mod->coursegroupmodeforce) {
return $this->build_editor_data($output);
}
// If the group mode is not editable, the no groups badge is not displayed.
if ($this->mod->effectivegroupmode === NOGROUPS) {
return null;
}
return $this->build_static_data($output);
}
/**
* Build the data for the static badge.
* @param \renderer_base $output
* @return stdClass
*/
protected function build_static_data(\renderer_base $output): stdClass {
switch ($this->mod->effectivegroupmode) {
case SEPARATEGROUPS:
$groupalt = get_string('groupsseparate', 'group');
$groupicon = $this->get_action_icon('cmSeparateGroups', $groupalt);
break;
case VISIBLEGROUPS:
$groupalt = get_string('groupsvisible', 'group');
$groupicon = $this->get_action_icon('cmVisibleGroups', $groupalt);
break;
case NOGROUPS:
default:
$groupalt = get_string('groupsnone', 'group');
$groupicon = $this->get_action_icon('cmNoGroups', $groupalt);
break;
}
$data = (object) [
'groupicon' => $output->render($groupicon),
'groupalt' => $groupalt,
'isInteractive' => false,
];
return $data;
}
/**
* Build the data for the interactive dropdown.
* @param \renderer_base $output
* @return stdClass
*/
protected function build_editor_data(\renderer_base $output): stdClass {
$choice = $this->get_choice_list();
$result = $this->get_dropdown_data($output, $choice);
$result->autohide = ($this->mod->effectivegroupmode === NOGROUPS);
return $result;
}
/**
* Build the data for the interactive dropdown.
* @param \renderer_base $output
* @param choicelist $choice the choice list
* @return stdClass
*/
protected function get_dropdown_data(\renderer_base $output, choicelist $choice): stdClass {
$buttondata = $this->build_static_data($output);
$dropdown = new status(
$buttondata->groupicon,
$choice,
['dialogwidth' => status::WIDTH['big']],
);
$dropdown->set_dialog_width(status::WIDTH['small']);
$dropdown->set_position(status::POSITION['end']);
return (object) [
'isInteractive' => true,
'groupicon' => $buttondata->groupicon,
'groupalt' => $buttondata->groupalt,
'dropwdown' => $dropdown->export_for_template($output),
];
}
/**
* Create a choice list for the dropdown.
* @return choicelist the choice list
*/
public function get_choice_list(): choicelist {
$choice = new choicelist();
$choice->add_option(
NOGROUPS,
get_string('groupsnone', 'group'),
$this->get_option_data(null, 'cmNoGroups', $this->mod->id)
);
$choice->add_option(
SEPARATEGROUPS,
get_string('groupsseparate', 'group'),
$this->get_option_data('groupsseparate', 'cmSeparateGroups', $this->mod->id)
);
$choice->add_option(
VISIBLEGROUPS,
get_string('groupsvisible', 'group'),
$this->get_option_data('groupsvisible', 'cmVisibleGroups', $this->mod->id)
);
$choice->set_selected_value($this->mod->effectivegroupmode);
return $choice;
}
/**
* Get the data for the option.
* @param string|null $name the name of the option
* @param string $action the state action of the option
* @param int $id the id of the module
* @return array
*/
private function get_option_data(?string $name, string $action, int $id): array {
return [
'description' => ($name) ? get_string("groupmode_{$name}_help", 'group') : null,
// The dropdown icons are decorative, so we don't need to provide alt text.
'icon' => $this->get_action_icon($action),
'extras' => [
'data-id' => $id,
'data-action' => $action,
]
];
}
/**
* Get the group mode icon.
* @param string $groupmode the group mode
* @param string $groupalt the alt text
* @return pix_icon
*/
protected function get_action_icon(string $groupmode, string $groupalt = ''): pix_icon {
$icons = [
'cmNoGroups' => 'i/groupn',
'cmSeparateGroups' => 'i/groups',
'cmVisibleGroups' => 'i/groupv',
];
return new pix_icon($icons[$groupmode], $groupalt);
}
}
@@ -0,0 +1,236 @@
<?php
// 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/>.
/**
* Contains the default activity title.
*
* This class is usually rendered inside the cmname inplace editable.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content\cm;
use cm_info;
use core\output\inplace_editable;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_text;
use lang_string;
use renderable;
use section_info;
use stdClass;
use core_external\external_api;
use context_module;
/**
* Base class to render a course module title inside a course format.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class title extends inplace_editable implements named_templatable, renderable {
/** @var course_format the course format */
protected $format;
/** @var section_info the section object */
private $section;
/** @var cm_info the course module instance */
protected $mod;
/** @var array optional display options */
protected $displayoptions;
/** @var editable if the title is editable */
protected $editable;
/** @var displaytemplate the default display template */
protected $displaytemplate = 'core_courseformat/local/content/cm/title';
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module ionfo
* @param array $displayoptions optional extra display options
* @param bool|null $editable force editable value
*/
public function __construct(
course_format $format,
section_info $section,
cm_info $mod,
array $displayoptions = [],
?bool $editable = null
) {
$this->format = $format;
$this->section = $section;
$this->mod = $mod;
// Usually displayoptions are loaded in the main cm output. However when the user uses the inplace editor
// the cmname output does not calculate the css classes.
$this->displayoptions = $this->load_display_options($displayoptions);
if ($editable === null) {
$editable = $format->show_editor();
}
$this->editable = $editable;
// Setup inplace editable.
parent::__construct(
'core_course',
'activityname',
$mod->id,
$this->editable,
$mod->name,
$mod->name,
new lang_string('edittitle'),
new lang_string('newactivityname', '', $mod->get_formatted_name())
);
}
/**
* Get the name of the template to use for this templatable.
*
* @param \renderer_base $renderer The renderer requesting the template name
* @return string
*/
public function get_template_name(\renderer_base $renderer): string {
return 'core/inplace_editable';
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): array {
// Inplace editable uses pre-rendered elements and does not allow line beaks in the UI value.
$this->displayvalue = str_replace("\n", "", $this->get_title_displayvalue());
if (trim($this->displayvalue) == '') {
$this->editable = false;
}
return parent::export_for_template($output);
}
/**
* Return the title template data to be used inside the inplace editable.
*
*/
protected function get_title_displayvalue(): string {
global $PAGE;
// Inplace editable uses core renderer by default. However, course elements require
// the format specific renderer.
$courseoutput = $this->format->get_renderer($PAGE);
$mod = $this->mod;
$data = (object)[
'url' => $mod->url,
'instancename' => $mod->get_formatted_name(),
'uservisible' => $mod->uservisible,
'linkclasses' => $this->displayoptions['linkclasses'],
];
// File type after name, for alphabetic lists (screen reader).
if (strpos(
core_text::strtolower($data->instancename),
core_text::strtolower($mod->modfullname)
) === false) {
$data->altname = get_accesshide(' ' . $mod->modfullname);
}
// Get on-click attribute value if specified and decode the onclick - it
// has already been encoded for display (puke).
$data->onclick = htmlspecialchars_decode($mod->onclick, ENT_QUOTES);
return $courseoutput->render_from_template(
$this->displaytemplate,
$data
);
}
/**
* Load the required display options if not present already.
*
* In most cases, display options are provided as a param when creating the
* object. However, inplace_editable and some blocks does not know all of them as it is
* called in a webservice and we need to ensure it is calculated.
*
* @param array $displayoptions the provided dispaly options
* @return array the full display options list
*/
protected function load_display_options(array $displayoptions): array {
$format = $this->format;
$mod = $this->mod;
if (
isset($displayoptions['linkclasses']) &&
isset($displayoptions['textclasses']) &&
isset($displayoptions['onclick'])
) {
return $displayoptions;
}
$cmclass = $format->get_output_classname('content\\cm');
$cmoutput = new $cmclass(
$format,
$this->section,
$mod,
$displayoptions
);
$displayoptions['linkclasses'] = $cmoutput->get_link_classes();
$displayoptions['textclasses'] = $cmoutput->get_text_classes();
$displayoptions['onclick'] = $cmoutput->get_onclick_code();
return $displayoptions;
}
/**
* Updates course module name.
*
* This method is used mainly by inplace_editable webservice.
*
* @param int $itemid course module id
* @param string $newvalue new name
* @return static
*/
public static function update($itemid, $newvalue) {
$context = context_module::instance($itemid);
// Check access.
external_api::validate_context($context);
require_capability('moodle/course:manageactivities', $context);
// Trim module name and Update value.
set_coursemodule_name($itemid, trim($newvalue));
$coursemodulerecord = get_coursemodule_from_id('', $itemid, 0, false, MUST_EXIST);
// Return instance.
$modinfo = get_fast_modinfo($coursemodulerecord->course);
$cm = $modinfo->get_cm($itemid);
$section = $modinfo->get_section_info($cm->sectionnum);
$format = course_get_format($cm->course);
return new static($format, $section, $cm, [], true);
}
}
@@ -0,0 +1,292 @@
<?php
// 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/>.
/**
* Contains the default activity availability information.
*
* @package core_courseformat
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content\cm;
use action_menu_link_secondary;
use core\output\local\action_menu\subpanel as action_menu_subpanel;
use cm_info;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use core\output\choicelist;
use core\output\local\dropdown\status;
use core\output\named_templatable;
use pix_icon;
use renderable;
use section_info;
use stdClass;
/**
* Base class to render a course module availability inside a course format.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class visibility implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format */
protected $format;
/** @var section_info the section object */
protected $section;
/** @var cm_info the course module instance */
protected $mod;
/**
* Constructor.
* @param course_format $format the course format
* @param section_info $section the section info
* @param cm_info $mod the course module ionfo
*/
public function __construct(course_format $format, section_info $section, cm_info $mod) {
$this->format = $format;
$this->section = $section;
$this->mod = $mod;
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass|null data context for a mustache template
*/
public function export_for_template(\renderer_base $output): ?stdClass {
if (!$this->show_visibility()) {
return null;
}
$format = $this->format;
// In rare legacy cases, the section could be stealth (orphaned) but they are not editable.
if (!$format->show_editor()
|| !has_capability('moodle/course:activityvisibility', $this->mod->context)) {
return $this->build_static_data($output);
} else {
return $this->build_editor_data($output);
}
}
/**
* Check if the visibility is displayed.
* @return bool
*/
protected function show_visibility(): bool {
return !$this->mod->visible || $this->mod->is_stealth();
}
/**
* Get the icon for the section visibility.
* @param string $selected the visibility selected value
* @return pix_icon
*/
protected function get_icon(string $selected): pix_icon {
if ($selected === 'hide') {
return new pix_icon('t/show', '');
} else if ($selected === 'stealth') {
return new pix_icon('t/stealth', '');
} else {
return new pix_icon('t/hide', '');
}
}
/**
* Build the data for the editor.
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass|null data context for a mustache template
*/
public function build_editor_data(\renderer_base $output): ?stdClass {
$choice = $this->get_choice_list();
return $this->get_dropdown_data($output, $choice);
}
/**
* Build the data for the interactive dropdown.
* @param \renderer_base $output
* @param choicelist $choice the choice list
* @return stdClass
*/
protected function get_dropdown_data(
\renderer_base $output,
choicelist $choice,
): stdClass {
$badgetext = $output->sr_text(get_string('availability'));
if (!$this->mod->visible) {
$badgetext .= get_string('hiddenfromstudents');
$icon = $this->get_icon('hide');
} else if ($this->mod->is_stealth()) {
$badgetext .= get_string('hiddenoncoursepage');
$icon = $this->get_icon('stealth');
} else {
$badgetext .= get_string("availability_show", 'core_courseformat');
$icon = $this->get_icon('show');
}
$dropdown = new status(
$output->render($icon) . ' ' . $badgetext,
$choice,
['dialogwidth' => status::WIDTH['big']],
);
return (object) [
'isInteractive' => true,
'dropwdown' => $dropdown->export_for_template($output),
];
}
/**
* Get the availability choice list.
* @return choicelist
*/
public function get_choice_list(): choicelist {
$choice = $this->create_choice_list();
$choice->set_selected_value($this->get_selected_choice_value());
return $choice;
}
/**
* Return the cm availability menu item.
*
* By default, the cm availability is displayed as a menu item subpanel.
* However, it can be simplified when there is only one option and
* it is not stealth (stealth require a subpanel to inform the user).
*
* @return action_menu_link_secondary|action_menu_subpanel|null
*/
public function get_menu_item(): action_menu_link_secondary|action_menu_subpanel|null {
$choice = $this->get_choice_list();
$selectableoptions = $choice->get_selectable_options();
if (count($selectableoptions) === 0) {
return null;
}
// Visible activities in hidden sections are always considered stealth.
if ($this->section->visible && count($selectableoptions) === 1) {
$option = reset($selectableoptions);
$actionlabel = $option->value === 'show' ? 'modshow' : 'modhide';
return new action_menu_link_secondary(
$option->url,
$option->icon,
get_string($actionlabel, 'moodle'),
$choice->get_option_extras($option->value)
);
}
return new action_menu_subpanel(
get_string('availability', 'moodle'),
$choice,
['class' => 'editing_availability'],
new pix_icon('t/hide', '', 'moodle', ['class' => 'iconsmall'])
);
}
/**
* Get the selected choice value depending on the course, section and stealth settings.
* @return string
*/
protected function get_selected_choice_value(): string {
if (!$this->mod->visible) {
return 'hide';
}
if (!$this->mod->is_stealth()) {
return 'show';
}
if (!$this->section->visible) {
// All visible activities in a hidden sections are considered stealth
// but they don't use the stealth attribute for it. It is just implicit.
return 'show';
}
return 'stealth';
}
/**
* Create a choice list for the dropdown.
* @return choicelist the choice list
*/
protected function create_choice_list(): choicelist {
global $CFG;
$choice = new choicelist();
if ($this->section->visible || $this->mod->has_view()) {
$label = $this->section->visible ? 'show' : 'stealth';
$choice->add_option(
'show',
get_string("availability_{$label}", 'core_courseformat'),
$this->get_option_data($label, 'cmShow')
);
}
$choice->add_option(
'hide',
get_string('availability_hide', 'core_courseformat'),
$this->get_option_data('hide', 'cmHide')
);
if ($CFG->allowstealth && $this->format->allow_stealth_module_visibility($this->mod, $this->section)) {
$choice->add_option(
'stealth',
get_string('availability_stealth', 'core_courseformat'),
$this->get_option_data('stealth', 'cmStealth')
);
}
return $choice;
}
/**
* Get the data for the option.
* @param string $name the name of the option
* @param string $action the state action of the option
* @return array
*/
private function get_option_data(string $name, string $action): array {
return [
'description' => get_string("availability_{$name}_help", 'core_courseformat'),
'icon' => $this->get_icon($name),
// Non-ajax behat is not smart enough to discrimante hidden links
// so we need to keep providing the non-ajax links.
'url' => $this->format->get_non_ajax_cm_action_url($action, $this->mod),
'extras' => [
'data-id' => $this->mod->id,
'data-action' => $action,
]
];
}
/**
* Build the static badges data.
* @param \renderer_base $output typically, the renderer that's calling this function
* @return stdClass|null data context for a mustache template
*/
public function build_static_data(\renderer_base $output): ?stdClass {
$data = (object) [
'isInteractive' => false,
];
if (!$this->mod->visible) {
$data->modhiddenfromstudents = true;
} else if ($this->mod->is_stealth()) {
$data->modstealth = true;
}
return $data;
}
}
@@ -0,0 +1,102 @@
<?php
// 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/>.
/**
* Contains the default frontpage section displayer.
*
* The frontpage has a different wat of rendering the main topic.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content;
use context_course;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use moodle_url;
use renderable;
use section_info;
use stdClass;
/**
* Represents the frontpage section 1.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class frontpagesection implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format class */
protected $format;
/** @var section_info the course section class */
protected $section;
/** @var string the section output class name */
protected $sectionclass;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
*/
public function __construct(course_format $format, section_info $section) {
$this->format = $format;
$this->section = $section;
// Get the necessary classes.
$this->sectionclass = $format->get_output_classname('content\\section');
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(\renderer_base $output): stdClass {
global $USER;
$format = $this->format;
$section = $this->section;
$sectionoutput = new $this->sectionclass($format, $section);
$sectionoutput->hide_controls();
if (trim($section->name ?? '') == '') {
$sectionoutput->hide_title();
}
$data = (object)[
'sections' => [$sectionoutput->export_for_template($output)],
];
if ($format->show_editor(['moodle/course:update'])) {
$data->showsettings = true;
$data->settingsurl = new moodle_url('/course/editsection.php', ['id' => $section->id]);
}
return $data;
}
}
@@ -0,0 +1,371 @@
<?php
// 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/>.
/**
* Contains the default section course format output class.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_courseformat\output\local\content;
use context_course;
use core\output\named_templatable;
use core_courseformat\base as course_format;
use core_courseformat\output\local\courseformat_named_templatable;
use renderable;
use renderer_base;
use section_info;
use stdClass;
/**
* Base class to render a course section.
*
* @package core_courseformat
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class section implements named_templatable, renderable {
use courseformat_named_templatable;
/** @var course_format the course format */
protected $format;
/** @var section_info the section info */
protected $section;
/** @var section header output class */
protected $headerclass;
/** @var cm list output class */
protected $cmlistclass;
/** @var section summary output class */
protected $summaryclass;
/** @var activities summary output class */
protected $cmsummaryclass;
/** @var section control menu output class */
protected $controlclass;
/** @var section availability output class */
protected $availabilityclass;
/** @var optional move here output class */
protected $movehereclass;
/** @var optional visibility output class */
protected $visibilityclass;
/** @var bool if the title is hidden for some reason */
protected $hidetitle = false;
/** @var bool if the title is hidden for some reason */
protected $hidecontrols = false;
/** @var bool if the section is considered stealth */
protected $isstealth = false;
/** @var string control menu class. */
protected $controlmenuclass;
/**
* Constructor.
*
* @param course_format $format the course format
* @param section_info $section the section info
*/
public function __construct(course_format $format, section_info $section) {
$this->format = $format;
$this->section = $section;
if ($section->section > $format->get_last_section_number()) {
$this->isstealth = true;
}
// Load output classes names from format.
$this->headerclass = $format->get_output_classname('content\\section\\header');
$this->cmlistclass = $format->get_output_classname('content\\section\\cmlist');
$this->summaryclass = $format->get_output_classname('content\\section\\summary');
$this->cmsummaryclass = $format->get_output_classname('content\\section\\cmsummary');
$this->controlmenuclass = $format->get_output_classname('content\\section\\controlmenu');
$this->availabilityclass = $format->get_output_classname('content\\section\\availability');
$this->movehereclass = $format->get_output_classname('content\\section\\movehere');
$this->visibilityclass = $format->get_output_classname('content\\section\\visibility');
}
/**
* Hide the section title.
*
* This is used on blocks or in the home page where an isolated section is displayed.
*/
public function hide_title(): void {
$this->hidetitle = true;
}
/**
* Hide the section controls.
*
* This is used on blocks or in the home page where an isolated section is displayed.
*/
public function hide_controls(): void {
$this->hidecontrols = true;
}
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param renderer_base $output typically, the renderer that's calling this function
* @return stdClass data context for a mustache template
*/
public function export_for_template(renderer_base $output): stdClass {
global $USER, $PAGE;
$format = $this->format;
$course = $format->get_course();
$section = $this->section;
$summary = new $this->summaryclass($format, $section);
$data = (object)[
'num' => $section->section ?? '0',
'id' => $section->id,
'sectionreturnid' => $format->get_sectionnum(),
'insertafter' => false,
'summary' => $summary->export_for_template($output),
'highlightedlabel' => $format->get_section_highlighted_name(),
'sitehome' => $course->id == SITEID,
'editing' => $PAGE->user_is_editing(),
'displayonesection' => ($course->id != SITEID && !is_null($format->get_sectionid())),
];
$haspartials = [];
$haspartials['availability'] = $this->add_availability_data($data, $output);
$haspartials['visibility'] = $this->add_visibility_data($data, $output);
$haspartials['editor'] = $this->add_editor_data($data, $output);
$haspartials['header'] = $this->add_header_data($data, $output);
$haspartials['cm'] = $this->add_cm_data($data, $output);
$this->add_format_data($data, $haspartials, $output);
return $data;
}
/**
* Add the section header to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has name data
*/
protected function add_header_data(stdClass &$data, renderer_base $output): bool {
if (!empty($this->hidetitle)) {
return false;
}
$section = $this->section;
$format = $this->format;
$header = new $this->headerclass($format, $section);
$headerdata = $header->export_for_template($output);
// When a section is displayed alone the title goes over the section, not inside it.
if ($section->section != 0 && $section->section == $format->get_sectionnum()) {
$data->singleheader = $headerdata;
} else {
$data->header = $headerdata;
}
return true;
}
/**
* Add the section cm list to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has name data
*/
protected function add_cm_data(stdClass &$data, renderer_base $output): bool {
$result = false;
$section = $this->section;
$format = $this->format;
$showsummary = ($section->section != 0 &&
$section->section != $format->get_sectionnum() &&
$format->get_course_display() == COURSE_DISPLAY_MULTIPAGE &&
!$format->show_editor()
);
$showcmlist = $section->uservisible;
// Add activities summary if necessary.
if ($showsummary) {
$cmsummary = new $this->cmsummaryclass($format, $section);
$data->cmsummary = $cmsummary->export_for_template($output);
$data->onlysummary = true;
$result = true;
if (!$format->is_section_current($section)) {
// In multipage, only the current section (and the section zero) has elements.
$showcmlist = false;
}
}
// Add the cm list.
if ($showcmlist) {
$cmlist = new $this->cmlistclass($format, $section);
$data->cmlist = $cmlist->export_for_template($output);
$result = true;
}
return $result;
}
/**
* Add the section availability to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has name data
*/
protected function add_availability_data(stdClass &$data, renderer_base $output): bool {
$availability = new $this->availabilityclass($this->format, $this->section);
$data->availability = $availability->export_for_template($output);
$data->restrictionlock = !empty($this->section->availableinfo);
$data->hasavailability = $availability->has_availability($output);
return true;
}
/**
* Add the section vibility information to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has name data
*/
protected function add_visibility_data(stdClass &$data, renderer_base $output): bool {
global $USER;
$result = false;
// Check if it is a stealth sections (orphaned).
if ($this->isstealth) {
$data->isstealth = true;
$data->ishidden = true;
$result = true;
}
if (!$this->section->visible) {
$data->ishidden = true;
$course = $this->format->get_course();
$context = context_course::instance($course->id);
if (has_capability('moodle/course:viewhiddensections', $context, $USER)) {
$result = true;
}
}
/* @var \core_courseformat\output\local\content\section\visibility $visibility By default the visibility class used
* here but can be overriden by any course format */
$visibility = new $this->visibilityclass($this->format, $this->section);
$data->visibility = $visibility->export_for_template($output);
return $result;
}
/**
* Add the section editor attributes to the data structure.
*
* @param stdClass $data the current cm data reference
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has name data
*/
protected function add_editor_data(stdClass &$data, renderer_base $output): bool {
$course = $this->format->get_course();
$coursecontext = context_course::instance($course->id);
$editcaps = [];
if (has_capability('moodle/course:sectionvisibility', $coursecontext)) {
$editcaps = ['moodle/course:sectionvisibility'];
}
if (!$this->format->show_editor($editcaps)) {
return false;
}
// In a single section page the control menu is located in the page header.
if (empty($this->hidecontrols) && $this->format->get_sectionid() != $this->section->id) {
$controlmenu = new $this->controlmenuclass($this->format, $this->section);
$data->controlmenu = $controlmenu->export_for_template($output);
}
if (!$this->isstealth) {
$data->cmcontrols = $output->course_section_add_cm_control(
$course,
$this->section->section,
$this->format->get_sectionnum()
);
}
return true;
}
/**
* Add the section format attributes to the data structure.
*
* @param stdClass $data the current cm data reference
* @param bool[] $haspartials the result of loading partial data elements
* @param renderer_base $output typically, the renderer that's calling this function
* @return bool if the cm has name data
*/
protected function add_format_data(stdClass &$data, array $haspartials, renderer_base $output): bool {
$section = $this->section;
$format = $this->format;
$data->iscoursedisplaymultipage = ($format->get_course_display() == COURSE_DISPLAY_MULTIPAGE);
if ($data->num === 0 && !$data->iscoursedisplaymultipage) {
$data->collapsemenu = true;
}
$data->contentcollapsed = $this->is_section_collapsed();
if ($format->is_section_current($section)) {
$data->iscurrent = true;
$data->currentlink = get_accesshide(
get_string('currentsection', 'format_' . $format->get_format())
);
}
return true;
}
/**
* Returns true if the current section should be shown collapsed.
*
* @return bool
*/
protected function is_section_collapsed(): bool {
global $PAGE;
$contentcollapsed = false;
$preferences = $this->format->get_sections_preferences();
if (isset($preferences[$this->section->id])) {
$sectionpreferences = $preferences[$this->section->id];
if (!empty($sectionpreferences->contentcollapsed)) {
$contentcollapsed = true;
}
}
// No matter if the user's preference was to collapse the section or not: If the
// 'expandsection' parameter has been specified, it will be shown uncollapsed.
$expandsection = $PAGE->url->get_param('expandsection');
if ($expandsection !== null && $this->section->section == $expandsection) {
$contentcollapsed = false;
}
return $contentcollapsed;
}
}

Some files were not shown because too many files have changed in this diff Show More