MDL-76893 core_courseformat: bulk move activites

This commit is contained in:
Ferran Recio 2023-02-09 18:20:58 +01:00 committed by Sara Arjona
parent 718108c52f
commit 3beffbb506
11 changed files with 160 additions and 54 deletions

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

View File

@ -31,7 +31,7 @@ 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, getFirst} from 'core/normalise';
import {getFirst} from 'core/normalise';
import * as CourseEvents from 'core_course/events';
import Pending from 'core/pending';
import ContentTree from 'core_courseformat/local/courseeditor/contenttree';
@ -291,39 +291,48 @@ export default class extends BaseComponent {
*/
async _requestMoveCm(target, event) {
// Check we have an id.
const cmId = target.dataset.id;
if (!cmId) {
const cmIds = this._getTargetIds(target);
if (cmIds.length == 0) {
return;
}
const cmInfo = this.reactive.get('cm', cmId);
event.preventDefault();
// The section edit menu to refocus on end.
const editTools = this._getClosestActionMenuToogler(target);
// Collect section information from the state.
// Collect 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;
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');
}
// Build the modal parameters from the event data.
const modalParams = {
title: getString('movecoursemodule', 'core'),
title: titleText,
body: Templates.render('core_courseformat/local/content/movecm', data),
};
// Create the modal.
const modal = await this._modalBodyRenderedPromise(modalParams);
const modalBody = getList(modal.getBody())[0];
const modalBody = getFirst(modal.getBody());
// Disable current element.
let currentElement = modalBody.querySelector(`${this.selectors.CMLINK}[data-id='${cmId}']`);
this._disableLink(currentElement);
// 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(
@ -337,17 +346,20 @@ export default class extends BaseComponent {
);
// Open the cm section node if possible (Bootstrap 4 uses jQuery to interact with collapsibles).
// All jQuery int this code can be replaced when MDL-71979 is integrated.
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('#', '');
jQuery(`#${collapsibleId}`).collapse('toggle');
}
// 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');
}
});
// Capture click.
modalBody.addEventListener('click', (event) => {
const target = event.target;
if (!target.matches('a') || target.dataset.for === undefined || target.dataset.id === undefined) {
@ -358,7 +370,6 @@ export default class extends BaseComponent {
}
event.preventDefault();
// Get draggable data from cm or section to dispatch.
let targetSectionId;
let targetCmId;
if (target.dataset.for == 'cm') {
@ -370,8 +381,7 @@ export default class extends BaseComponent {
targetSectionId = target.dataset.id;
targetCmId = section?.cmlist[0];
}
this.reactive.dispatch('cmMove', [cmId], targetSectionId, targetCmId);
this.reactive.dispatch('cmMove', cmIds, targetSectionId, targetCmId);
this._destroyModal(modal, editTools);
});
}

View File

@ -227,6 +227,7 @@ export default class {
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);
}

View File

