// 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 {get_string as getString} from "core/str";
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
);
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
);
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);
}
/**
* 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);
stateManager.processUpdates(updates);
this.cmLock(stateManager, cmids, false);
}
/**
* Move course modules to 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 sectionMove(stateManager, sectionIds, targetSectionId) {
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);
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);
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);
}
/**
* 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 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;
}
/**
* 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);
}
}