1
0
mirror of https://github.com/moodle/moodle.git synced 2025-04-24 09:55:33 +02:00

Merge branch 'MDL-71663-master' of git://github.com/ferranrecio/moodle

This commit is contained in:
Andrew Nicols 2021-10-21 09:29:19 +08:00
commit f7ccc1b590
26 changed files with 839 additions and 106 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -25,6 +25,12 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
'core/modal_factory', 'core/modal_events', 'core/key_codes', 'core/log', 'core_courseformat/courseeditor'],
function($, ajax, templates, notification, str, url, Y, ModalFactory, ModalEvents, KeyCodes, log, editor) {
// Eventually, core_courseformat/local/content/actions will handle all actions for
// component compatible formats and the default actions.js won't be necessary anymore.
// Meanwhile, we filter the migrated actions.
const componentActions = ['moveSection', 'moveCm'];
// The course reactive instance.
const courseeditor = editor.getCurrentCourseEditor();
var CSS = {
@ -488,10 +494,17 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
* @param {Nunmber} sectionid
* @param {JQuery} target the element (menu item) that was clicked
* @param {String} courseformat
* @return {boolean} true the action call is sent to the server or false if it is ignored.
*/
var editSection = function(sectionElement, sectionid, target, courseformat) {
var action = target.attr('data-action'),
sectionreturn = target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : 0;
// Filter direct component handled actions.
if (courseeditor.supportComponents && componentActions.includes(action)) {
return false;
}
var spinner = addSectionSpinner(sectionElement);
var promises = ajax.call([{
methodname: 'core_course_edit_section',
@ -522,6 +535,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
notification.exception(ex);
}
});
return true;
};
// Register a function to be executed after D&D of an activity.
@ -735,14 +749,19 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
var actionItem = $(this),
sectionElement = actionItem.closest(SELECTOR.SECTIONLI),
sectionId = actionItem.closest(SELECTOR.SECTIONACTIONMENU).attr('data-sectionid');
e.preventDefault();
let isExecuted = true;
if (actionItem.attr('data-confirm')) {
// Action requires confirmation.
confirmEditSection(actionItem.attr('data-confirm'), function() {
editSection(sectionElement, sectionId, actionItem, courseformat);
isExecuted = editSection(sectionElement, sectionId, actionItem, courseformat);
});
} else {
editSection(sectionElement, sectionId, actionItem, courseformat);
isExecuted = editSection(sectionElement, sectionId, actionItem, courseformat);
}
// Prevent any other module from capturing the action if it is already in execution.
if (isExecuted) {
e.preventDefault();
}
});

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

