MDL-74987 core_courseformat: migrate duplicate to state action

This commit is contained in:
Ferran Recio 2023-01-03 11:35:37 +01:00
parent a3264eb5e0
commit 35a12cfffb
13 changed files with 292 additions and 20 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -59,7 +59,7 @@ define(
// component compatible formats and the default actions.js won't be necessary anymore.
// Meanwhile, we filter the migrated actions.
const componentActions = [
'moveSection', 'moveCm', 'addSection', 'deleteSection', 'cmDelete', 'sectionHide', 'sectionShow',
'moveSection', 'moveCm', 'addSection', 'deleteSection', 'cmDelete', 'cmDuplicate', 'sectionHide', 'sectionShow',
'cmHide', 'cmShow', 'cmStealth', 'sectionHighlight', 'sectionUnhighlight',
];

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

@ -408,6 +408,22 @@ export default class extends BaseComponent {
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 cmId = target.dataset.id;
if (!cmId) {
return;
}
const sectionId = target.dataset.sectionid ?? null;
event.preventDefault();
this.reactive.dispatch('cmDuplicate', [cmId], sectionId);
}
/**
* Handle a delete cm request.
*

View File

@ -175,6 +175,32 @@ export default class {
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
*/
async cmDuplicate(stateManager, cmIds, targetSectionId) {
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);
stateManager.processUpdates(updates);
this.sectionLock(stateManager, Array.from(sectionIds), false);
}
/**
* Move course modules to specific course location.
*

View File

@ -422,6 +422,64 @@ class stateactions {
}
}
/**
* Duplicate a course modules instances into the same course.
*
* @param stateupdates $updates the affected course elements track
* @param stdClass $course the course object
* @param int[] $ids course modules ids to duplicate
* @param int $targetsectionid optional target section id destination
* @param int $targetcmid not used
*/
public function cm_duplicate(
stateupdates $updates,
stdClass $course,
array $ids = [],
?int $targetsectionid = null,
?int $targetcmid = null
): void {
$this->validate_cms($course, $ids, __FUNCTION__);
$modinfo = get_fast_modinfo($course);
$cms = $this->get_cm_info($modinfo, $ids);
// Check capabilities on every activity context.
foreach ($cms as $cmid => $cm) {
$modcontext = context_module::instance($cmid);
require_all_capabilities(
['moodle/course:manageactivities', 'moodle/backup:backuptargetimport', 'moodle/restore:restoretargetimport'],
$modcontext
);
if (!course_allowed_module($course, $cm->modname)) {
throw new moodle_exception('No permission to create that activity');
}
}
$targetsection = null;
if (!empty($targetsectionid)) {
$this->validate_sections($course, [$targetsectionid], __FUNCTION__);
$targetsection = $modinfo->get_section_info_by_id($targetsectionid, MUST_EXIST);
}
// Duplicate course modules.
$affectedcmids = [];
foreach ($cms as $cm) {
if ($newcm = duplicate_module($course, $cm)) {
if ($targetsection) {
moveto_module($newcm, $targetsection);
} else {
$affectedcmids[] = $newcm->id;
}
}
}
if ($targetsection) {
$this->section_state($updates, $course, [$targetsection->id]);
} else {
$this->cm_state($updates, $course, $affectedcmids);
}
}
/**
* Delete course cms.
*

View File

@ -16,6 +16,7 @@
namespace core_courseformat;
use course_modinfo;
use moodle_exception;
use stdClass;
@ -35,6 +36,7 @@ class stateactions_test extends \advanced_testcase {
*/
public static function setupBeforeClass(): void {
global $CFG;
// State data uses external_format_string.
require_once($CFG->dirroot . '/lib/externallib.php');
}
@ -174,6 +176,24 @@ class stateactions_test extends \advanced_testcase {
return $result;
}
/**
* Enrol, set and create the test user depending on the role name.
*
* @param stdClass $course the course data
* @param string $rolename the testing role name
*/
private function set_test_user_by_role(stdClass $course, string $rolename) {
if ($rolename == 'admin') {
$this->setAdminUser();
} else {
$user = $this->getDataGenerator()->create_user();
if ($rolename != 'unenroled') {
$this->getDataGenerator()->enrol_user($user->id, $course->id, $rolename);
}
$this->setUser($user);
}
}
/**
* Test the behaviour course_state.
*
@ -207,15 +227,7 @@ class stateactions_test extends \advanced_testcase {
$references = $this->course_references($course);
// Create and enrol user using given role.
if ($role == 'admin') {
$this->setAdminUser();
} else {
$user = $this->getDataGenerator()->create_user();
if ($role != 'unenroled') {
$this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
}
$this->setUser($user);
}
$this->set_test_user_by_role($course, $role);
// Add some activities to the course. One visible and one hidden in both sections 1 and 2.
$references["cm0"] = $this->create_activity($course->id, 'assign', 1, true);
@ -874,6 +886,155 @@ class stateactions_test extends \advanced_testcase {
];
}
/**
* Duplicate course module method.
*
* @covers ::cm_duplicate
* @dataProvider cm_duplicate_provider
* @param string $targetsection the target section (empty for none)
* @param bool $validcms if uses valid cms
* @param string $role the current user role name
* @param bool $expectedexception if the test will raise an exception
*/
public function test_cm_duplicate(
string $targetsection = '',
bool $validcms = true,
string $role = 'admin',
bool $expectedexception = false
) {
$this->resetAfterTest();
// Create a course with 3 sections.
$course = $this->create_course('topics', 3, []);
$references = $this->course_references($course);
// Create and enrol user using given role.
$this->set_test_user_by_role($course, $role);
// Add some activities to the course. One visible and one hidden in both sections 1 and 2.
$references["cm0"] = $this->create_activity($course->id, 'assign', 1, true);
$references["cm1"] = $this->create_activity($course->id, 'page', 2, false);
if ($expectedexception) {
$this->expectException(moodle_exception::class);
}
// Initialise stateupdates.
$courseformat = course_get_format($course->id);
$updates = new stateupdates($courseformat);
// Execute method.
$targetsectionid = (!empty($targetsection)) ? $references[$targetsection] : null;
$cmrefs = ($validcms) ? ['cm0', 'cm1'] : ['invalidcm'];
$actions = new stateactions();
$actions->cm_duplicate(
$updates,
$course,
$this->translate_references($references, $cmrefs),
$targetsectionid,
);
// Check the new elements in the course structure.
$originalsections = [
'assign' => $references['section1'],
'page' => $references['section2'],
];
$modinfo = course_modinfo::instance($course);
$cms = $modinfo->get_cms();
$i = 0;
foreach ($cms as $cmid => $cminfo) {
if ($cmid == $references['cm0'] || $cmid == $references['cm1']) {
continue;
}
$references["newcm$i"] = $cmid;
if ($targetsectionid) {
$this->assertEquals($targetsectionid, $cminfo->section);
} else {
$this->assertEquals($originalsections[$cminfo->modname], $cminfo->section);
}
$i++;
}
// Check the resulting updates.
$results = $this->summarize_updates($updates);
if ($targetsectionid) {
$this->assertArrayHasKey($references[$targetsection], $results['put']['section']);
} else {
$this->assertArrayHasKey($references['section1'], $results['put']['section']);
$this->assertArrayHasKey($references['section2'], $results['put']['section']);
}
$countcms = ($targetsection == 'section3' || $targetsection === '') ? 2 : 3;
$this->assertCount($countcms, $results['put']['cm']);
$this->assertArrayHasKey($references['newcm0'], $results['put']['cm']);
$this->assertArrayHasKey($references['newcm1'], $results['put']['cm']);
}
/**
* Duplicate course module data provider.
*
* @return array the testing scenarios
*/
public function cm_duplicate_provider(): array {
return [
'valid cms without target section' => [
'targetsection' => '',
'validcms' => true,
'role' => 'admin',
'expectedexception' => false,
],
'valid cms targeting an empty section' => [
'targetsection' => 'section3',
'validcms' => true,
'role' => 'admin',
'expectedexception' => false,
],
'valid cms targeting a section with activities' => [
'targetsection' => 'section2',
'validcms' => true,
'role' => 'admin',
'expectedexception' => false,
],
'invalid cms without target section' => [
'targetsection' => '',
'validcms' => false,
'role' => 'admin',
'expectedexception' => true,
],
'invalid cms with target section' => [
'targetsection' => 'section3',
'validcms' => false,
'role' => 'admin',
'expectedexception' => true,
],
'student role with target section' => [
'targetsection' => 'section3',
'validcms' => true,
'role' => 'student',
'expectedexception' => true,
],
'student role without target section' => [
'targetsection' => '',
'validcms' => true,
'role' => 'student',
'expectedexception' => true,
],
'unrenolled user with target section' => [
'targetsection' => 'section3',
'validcms' => true,
'role' => 'unenroled',
'expectedexception' => true,
],
'unrenolled user without target section' => [
'targetsection' => '',
'validcms' => true,
'role' => 'unenroled',
'expectedexception' => true,
],
];
}
/**
* Test for cm_delete
*

View File

@ -1816,10 +1816,15 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
plugin_supports('mod', $mod->modname, FEATURE_BACKUP_MOODLE2) &&
course_allowed_module($mod->get_course(), $mod->modname)) {
$actions['duplicate'] = new action_menu_link_secondary(
new moodle_url($baseurl, array('duplicate' => $mod->id)),
new moodle_url($baseurl, ['duplicate' => $mod->id]),
new pix_icon('t/copy', '', 'moodle', array('class' => 'iconsmall')),
$str->duplicate,
array('class' => 'editing_duplicate', 'data-action' => 'duplicate', 'data-sectionreturn' => $sr)
[
'class' => 'editing_duplicate',
'data-action' => ($courseformat->supports_components()) ? 'cmDuplicate' : 'duplicate',
'data-sectionreturn' => $sr,
'data-id' => $mod->id,
]
);
}

View File

@ -1092,8 +1092,14 @@ class behat_course extends behat_base {
"/ancestor::li[contains(concat(' ', normalize-space(@class), ' '), ' section ')]" .
"/descendant::div[contains(concat(' ', @class, ' '), ' lightbox ')][contains(@style, 'display: none')]";
$this->execute("behat_general::wait_until_exists",
array($this->escape($hiddenlightboxxpath), "xpath_element")
// Component based courses do not use lightboxes anymore but js depending.
$sectionreadyxpath = "//*[contains(@id,'page-content')]" .
"/descendant::*[contains(concat(' ', normalize-space(@class), ' '), ' stateready ')]";
$duplicationreadyxpath = "$hiddenlightboxxpath | $sectionreadyxpath";
$this->execute(
"behat_general::wait_until_exists",
[$this->escape($duplicationreadyxpath), "xpath_element"]
);
// Close the original activity actions menu.