@ -113,6 +113,14 @@ class bulkedittools implements named_templatable, renderable {
$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',

View File

@ -62,41 +62,42 @@ class stateactions {
throw new moodle_exception("Action cm_move requires targetsectionid or targetcmid");
}
$this->validate_cms($course, $ids, __FUNCTION__);
// Check capabilities on every activity context.
foreach ($ids as $cmid) {
$modcontext = context_module::instance($cmid);
require_capability('moodle/course:manageactivities', $modcontext);
}
$modinfo = get_fast_modinfo($course);
$this->validate_cms($course, $ids, __FUNCTION__, ['moodle/course:manageactivities']);
// The moveto_module function move elements before a specific target.
// To keep the order the movements must be done in descending order (last activity first).
$ids = $this->sort_cm_ids_by_course_position($course, $ids, true);
// Target cm has more priority than target section.
if (!empty($targetcmid)) {
$this->validate_cms($course, [$targetcmid], __FUNCTION__);
$targetcm = $modinfo->get_cm($targetcmid);
$targetsection = $modinfo->get_section_info_by_id($targetcm->section, MUST_EXIST);
$targetcm = get_fast_modinfo($course)->get_cm($targetcmid);
$targetsectionid = $targetcm->section;
} else {
$this->validate_sections($course, [$targetsectionid], __FUNCTION__);
$targetcm = null;
$targetsection = $modinfo->get_section_info_by_id($targetsectionid, MUST_EXIST);
}
// The origin sections must be updated as well.
$originalsections = [];
$cms = $this->get_cm_info($modinfo, $ids);
foreach ($cms as $cm) {
$currentsection = $modinfo->get_section_info_by_id($cm->section, MUST_EXIST);
moveto_module($cm, $targetsection, $targetcm);
$beforecmdid = $targetcmid;
foreach ($ids as $cmid) {
// An updated $modinfo is needed on every loop as activities list change.
$modinfo = get_fast_modinfo($course);
$cm = $modinfo->get_cm($cmid);
$currentsectionid = $cm->section;
$targetsection = $modinfo->get_section_info_by_id($targetsectionid, MUST_EXIST);
$beforecm = (!empty($beforecmdid)) ? $modinfo->get_cm($beforecmdid) : null;
if ($beforecm === null || $beforecm->id != $cmid) {
moveto_module($cm, $targetsection, $beforecm);
}
$beforecmdid = $cm->id;
$updates->add_cm_put($cm->id);
if ($currentsection->id != $targetsection->id) {
$originalsections[$currentsection->id] = true;
if ($currentsectionid != $targetsectionid) {
$originalsections[$currentsectionid] = true;
}
// If some of the original sections are also target sections, we don't need to update them.
if (array_key_exists($targetsection->id, $originalsections)) {
unset($originalsections[$targetsection->id]);
if (array_key_exists($targetsectionid, $originalsections)) {
unset($originalsections[$targetsectionid]);
}
}
@ -108,6 +109,35 @@ class stateactions {
}
}
/**
* Sort the cm ids list depending on the course position.
*
* Some actions like move should be done in an specific order.
*
* @param stdClass $course the course object
* @param int[] $cmids the array of section $ids
* @param bool $descending if the sort order must be descending instead of ascending
* @return int[] the array of section ids sorted by section number
*/
protected function sort_cm_ids_by_course_position(
stdClass $course,
array $cmids,
bool $descending = false
): array {
$modinfo = get_fast_modinfo($course);
$cmlist = array_keys($modinfo->get_cms());
$cmposition = [];
foreach ($cmids as $cmid) {
$cmposition[$cmid] = array_search($cmid, $cmlist);
}
$sorting = ($descending) ? -1 : 1;
$sortfunction = function ($acmid, $bcmid) use ($sorting, $cmposition) {
return ($cmposition[$acmid] <=> $cmposition[$bcmid]) * $sorting;
};
usort($cmids, $sortfunction);
return $cmids;
}
/**
* Move course sections to another location in the same course.
*
@ -942,14 +972,15 @@ class stateactions {
}
/**
* Checks related to course modules: all given cm exist.
* Checks related to course modules: all given cm exist and the user has the required capabilities.
*
* @param stdClass $course The course where given $cmids belong.
* @param array $cmids List of course module ids to validate.
* @param string $info additional information in case of error.
* @param array $capabilities optional capabilities checks per each cm context.
* @throws moodle_exception if any id is not valid
*/
protected function validate_cms(stdClass $course, array $cmids, ?string $info = null): void {
protected function validate_cms(stdClass $course, array $cmids, ?string $info = null, array $capabilities = []): void {
if (empty($cmids)) {
throw new moodle_exception('emptycmids', 'core', null, $info);
@ -960,5 +991,11 @@ class stateactions {
if (count($cmids) != count($intersect)) {
throw new moodle_exception('unexistingcmid', 'core', null, $info);
}
if (!empty($capabilities)) {
foreach ($cmids as $cmid) {
$modcontext = context_module::instance($cmid);
require_all_capabilities($capabilities, $modcontext);
}
}
}
}

View File

@ -76,7 +76,7 @@
}
}}
<p data-for="sectionname">{{#str}} movefull, moodle, {{cmname}} {{/str}}:</p>
<p data-for="sectionname">{{information}}:</p>
<nav class="collapse-list" id="destination-selector" role="tree">
{{#sections}}
<div data-for="sectionnode"

View File

@ -125,3 +125,48 @@ Feature: Bulk course activity actions.
And I should not see "Activity sample 3" in the "Topic 2" "section"
And I should see "Activity sample 4" in the "Topic 2" "section"
And I should see "0 selected" in the "sticky-footer" "region"
Scenario: Bulk move activities after a specific activity
Given I should see "Activity sample 1" in the "Topic 1" "section"
And I should see "Activity sample 2" in the "Topic 1" "section"
And I should see "Activity sample 3" in the "Topic 2" "section"
And I should see "Activity sample 4" in the "Topic 2" "section"
And I click on "Select activity Activity sample 1" "checkbox"
And I click on "Select activity Activity sample 3" "checkbox"
And I should see "2 selected" in the "sticky-footer" "region"
When I click on "Move activities" "button" in the "sticky-footer" "region"
And I click on "Activity sample 2" "link" in the "Move selected activities" "dialogue"
And I should see "0 selected" in the "sticky-footer" "region"
# Check activities are moved to the right topics.
Then I should see "Activity sample 1" in the "Topic 1" "section"
And I should see "Activity sample 2" in the "Topic 1" "section"
And I should see "Activity sample 3" in the "Topic 1" "section"
And I should not see "Activity sample 3" in the "Topic 2" "section"
And I should see "Activity sample 4" in the "Topic 2" "section"
# Check new activities order.
And "Activity sample 1" "activity" should appear after "Activity sample 2" "activity"
And "Activity sample 3" "activity" should appear after "Activity sample 1" "activity"
And "Activity sample 4" "activity" should appear after "Activity sample 3" "activity"
Scenario: Bulk move activities after a specific section header
Given I should see "Activity sample 1" in the "Topic 1" "section"
And I should see "Activity sample 2" in the "Topic 1" "section"
And I should see "Activity sample 3" in the "Topic 2" "section"
And I should see "Activity sample 4" in the "Topic 2" "section"
And I click on "Select activity Activity sample 1" "checkbox"
And I click on "Select activity Activity sample 3" "checkbox"
And I should see "2 selected" in the "sticky-footer" "region"
When I click on "Move activities" "button" in the "sticky-footer" "region"
And I click on "Topic 3" "link" in the "Move selected activities" "dialogue"
And I should see "0 selected" in the "sticky-footer" "region"
# Check activities are moved to the right topics.
Then I should see "Activity sample 1" in the "Topic 3" "section"
Then I should not see "Activity sample 1" in the "Topic 1" "section"
And I should see "Activity sample 2" in the "Topic 1" "section"
And I should see "Activity sample 3" in the "Topic 3" "section"
And I should not see "Activity sample 3" in the "Topic 2" "section"
And I should see "Activity sample 4" in the "Topic 2" "section"
# Check new activities order.
And "Activity sample 4" "activity" should appear after "Activity sample 2" "activity"
And "Activity sample 1" "activity" should appear after "Activity sample 4" "activity"
And "Activity sample 3" "activity" should appear after "Activity sample 1" "activity"

View File

@ -38,6 +38,11 @@ $string['cmdelete_title'] = 'Delete activity?';
$string['cmsdelete'] = 'Delete activities';
$string['cmsdelete_info'] = 'This will delete {$a->count} activities and any user data they contain';
$string['cmsdelete_title'] = 'Delete selected activities?';
$string['cmsmove'] = 'Move activities';
$string['cmmove_title'] = 'Move activity';
$string['cmmove_info'] = 'Move "{$a}" activity after';
$string['cmsmove_title'] = 'Move selected activities';
$string['cmsmove_info'] = 'Move {$a} activities after';
$string['courseindex'] = 'Course index';
$string['nobulkaction'] = 'No bulk actions available';
$string['preference:coursesectionspreferences'] = 'Section user preferences for course {$a}';