@ -1,2 +1,2 @@
define ("core_courseformat/local/courseeditor/exporter",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;function b(a,b){var c=Object.keys(a);if(Object.getOwnPropertySymbols){var d=Object.getOwnPropertySymbols(a);if(b)d=d.filter(function(b){return Object.getOwnPropertyDescriptor(a,b).enumerable});c.push.apply(c,d)}return c}function c(a){for(var c=1,e;c<arguments.length;c++){e=null!=arguments[c]?arguments[c]:{};if(c%2){b(Object(e),!0).forEach(function(b){d(a,b,e[b])})}else if(Object.getOwnPropertyDescriptors){Object.defineProperties(a,Object.getOwnPropertyDescriptors(e))}else{b(Object(e)).forEach(function(b){Object.defineProperty(a,b,Object.getOwnPropertyDescriptor(e,b))})}}return a}function d(a,b,c){if(b in a){Object.defineProperty(a,b,{value:c,enumerable:!0,configurable:!0,writable:!0})}else{a[b]=c}return a}function e(a,b){if(!(a instanceof b)){throw new TypeError("Cannot call a class as a function")}}function f(a,b){for(var c=0,d;c<b.length;c++){d=b[c];d.enumerable=d.enumerable||!1;d.configurable=!0;if("value"in d)d.writable=!0;Object.defineProperty(a,d.key,d)}}function g(a,b,c){if(b)f(a.prototype,b);if(c)f(a,c);return a}var h=function(){function a(b){e(this,a);this.reactive=b}g(a,[{key:"course",value:function course(a){var b,c,d=this,e={sections:[],editmode:this.reactive.isEditing,highlighted:null!==(b=a.course.highlighted)&&void 0!==b?b:""},f=null!==(c=a.course.sectionlist)&&void 0!==c?c:[];f.forEach(function(b){var c,f=null!==(c=a.section.get(b))&&void 0!==c?c:{},g=d.section(a,f);e.sections.push(g)});e.hassections=0!=e.sections.length;return e}},{key:"section",value:function(a,b){var d,e=this,f=c({},b,{cms:[]}),g=null!==(d=b.cmlist)&&void 0!==d?d:[];g.forEach(function(b){var c=a.cm.get(b),d=e.cm(a,c);f.cms.push(d)});f.hascms=0!=f.cms.length;return f}},{key:"cm",value:function(a,b){var d=c({},b,{isactive:!1});return d}},{key:"cmDraggableData",value:function cmDraggableData(a,b){var c=a.cm.get(b);if(!c){return null}var d,e=a.section.get(c.sectionid),f=null===e||void 0===e?void 0:e.cmlist.indexOf(c.id);if(f!==void 0){d=null===e||void 0===e?void 0:e.cmlist[f+1]}return{type:"cm",id:c.id,name:c.name,nextcmid:d}}},{key:"sectionDraggableData",value:function sectionDraggableData(a,b){var c=a.section.get(b);if(!c){return null}return{type:"section",id:c.id,name:c.name,number:c.number}}}]);return a}();a.default=h;return a.default});
define ("core_courseformat/local/courseeditor/exporter",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;function b(a,b){var c=Object.keys(a);if(Object.getOwnPropertySymbols){var d=Object.getOwnPropertySymbols(a);if(b)d=d.filter(function(b){return Object.getOwnPropertyDescriptor(a,b).enumerable});c.push.apply(c,d)}return c}function c(a){for(var c=1,e;c<arguments.length;c++){e=null!=arguments[c]?arguments[c]:{};if(c%2){b(Object(e),!0).forEach(function(b){d(a,b,e[b])})}else if(Object.getOwnPropertyDescriptors){Object.defineProperties(a,Object.getOwnPropertyDescriptors(e))}else{b(Object(e)).forEach(function(b){Object.defineProperty(a,b,Object.getOwnPropertyDescriptor(e,b))})}}return a}function d(a,b,c){if(b in a){Object.defineProperty(a,b,{value:c,enumerable:!0,configurable:!0,writable:!0})}else{a[b]=c}return a}function e(a,b){if(!(a instanceof b)){throw new TypeError("Cannot call a class as a function")}}function f(a,b){for(var c=0,d;c<b.length;c++){d=b[c];d.enumerable=d.enumerable||!1;d.configurable=!0;if("value"in d)d.writable=!0;Object.defineProperty(a,d.key,d)}}function g(a,b,c){if(b)f(a.prototype,b);if(c)f(a,c);return a}var h=function(){function a(b){e(this,a);this.reactive=b}g(a,[{key:"course",value:function course(a){var b,c,d=this,e={sections:[],editmode:this.reactive.isEditing,highlighted:null!==(b=a.course.highlighted)&&void 0!==b?b:""},f=null!==(c=a.course.sectionlist)&&void 0!==c?c:[];f.forEach(function(b){var c,f=null!==(c=a.section.get(b))&&void 0!==c?c:{},g=d.section(a,f);e.sections.push(g)});e.hassections=0!=e.sections.length;return e}},{key:"section",value:function(a,b){var d,e=this,f=c({},b,{cms:[]}),g=null!==(d=b.cmlist)&&void 0!==d?d:[];g.forEach(function(b){var c=a.cm.get(b),d=e.cm(a,c);f.cms.push(d)});f.hascms=0!=f.cms.length;return f}},{key:"cm",value:function(a,b){var d=c({},b,{isactive:!1});return d}},{key:"cmDraggableData",value:function cmDraggableData(a,b){var c=a.cm.get(b);if(!c){return null}var d,e=a.section.get(c.sectionid),f=null===e||void 0===e?void 0:e.cmlist.indexOf(c.id);if(f!==void 0){d=null===e||void 0===e?void 0:e.cmlist[f+1]}return{type:"cm",id:c.id,name:c.name,sectionid:c.sectionid,nextcmid:d}}},{key:"sectionDraggableData",value:function sectionDraggableData(a,b){var c=a.section.get(b);if(!c){return null}return{type:"section",id:c.id,name:c.name,number:c.number}}}]);return a}();a.default=h;return a.default});
//# sourceMappingURL=exporter.min.js.map

File diff suppressed because one or more lines are too long

@ -29,6 +29,7 @@ import Section from 'core_courseformat/local/content/section';
import CmItem from 'core_courseformat/local/content/section/cmitem';
// Course actions is needed for actions that are not migrated to components.
import courseActions from 'core_course/actions';
import DispatchActions from 'core_courseformat/local/content/actions';
export default class Component extends BaseComponent {
@ -51,6 +52,12 @@ export default class Component extends BaseComponent {
// Default classes to toggle on refresh.
this.classes = {
COLLAPSED: `collapsed`,
// Formats can override the activity tag but a default one is needed to create new elements.
ACTIVITYTAG: 'li',
// Course content classes.
ACTIVITY: `activity`,
STATEDREADY: `stateready`,
};
// Array to save dettached elements during element resorting.
this.dettachedCms = {};
@ -116,6 +123,30 @@ export default class Component extends BaseComponent {
}
}
/**
*
* Course content elements could not provide JS Components because the elements HTML is applied
* directly from the course actions. To keep internal components updated this module keeps
* a list of the active components and mark them as "indexed". This way when any action replace
* the HTML this component will recreate the components an add any necessary event listener.
*
* Format plugins can override this method to provide extra logic to the course frontend.
*
*/
stateReady() {
this._indexContents();
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);
}
}
/**
* Return the component watchers.
*
@ -209,7 +240,8 @@ export default class Component extends BaseComponent {
// Find the element.
const target = this.getElement(this.selectors.SECTION, element.id);
if (!target) {
throw new Error(`Unkown section with ID ${element.id}`);
// Job done. Nothing to refresh.
return;
}
// Update section numbers in all data, css and YUI attributes.
target.id = `section-${element.number}`;
@ -240,14 +272,17 @@ export default class Component extends BaseComponent {
/**
* Refresh a section cm list.
*
* @param {Object} details the update details.
* @param {details} details the update details
* @property {object} details.element the state object
*/
_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);
this._fixOrder(listparent, cmlist, this.selectors.CM, this.dettachedCms, createCm);
}
}
@ -259,8 +294,10 @@ export default class Component extends BaseComponent {
_refreshCourseSectionlist({element}) {
const sectionlist = element.sectionlist ?? [];
const listparent = this.getElement(this.selectors.COURSE_SECTIONLIST);
// For now section cannot be created at a frontend level.
const createSection = () => undefined;
if (listparent) {
this._fixOrder(listparent, sectionlist, this.selectors.SECTION, this.dettachedSections);
this._fixOrder(listparent, sectionlist, this.selectors.SECTION, this.dettachedSections, createSection);
}
}
@ -321,6 +358,9 @@ export default class Component extends BaseComponent {
/**
* 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 {details} param0 the watcher details
* @property {object} param0.element the state object
*/
@ -335,6 +375,30 @@ export default class Component extends BaseComponent {
}
}
/**
* 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;
}
/**
* Fix/reorder the section or cms order.
*
@ -342,8 +406,12 @@ export default class Component extends BaseComponent {
* @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
*/
_fixOrder(container, neworder, selector, dettachedelements) {
async _fixOrder(container, neworder, selector, dettachedelements, createMethod) {
if (container === undefined) {
return;
}
// Empty lists should not be visible.
if (!neworder.length) {
@ -357,7 +425,11 @@ export default class Component extends BaseComponent {
// Move the elements in order at the beginning of the list.
neworder.forEach((itemid, index) => {
const item = this.getElement(selector, itemid) ?? dettachedelements[itemid];
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) {
@ -375,7 +447,7 @@ export default class Component extends BaseComponent {
// Remove the remaining elements.
while (container.children.length > neworder.length) {
const lastchild = container.lastChild;
if (lastchild.classList.contains('dndupload-preview')) {
if (lastchild?.classList?.contains('dndupload-preview')) {
dndFakeActivity = lastchild;
} else {
dettachedelements[lastchild?.dataset?.id ?? 0] = lastchild;

@ -0,0 +1,255 @@
// 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 ModalFactory from 'core/modal_factory';
import ModalEvents from 'core/modal_events';
import Templates from 'core/templates';
import {prefetchStrings} from 'core/prefetch';
import {get_string as getString} from 'core/str';
import {getList} from 'core/normalise';
// Load global strings.
prefetchStrings('core', ['movecoursesection', 'movecoursemodule']);
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]`,
SECTIONLINK: `[data-for='section']`,
CMLINK: `[data-for='cm']`,
SECTIONNODE: `[data-for='sectionnode']`,
TOGGLER: `[data-toggle='collapse']`,
};
// Component css classes.
this.classes = {
DISABLED: `disabled`,
};
}
/**
* Initial state ready method.
*
*/
stateReady() {
// Delegate dispatch clicks.
this.addEventListener(
this.element,
'click',
this._dispatchClick
);
}
_dispatchClick(event) {
const target = event.target.closest(this.selectors.ACTIONLINK);
if (!target) {
return;
}
// Invoke proper method.
const methodName = this._actionMethodName(target.dataset.action);
if (this[methodName] !== undefined) {
this[methodName](target, event);
}
}
_actionMethodName(name) {
const requestName = name.charAt(0).toUpperCase() + name.slice(1);
return `_request${requestName}`;
}
/**
* 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 sectionId = target.dataset.id;
if (!sectionId) {
return;
}
const sectionInfo = this.reactive.get('section', sectionId);
event.preventDefault();
// Collect section information from the state.
const exporter = this.reactive.getExporter();
const data = exporter.course(this.reactive.state);
// Add the target section id and title.
data.sectionid = sectionInfo.id;
data.sectiontitle = sectionInfo.title;
// Build the modal parameters from the event data.
const modalParams = {
title: getString('movecoursesection', 'core'),
body: Templates.render('core_courseformat/local/content/movesection', data),
};
// Create the modal.
const modal = await this._modalBodyRenderedPromise(modalParams);
const modalBody = getList(modal.getBody())[0];
// Disable current element and section zero.
const currentElement = modalBody.querySelector(`${this.selectors.SECTIONLINK}[data-id='${sectionId}']`);
this._disableLink(currentElement);
const generalSection = modalBody.querySelector(`${this.selectors.SECTIONLINK}[data-number='0']`);
this._disableLink(generalSection);
// 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('sectionMove', [sectionId], target.dataset.id);
modal.destroy();
});
}
/**
* 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 cmId = target.dataset.id;
if (!cmId) {
return;
}
const cmInfo = this.reactive.get('cm', cmId);
event.preventDefault();
// Collect section information from the state.
const exporter = this.reactive.getExporter();
const data = exporter.course(this.reactive.state);
// Add the target cm info.
data.cmid = cmInfo.id;
data.cmname = cmInfo.name;
// Build the modal parameters from the event data.
const modalParams = {
title: getString('movecoursemodule', 'core'),
body: Templates.render('core_courseformat/local/content/movecm', data),
};
// Create the modal.
const modal = await this._modalBodyRenderedPromise(modalParams);
const modalBody = getList(modal.getBody())[0];
// Disable current element.
let currentElement = modalBody.querySelector(`${this.selectors.CMLINK}[data-id='${cmId}']`);
this._disableLink(currentElement);
// Open the cm section node if possible.
currentElement.closest(this.selectors.SECTIONNODE)?.querySelector(this.selectors.TOGGLER)?.click();
// Capture click.
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();
// Get draggable data from cm or section to dispatch.
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', [cmId], targetSectionId, targetCmId);
modal.destroy();
});
}
/**
* 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.setAttribute('aria-disabled', true);
element.addEventListener('click', event => event.preventDefault());
}
}
/**
* Render a modal and return a body ready promise.
*
* @param {object} modalParams the modal params
* @return {Promise} the modal body ready promise
*/
_modalBodyRenderedPromise(modalParams) {
return new Promise((resolve, reject) => {
ModalFactory.create(modalParams).then((modal) => {
// Handle body loading event.
modal.getRoot().on(ModalEvents.bodyRendered, () => {
resolve(modal);
});
modal.show();
return;
}).catch(() => {
reject(`Cannot load modal content`);
});
});
}
}

@ -123,6 +123,7 @@ export default class {
type: 'cm',
id: cminfo.id,
name: cminfo.name,
sectionid: cminfo.sectionid,
nextcmid,
};
}

@ -192,7 +192,12 @@ class cm implements renderable, templatable {
$data->controlmenu = $controlmenu->export_for_template($output);
// Move and select options.
$data->moveicon = course_get_cm_move($mod, $returnsection);
if ($format->supports_components()) {
$data->moveicon = $output->pix_icon('i/dragdrop', '', 'moodle', ['class' => 'editing_move dragicon']);
} else {
// Add the legacy YUI move link.
$data->moveicon = course_get_cm_move($mod, $returnsection);
}
}
return $data;

@ -85,11 +85,11 @@ class controlmenu implements renderable, templatable {
$url = empty($value['url']) ? '' : $value['url'];
$icon = empty($value['icon']) ? '' : $value['icon'];
$name = empty($value['name']) ? '' : $value['name'];
$attr = empty($value['attr']) ? array() : $value['attr'];
$attr = empty($value['attr']) ? [] : $value['attr'];
$class = empty($value['pixattr']['class']) ? '' : $value['pixattr']['class'];
$al = new action_menu_link_secondary(
new moodle_url($url),
new pix_icon($icon, '', null, array('class' => "smallicon " . $class)),
new pix_icon($icon, '', null, ['class' => "smallicon " . $class]),
$name,
$attr
);
@ -124,6 +124,7 @@ class controlmenu implements renderable, templatable {
$sectionreturn = $format->get_section_number();
$user = $USER;
$usecomponents = $format->supports_components();
$coursecontext = context_course::instance($course->id);
$numsections = $format->get_last_section_number();
$isstealth = $section->section > $numsections;
@ -141,12 +142,13 @@ class controlmenu implements renderable, templatable {
$streditsection = get_string('editsection');
}
$controls['edit'] = array(
'url' => new moodle_url('/course/editsection.php', array('id' => $section->id, 'sr' => $sectionreturn)),
$controls['edit'] = [
'url' => new moodle_url('/course/editsection.php', ['id' => $section->id, 'sr' => $sectionreturn]),
'icon' => 'i/settings',
'name' => $streditsection,
'pixattr' => array('class' => ''),
'attr' => array('class' => 'icon edit'));
'pixattr' => ['class' => ''],
'attr' => ['class' => 'icon edit'],
];
}
if ($section->section) {
@ -156,53 +158,79 @@ class controlmenu implements renderable, templatable {
if ($section->visible) { // Show the hide/show eye.
$strhidefromothers = get_string('hidefromothers', 'format_'.$course->format);
$url->param('hide', $section->section);
$controls['visiblity'] = array(
$controls['visiblity'] = [
'url' => $url,
'icon' => 'i/hide',
'name' => $strhidefromothers,
'pixattr' => array('class' => ''),
'attr' => array('class' => 'icon editing_showhide',
'data-sectionreturn' => $sectionreturn, 'data-action' => 'hide'));
'pixattr' => ['class' => ''],
'attr' => [
'class' => 'icon editing_showhide',
'data-sectionreturn' => $sectionreturn,
'data-action' => 'hide',
],
];
} else {
$strshowfromothers = get_string('showfromothers', 'format_'.$course->format);
$url->param('show', $section->section);
$controls['visiblity'] = array(
$controls['visiblity'] = [
'url' => $url,
'icon' => 'i/show',
'name' => $strshowfromothers,
'pixattr' => array('class' => ''),
'attr' => array('class' => 'icon editing_showhide',
'data-sectionreturn' => $sectionreturn, 'data-action' => 'show'));
'pixattr' => ['class' => ''],
'attr' => [
'class' => 'icon editing_showhide',
'data-sectionreturn' => $sectionreturn,
'data-action' => 'show',
],
];
}
}
if (!$sectionreturn) {
if (has_capability('moodle/course:movesections', $coursecontext, $user)) {
$url = clone($baseurl);
if ($section->section > 1) { // Add a arrow to move section up.
$url->param('section', $section->section);
$url->param('move', -1);
$strmoveup = get_string('moveup');
$controls['moveup'] = array(
'url' => $url,
'icon' => 'i/up',
'name' => $strmoveup,
'pixattr' => array('class' => ''),
'attr' => array('class' => 'icon moveup'));
}
if (!$sectionreturn && has_capability('moodle/course:movesections', $coursecontext, $user)) {
if ($usecomponents) {
// This tool will appear only when the state is ready.
$url = clone ($baseurl);
$url->param('movesection', $section->section);
$url->param('section', $section->section);
$controls['movesection'] = [
'url' => $url,
'icon' => 'i/dragdrop',
'name' => get_string('move', 'moodle'),
'pixattr' => ['class' => ''],
'attr' => [
'class' => 'icon move waitstate',
'data-action' => 'moveSection',
'data-id' => $section->id,
],
];
}
// Legacy move up and down links for non component-based formats.
$url = clone($baseurl);
if ($section->section > 1) { // Add a arrow to move section up.
$url->param('section', $section->section);
$url->param('move', -1);
$strmoveup = get_string('moveup');
$controls['moveup'] = [
'url' => $url,
'icon' => 'i/up',
'name' => $strmoveup,
'pixattr' => ['class' => ''],
'attr' => ['class' => 'icon moveup whilenostate'],
];
}
$url = clone($baseurl);
if ($section->section < $numsections) { // Add a arrow to move section down.
$url->param('section', $section->section);
$url->param('move', 1);
$strmovedown = get_string('movedown');
$controls['movedown'] = array(
'url' => $url,
'icon' => 'i/down',
'name' => $strmovedown,
'pixattr' => array('class' => ''),
'attr' => array('class' => 'icon movedown'));
}
$url = clone($baseurl);
if ($section->section < $numsections) { // Add a arrow to move section down.
$url->param('section', $section->section);
$url->param('move', 1);
$strmovedown = get_string('movedown');
$controls['movedown'] = [
'url' => $url,
'icon' => 'i/down',
'name' => $strmovedown,
'pixattr' => ['class' => ''],
'attr' => ['class' => 'icon movedown whilenostate'],
];
}
}
}
@ -213,17 +241,22 @@ class controlmenu implements renderable, templatable {
} else {
$strdelete = get_string('deletesection');
}
$url = new moodle_url('/course/editsection.php', array(
'id' => $section->id,
'sr' => $sectionreturn,
'delete' => 1,
'sesskey' => sesskey()));
$controls['delete'] = array(
$url = new moodle_url(
'/course/editsection.php',
[
'id' => $section->id,
'sr' => $sectionreturn,
'delete' => 1,
'sesskey' => sesskey(),
]
);
$controls['delete'] = [
'url' => $url,
'icon' => 'i/delete',
'name' => $strdelete,
'pixattr' => array('class' => ''),
'attr' => array('class' => 'icon editing_delete'));
'pixattr' => ['class' => ''],
'attr' => ['class' => 'icon editing_delete'],
];
}
}

@ -0,0 +1,133 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template core_courseformat/local/movemodal/movecm
Displays the course index.
Example context (json):
{
"cmname": "Activity name",
"cmid": 42,
"sections": [
{
"title": "General",
"id": 42,
"number": 1,
"sectionurl": "#",
"hascms": true,
"cms": [
{
"name": "Glossary of characters",
"id": "10",
"url": "#"
},
{
"name": "World Cinema forum",
"id": "11",
"url": "#"
},
{
"name": "Announcements",
"id": "12",
"url": "#"
}
]
},
{
"title": "City of God or Cidade de Deus",
"id": "43",
"number": "2",
"sectionurl": "#",
"hascms": true,
"cms": [
{
"name": "Resources",
"id": "13",
"url": "#"
},
{
"name": "Studying City of God by Stephen Smith Bergman-Messerschmidt",
"id": "14",
"url": "#"
},
{
"name": "Film education study guide",
"id": "15",
"url": "#"
}
]
}
]
}
}}
<p data-for="sectionname">{{#str}} movefull, moodle, {{cmname}} {{/str}}:</p>
<nav class="collapse-list" id="destination-selector">
{{#sections}}
<div data-for="sectionnode">
<div class="collapse-list-item d-flex"
id="movemodalsection{{number}}"
data-for="section_item"
>
<a data-toggle="collapse"
href="#movemodalcollapse{{number}}"
aria-expanded="false"
aria-controls="movemodalcollapse{{number}}"
class="collapse-list-link icons-collapse-expand collapsed"
>
<span class="collapsed-icon icon-no-margin mr-1"
data-toggle="tooltip" title="{{#str}} expand, core {{/str}}">
{{#pix}} t/collapsed, core {{/pix}}
<span class="sr-only">{{#str}} expand, core {{/str}}</span>
</span>
<span class="expanded-icon icon-no-margin mr-1"
data-toggle="tooltip" title="{{#str}} collapse, core {{/str}}">
{{#pix}} t/expanded, core {{/pix}}
<span class="sr-only">{{#str}} collapse, core {{/str}}</span>
</span>
</a>
<a href="{{{sectionurl}}}"
class="collapse-list-link text-truncate"
data-for="section"
data-id="{{id}}"
data-number="{{number}}"
>
{{{title}}}
</a>
<span class="dragicon ml-auto">{{#pix}}i/dragdrop{{/pix}}</span>
</div>
<div id="movemodalcollapse{{number}}"
class="collapse-list-item-content collapse"
aria-labelledby="movemodalsection{{number}}">
<ul class="unlist" data-for="cmlist" data-id="{{id}}">
{{#cms}}
<li class="collapse-list-item d-flex">
<a class="collapse-list-link text-truncate"
href="{{{url}}}"
data-for="cm"
data-id="{{id}}"
>
{{{name}}}
</a>
</li>
{{/cms}}
</ul>
</div>
</div>
{{/sections}}
</nav>

@ -0,0 +1,61 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template core_courseformat/local/movemodal/movesection
Displays the course index.
Example context (json):
{
"sectionid": 23,
"sectiontitle": "Section title",
"sections": [
{
"title": "General",
"id": 42,
"number": 1,
"sectionurl": "#",
"isactive": 1
},
{
"title": "City of God or Cidade de Deus",
"id": "43",
"number": "2",
"sectionurl": "#",
"isactive": 0
}
]
}
}}
<p data-for="sectionname">{{#str}} movefull, moodle, {{sectiontitle}} {{/str}}:</p>
<nav class="collapse-list" id="destination-selector">
{{#sections}}
<div
class="collapse-list-item"
>
<a href="{{{sectionurl}}}"
class="collapse-list-link text-truncate"
data-for="section"
data-id="{{id}}"
data-number="{{number}}"
>
{{{title}}}
</a>
</div>
{{/sections}}
</nav>

@ -1820,6 +1820,23 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
);
}
// Move (only for component compatible formats).
if ($courseformat->supports_components()) {
$actions['move'] = new action_menu_link_secondary(
new moodle_url($baseurl, [
'sesskey' => sesskey(),
'copy' => $mod->id,
]),
new pix_icon('i/dragdrop', '', 'moodle', ['class' => 'iconsmall']),
$str->move,
[
'class' => 'editing_movecm',
'data-action' => 'moveCm',
'data-id' => $mod->id,
]
);
}
// Indent.
if ($hasmanageactivities && $indent >= 0) {
$indentlimits = new stdClass();
@ -3217,17 +3234,17 @@ function include_course_ajax($course, $usedmodules = array(), $enabledmodules =
$config = new stdClass();
}
// The URL to use for resource changes
// The URL to use for resource changes.
if (!isset($config->resourceurl)) {
$config->resourceurl = '/course/rest.php';
}
// The URL to use for section changes
// The URL to use for section changes.
if (!isset($config->sectionurl)) {
$config->sectionurl = '/course/rest.php';
}
// Any additional parameters which need to be included on page submission
// Any additional parameters which need to be included on page submission.
if (!isset($config->pageparams)) {
$config->pageparams = array();
}

@ -762,38 +762,84 @@ class behat_course extends behat_base {
}
/**
* Moves the specified activity to the first slot of a section. This step is experimental when using it in Javascript tests. Editing mode should be on.
* Moves the specified activity to the first slot of a section.
*
* Editing mode should be on.
*
* @Given /^I move "(?P<activity_name_string>(?:[^"]|\\")*)" activity to section "(?P<section_number>\d+)"$/
* @param string $activityname The activity name
* @param int $sectionnumber The number of section
*/
public function i_move_activity_to_section($activityname, $sectionnumber) {
public function i_move_activity_to_section($activityname, $sectionnumber): void {
// Ensure the destination is valid.
$sectionxpath = $this->section_exists($sectionnumber);
// Not all formats are compatible with the move tool.
$activitynode = $this->get_activity_node($activityname);
if (!$activitynode->find('css', "[data-action='moveCm']", false, false, 0)) {
// Execute the legacy YUI move option.
$this->i_move_activity_to_section_yui($activityname, $sectionnumber);
return;
}
// JS enabled.
if ($this->running_javascript()) {
$this->i_open_actions_menu($activityname);
$this->execute(
'behat_course::i_click_on_in_the_activity',
[get_string('move'), "link", $this->escape($activityname)]
);
$this->execute("behat_general::i_click_on_in_the", [
"[data-for='section'][data-number='$sectionnumber']",
'css_element',
"[data-region='modal-container']",
'css_element'
]);
} else {
$this->execute(
'behat_course::i_click_on_in_the_activity',
[get_string('move'), "link", $this->escape($activityname)]
);
$this->execute(
'behat_general::i_click_on_in_the',
["li.movehere a", "css_element", $this->escape($sectionxpath), "xpath_element"]
);
}
}
/**
* Moves the specified activity to the first slot of a section using the YUI course format.
*
* This step is experimental when using it in Javascript tests. Editing mode should be on.
*
* @param string $activityname The activity name
* @param int $sectionnumber The number of section
*/
public function i_move_activity_to_section_yui($activityname, $sectionnumber): void {
// Ensure the destination is valid.
$sectionxpath = $this->section_exists($sectionnumber);
// JS enabled.
if ($this->running_javascript()) {
$activitynode = $this->get_activity_element('Move', 'icon', $activityname);
$destinationxpath = $sectionxpath . "/descendant::ul[contains(concat(' ', normalize-space(@class), ' '), ' yui3-dd-drop ')]";
$this->execute("behat_general::i_drag_and_i_drop_it_in",
array($this->escape($activitynode->getXpath()), "xpath_element",
$this->escape($destinationxpath), "xpath_element")
$this->execute(
"behat_general::i_drag_and_i_drop_it_in",
[
$this->escape($activitynode->getXpath()), "xpath_element",
$this->escape($destinationxpath), "xpath_element",
]
);
} else {
// Following links with no-JS.
// Moving to the fist spot of the section (before all other section's activities).
$this->execute('behat_course::i_click_on_in_the_activity',
array("a.editing_move", "css_element", $this->escape($activityname))
$this->execute(
'behat_course::i_click_on_in_the_activity',
["a.editing_move", "css_element", $this->escape($activityname)]
);
$this->execute('behat_general::i_click_on_in_the',
array("li.movehere a", "css_element", $this->escape($sectionxpath), "xpath_element")
$this->execute(
'behat_general::i_click_on_in_the',
["li.movehere a", "css_element", $this->escape($sectionxpath), "xpath_element"]
);
}
}

@ -23,9 +23,6 @@ Feature: Activities can be moved between sections
| section | 1 |
And I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
And I add the "Recent activity" block
And I follow "Delete Recent activity block"
And I press "Yes"
Scenario: Move activities in a single page course with Javascript disabled
When I move "Test forum name" activity to section "2"
@ -56,3 +53,8 @@ Feature: Activities can be moved between sections
And I follow "Topic 1"
When I move "Second forum name" activity to section "1"
Then "Second forum name" "link" should appear before "Test forum name" "link"
@javascript
Scenario: Move activity with javascript
When I move "Test forum name" activity to section "3"
Then I should see "Test forum name" in the "Topic 3" "section"

@ -6,32 +6,28 @@ Feature: Sections can be moved
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format | coursedisplay | numsections |
| Course 1 | C1 | topics | 0 | 5 |
| Course 1 | C1 | topics | 0 | 5 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| forum | Test forum name | Test forum name description | C1 | forum1 | 1 |
And I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
Scenario: Move up and down a section with Javascript disabled in a single page course
Given the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| forum | Test forum name | Test forum name description | C1 | forum1 | 1 |
And I am on "Course 1" course homepage with editing mode on
When I move down section "1"
Then I should see "Test forum name" in the "Topic 2" "section"
And I move up section "2"
And I should see "Test forum name" in the "Topic 1" "section"
Scenario: Move up and down a section with Javascript disabled in the course home of a course using paged mode
Given the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| forum | Test forum name | Test forum name description | C1 | forum1 | 1 |
And I am on "Course 1" course homepage with editing mode on
And I navigate to "Settings" in current page administration
Given I navigate to "Settings" in current page administration
And I set the following fields to these values:
| Course layout | Show one section per page |
And I press "Save and display"
@ -41,11 +37,7 @@ Feature: Sections can be moved
And I should see "Test forum name" in the "Topic 1" "section"
Scenario: Sections can not be moved with Javascript disabled in a section page of a course using paged mode
Given the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| forum | Test forum name | Test forum name description | C1 | forum1 | 2 |
And I am on "Course 1" course homepage with editing mode on
And I navigate to "Settings" in current page administration
Given I navigate to "Settings" in current page administration
And I set the following fields to these values:
| Course layout | Show one section per page |
And I press "Save and display"
@ -54,3 +46,10 @@ Feature: Sections can be moved
And "Topic 3" "section" should not exist
And "Move down" "link" should not exist
And "Move up" "link" should not exist
@javascript
Scenario: Move section with javascript
When I open section "1" edit menu
And I click on "Move" "link" in the "Topic 1" "section"
And I click on "Topic 3" "link" in the ".modal-body" "css_element"
Then I should see "Test forum name" in the "Topic 3" "section"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -191,6 +191,20 @@ export default class {
return this.stateManager.state;
}
/**
* Get state data.
*
* Components access the state frequently. This convenience method is a shortcut to
* this.reactive.state.stateManager.get() method.
*
* @param {String} name the state object name
* @param {*} id an optional object id for state maps.
* @return {Object|undefined} the state object found
*/
get(name, id) {
return this.stateManager.get(name, id);
}
/**
* Return the initial state promise.
*

@ -7,6 +7,11 @@ $bg-inverse-link-color: #fff !default;
$dropzone-border: $gray-900 !default;
$collapse-list-item-padding-y: 0.5rem !default;
$collapse-list-item-padding-x: 1rem !default;
$collapse-list-item-hover-bg: theme-color-level('info', -11) !default;
$collapse-list-item-hover-border: theme-color-level('info', -9) !default;
$font-size-xs: ($font-size-base * .75) !default;
#region-main {
@ -2829,3 +2834,35 @@ body.dragging {
visibility: visible;
cursor: move;
}
// Generic classes reactive components can use.
.waitstate {
display: none;
}
.stateready {
.waitstate {
display: inherit;
}
.whilenostate {
display: none;
}
}
// Collapsible list.
.collapse-list {
.collapse-list-item {
padding: $collapse-list-item-padding-y $collapse-list-item-padding-x;
@include hover-focus() {
background-color: $collapse-list-item-hover-bg;
border-color: $collapse-list-item-hover-border;
}
}
.collapse-list-item-content {
.collapse-list-item {
padding-left: calc(#{$collapse-list-item-padding-x} * 3);
}
}
}

@ -12187,6 +12187,24 @@ body.dragging .dragging {
visibility: visible;
cursor: move; }
.waitstate {
display: none; }
.stateready .waitstate {
display: inherit; }
.stateready .whilenostate {
display: none; }
.collapse-list .collapse-list-item {
padding: 0.5rem 1rem; }
.collapse-list .collapse-list-item:hover, .collapse-list .collapse-list-item:focus {
background-color: #e0f0f2;
border-color: #b8dce2; }
.collapse-list .collapse-list-item-content .collapse-list-item {
padding-left: calc(1rem * 3); }
.icon {
font-size: 16px;
width: 16px;

@ -12187,6 +12187,24 @@ body.dragging .dragging {
visibility: visible;
cursor: move; }
.waitstate {
display: none; }
.stateready .waitstate {
display: inherit; }
.stateready .whilenostate {
display: none; }
.collapse-list .collapse-list-item {
padding: 0.5rem 1rem; }
.collapse-list .collapse-list-item:hover, .collapse-list .collapse-list-item:focus {
background-color: #e0f0f2;
border-color: #b8dce2; }
.collapse-list .collapse-list-item-content .collapse-list-item {
padding-left: calc(1rem * 3); }
.icon {
font-size: 16px;
width: 16px;