mirror of
https://github.com/moodle/moodle.git
synced 2025-01-18 05:58:34 +01:00
MDL-71211 core_course: Keep the status of course index.
This commit is contained in:
parent
8885e22a0b
commit
0350d42610
2
course/format/amd/build/local/content.min.js
vendored
2
course/format/amd/build/local/content.min.js
vendored
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:[],isactive:!0}),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,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
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
@ -45,6 +45,12 @@ export default class Component extends BaseComponent {
|
||||
SECTION_CMLIST: `[data-for='cmlist']`,
|
||||
COURSE_SECTIONLIST: `[data-for='course_sectionlist']`,
|
||||
CM: `[data-for='cmitem']`,
|
||||
TOGGLER: `[data-action="togglecoursecontentsection"]`,
|
||||
COLLAPSE: `[data-toggle="collapse"]`,
|
||||
};
|
||||
// Default classes to toggle on refresh.
|
||||
this.classes = {
|
||||
COLLAPSED: `collapsed`,
|
||||
};
|
||||
// Array to save dettached elements during element resorting.
|
||||
this.dettachedCms = {};
|
||||
@ -71,15 +77,43 @@ export default class Component extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Initial state ready method.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
stateReady() {
|
||||
this._indexContents();
|
||||
// Activate section togglers.
|
||||
this.addEventListener(this.element, 'click', this._sectionTogglers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
|
||||
if (isChevron || isCollapsed) {
|
||||
// Update the state.
|
||||
const sectionId = section.getAttribute('data-id');
|
||||
this.reactive.dispatch(
|
||||
'sectionPreferences',
|
||||
[sectionId],
|
||||
{
|
||||
contentexpanded: isCollapsed,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -97,6 +131,8 @@ export default class Component extends BaseComponent {
|
||||
{watch: `cm.visible:updated`, handler: this._reloadCm},
|
||||
// Update section number and title.
|
||||
{watch: `section.number:updated`, handler: this._refreshSectionNumber},
|
||||
// Collapse and expand sections.
|
||||
{watch: `section.contentexpanded:updated`, handler: this._refreshSectionCollapsed},
|
||||
// Sections and cm sorting.
|
||||
{watch: `transaction:start`, handler: this._startProcessing},
|
||||
{watch: `course.sectionlist:updated`, handler: this._refreshCourseSectionlist},
|
||||
@ -124,6 +160,25 @@ export default class Component extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update section collapsed.
|
||||
*
|
||||
* @param {Object} details the update details.
|
||||
*/
|
||||
_refreshSectionCollapsed({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.contentexpanded === isCollapsed) {
|
||||
toggler.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the component to start a transaction.
|
||||
*
|
||||
|
@ -68,7 +68,6 @@ export default class {
|
||||
const section = {
|
||||
...sectioninfo,
|
||||
cms: [],
|
||||
isactive: true,
|
||||
};
|
||||
const cmlist = sectioninfo.cmlist ?? [];
|
||||
cmlist.forEach(cmid => {
|
||||
|
@ -203,6 +203,57 @@ export default class {
|
||||
stateManager.setReadOnly(true);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get updated user preferences and state data related to some section ids.
|
||||
*
|
||||
* @param {StateManager} stateManager the current state
|
||||
* @param {array} sectionIds the list of section ids to update
|
||||
* @param {Object} preferences the new preferences values
|
||||
*/
|
||||
async sectionPreferences(stateManager, sectionIds, preferences) {
|
||||
stateManager.setReadOnly(false);
|
||||
// Check if we need to update preferences.
|
||||
let updatePreferences = false;
|
||||
sectionIds.forEach(sectionId => {
|
||||
const section = stateManager.get('section', sectionId);
|
||||
if (section === undefined) {
|
||||
return;
|
||||
}
|
||||
let newValue = preferences.contentexpanded ?? section.contentexpanded;
|
||||
if (section.contentexpanded != newValue) {
|
||||
section.contentexpanded = newValue;
|
||||
updatePreferences = true;
|
||||
}
|
||||
newValue = preferences.isactive ?? section.isactive;
|
||||
if (section.isactive != newValue) {
|
||||
section.isactive = newValue;
|
||||
updatePreferences = true;
|
||||
}
|
||||
});
|
||||
stateManager.setReadOnly(true);
|
||||
|
||||
if (updatePreferences) {
|
||||
// Build the preference structures.
|
||||
const course = stateManager.get('course');
|
||||
const state = stateManager.state;
|
||||
const prefKey = `coursesectionspreferences_${course.id}`;
|
||||
const preferences = {
|
||||
contentcollapsed: [],
|
||||
indexcollapsed: [],
|
||||
};
|
||||
state.section.forEach(section => {
|
||||
if (!section.contentexpanded) {
|
||||
preferences.contentcollapsed.push(section.id);
|
||||
}
|
||||
if (!section.isactive) {
|
||||
preferences.indexcollapsed.push(section.id);
|
||||
}
|
||||
});
|
||||
const jsonString = JSON.stringify(preferences);
|
||||
M.util.set_user_preference(prefKey, jsonString);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updated state data related to some cm ids.
|
||||
*
|
||||
|
@ -24,6 +24,7 @@
|
||||
|
||||
import {BaseComponent} from 'core/reactive';
|
||||
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
|
||||
import jQuery from 'jquery';
|
||||
|
||||
export default class Component extends BaseComponent {
|
||||
|
||||
@ -73,7 +74,7 @@ export default class Component extends BaseComponent {
|
||||
*/
|
||||
stateReady() {
|
||||
// Activate section togglers.
|
||||
this.addEventListener(this.element, 'click', this._setupSectionTogglers);
|
||||
this.addEventListener(this.element, 'click', this._sectionTogglers);
|
||||
|
||||
// Get cms and sections elements.
|
||||
const sections = this.getElements(this.selectors.SECTION);
|
||||
@ -88,6 +89,7 @@ export default class Component extends BaseComponent {
|
||||
|
||||
getWatchers() {
|
||||
return [
|
||||
{watch: `section.isactive:updated`, handler: this._refreshSectionCollapsed},
|
||||
{watch: `cm:created`, handler: this._createCm},
|
||||
{watch: `cm:deleted`, handler: this._deleteCm},
|
||||
// Sections and cm sorting.
|
||||
@ -104,16 +106,83 @@ export default class Component extends BaseComponent {
|
||||
*
|
||||
* @param {Event} event the triggered event
|
||||
*/
|
||||
_setupSectionTogglers(event) {
|
||||
_sectionTogglers(event) {
|
||||
const sectionlink = event.target.closest(this.selectors.TOGGLER);
|
||||
if (sectionlink) {
|
||||
const toggler = sectionlink.parentNode.querySelector(this.selectors.COLLAPSE);
|
||||
if (toggler?.classList.contains(this.classes.COLLAPSED)) {
|
||||
toggler.click();
|
||||
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;
|
||||
|
||||
if (isChevron || isCollapsed) {
|
||||
// Update the state.
|
||||
const sectionId = section.getAttribute('data-id');
|
||||
this.reactive.dispatch(
|
||||
'sectionPreferences',
|
||||
[sectionId],
|
||||
{
|
||||
isactive: isCollapsed,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update section collapsed.
|
||||
*
|
||||
* @param {Object} details the update details.
|
||||
*/
|
||||
_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.isactive === isCollapsed) {
|
||||
this._expandSectionNode(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a section node.
|
||||
*
|
||||
* By default the method will use element.isactive to decide if the
|
||||
* section is opened or closed. However, using forceValue it is possible
|
||||
* to open or close a section independant from the isactive 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.isactive) ? true : false;
|
||||
}
|
||||
|
||||
// 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.
|
||||
const togglerValue = (forceValue) ? 'show' : 'hide';
|
||||
jQuery(collapsible).collapse(togglerValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a newcm instance.
|
||||
*
|
||||
|
@ -39,6 +39,7 @@ use lang_string;
|
||||
use completion_info;
|
||||
use external_api;
|
||||
use stdClass;
|
||||
use cache;
|
||||
use core_courseformat\output\legacy_renderer;
|
||||
|
||||
/**
|
||||
@ -505,6 +506,50 @@ abstract class base {
|
||||
return $this->singlesection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the format section preferences.
|
||||
*/
|
||||
public function get_sections_preferences(): array {
|
||||
global $USER;
|
||||
|
||||
$result = [];
|
||||
|
||||
$course = $this->get_course();
|
||||
$coursesectionscache = cache::make('core', 'coursesectionspreferences');
|
||||
|
||||
$coursesections = $coursesectionscache->get($course->id);
|
||||
if ($coursesections) {
|
||||
return $coursesections;
|
||||
}
|
||||
|
||||
// Calculate collapsed preferences.
|
||||
try {
|
||||
$sectionpreferences = (array) json_decode(
|
||||
get_user_preferences('coursesectionspreferences_' . $course->id, null, $USER->id)
|
||||
);
|
||||
if (empty($sectionpreferences)) {
|
||||
$sectionpreferences = [];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$sectionpreferences = [];
|
||||
}
|
||||
|
||||
foreach ($sectionpreferences as $preference => $sectionids) {
|
||||
if (!empty($sectionids) && is_array($sectionids)) {
|
||||
foreach ($sectionids as $sectionid) {
|
||||
if (!isset($result[$sectionid])) {
|
||||
$result[$sectionid] = new stdClass();
|
||||
}
|
||||
$result[$sectionid]->$preference = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$coursesectionscache->set($course->id, $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the information about the ajax support in the given source format
|
||||
*
|
||||
@ -1496,4 +1541,17 @@ abstract class base {
|
||||
public function get_config_for_external() {
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Course deletion hook.
|
||||
*
|
||||
* Format plugins can override this method to clean any format specific data and dependencies.
|
||||
*
|
||||
*/
|
||||
public function delete_format_data() {
|
||||
global $DB;
|
||||
$course = $this->get_course();
|
||||
// By default, formats store some most display specifics in a user preference.
|
||||
$DB->delete_records('user_preferences', ['name' => 'coursesectionspreferences_' . $course->id]);
|
||||
}
|
||||
}
|
||||
|
@ -161,8 +161,14 @@ class section implements renderable, templatable {
|
||||
$data->sitehome = true;
|
||||
}
|
||||
|
||||
// For now sections are always expanded. User preferences will be done in MDL-71211.
|
||||
$data->isactive = true;
|
||||
$data->contentexpanded = true;
|
||||
$preferences = $format->get_sections_preferences();
|
||||
if (isset($preferences[$thissection->id])) {
|
||||
$sectionpreferences = $preferences[$thissection->id];
|
||||
if (!empty($sectionpreferences->contentcollapsed)) {
|
||||
$data->contentexpanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($thissection->section == 0) {
|
||||
// Section zero is always visible only as a cmlist.
|
||||
|
@ -54,11 +54,25 @@ class section implements renderable {
|
||||
* @return array data context for a mustache template
|
||||
*/
|
||||
public function export_for_template(\renderer_base $output): stdClass {
|
||||
|
||||
$format = $this->format;
|
||||
$course = $format->get_course();
|
||||
$section = $this->section;
|
||||
$modinfo = $format->get_modinfo();
|
||||
|
||||
$isactive = true;
|
||||
$contentexpanded = true;
|
||||
$preferences = $format->get_sections_preferences();
|
||||
if (isset($preferences[$section->id])) {
|
||||
$sectionpreferences = $preferences[$section->id];
|
||||
if (!empty($sectionpreferences->contentcollapsed)) {
|
||||
$contentexpanded = false;
|
||||
}
|
||||
if (!empty($sectionpreferences->indexcollapsed)) {
|
||||
$isactive = false;
|
||||
}
|
||||
}
|
||||
|
||||
$data = (object)[
|
||||
'id' => $section->id,
|
||||
'section' => $section->section,
|
||||
@ -69,6 +83,8 @@ class section implements renderable {
|
||||
'visible' => !empty($section->visible),
|
||||
'sectionurl' => course_get_url($course, $section->section)->out(),
|
||||
'current' => $format->is_section_current($section),
|
||||
'isactive' => $isactive,
|
||||
'contentexpanded' => $contentexpanded
|
||||
];
|
||||
|
||||
if (empty($modinfo->sections[$section->section])) {
|
||||
|
@ -16,21 +16,68 @@
|
||||
|
||||
namespace core_courseformat\privacy;
|
||||
|
||||
use core_privacy\local\metadata\collection;
|
||||
|
||||
/**
|
||||
* Privacy provider implementation for courseformat core subsytem.
|
||||
* Privacy provider implementation for courseformat core subsystem.
|
||||
*
|
||||
* @package core_courseformat
|
||||
* @copyright 2021 Ferran Recio <ferran@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class provider implements \core_privacy\local\metadata\null_provider {
|
||||
class provider implements
|
||||
// This system has data.
|
||||
\core_privacy\local\metadata\provider,
|
||||
// This system has some sitewide user preferences to export.
|
||||
\core_privacy\local\request\user_preference_provider {
|
||||
|
||||
/** The user preference for the navigation drawer. */
|
||||
public const SECTION_PREFERENCES_PREFIX = 'coursesectionspreferences';
|
||||
|
||||
/**
|
||||
* Get the language string identifier with the component's language
|
||||
* file to explain why this plugin stores no data.
|
||||
* Returns meta data about this system.
|
||||
*
|
||||
* @return string
|
||||
* @param collection $collection The initialised collection to add items to.
|
||||
* @return collection A listing of user data stored through this system.
|
||||
*/
|
||||
public static function get_reason(): string {
|
||||
return 'privacy:metadata';
|
||||
public static function get_metadata(collection $collection): collection {
|
||||
|
||||
$collection->add_user_preference(
|
||||
self::SECTION_PREFERENCES_PREFIX,
|
||||
'privacy:metadata:preference:' . self::SECTION_PREFERENCES_PREFIX
|
||||
);
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store all user preferences for this system.
|
||||
*
|
||||
* @param int $userid The userid of the user whose data is to be exported.
|
||||
*/
|
||||
public static function export_user_preferences(int $userid) {
|
||||
|
||||
// Get user courses.
|
||||
$courses = enrol_get_all_users_courses($userid);
|
||||
|
||||
if (empty($courses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($courses as $course) {
|
||||
$preferencename = self::SECTION_PREFERENCES_PREFIX . '_' . $course->id;
|
||||
|
||||
$preference = get_user_preferences($preferencename, null, $userid);
|
||||
|
||||
if (isset($preference)) {
|
||||
$preferencestring = get_string('preference:' . self::SECTION_PREFERENCES_PREFIX, 'courseformat', $course->fullname);
|
||||
\core_privacy\local\request\writer::export_user_preference(
|
||||
'core_courseformat',
|
||||
$preferencename,
|
||||
$preference,
|
||||
$preferencestring
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -73,7 +73,7 @@
|
||||
"cmcontrols": "[Add an activity or resource]",
|
||||
"iscoursedisplaymultipage": true,
|
||||
"sectionreturnid": 0,
|
||||
"isactive": true,
|
||||
"contentexpanded": true,
|
||||
"sitehome": false
|
||||
}
|
||||
}}
|
||||
@ -97,7 +97,7 @@
|
||||
{{#header}} {{> core_courseformat/local/content/section/header }} {{/header}}
|
||||
<div id="coursecontentcollapse{{num}}"
|
||||
class="content {{^iscoursedisplaymultipage}}
|
||||
{{^sitehome}}course-content-item-content collapse {{#isactive}}show{{/isactive}}{{/sitehome}}
|
||||
{{^sitehome}}course-content-item-content collapse {{#contentexpanded}}show{{/contentexpanded}}{{/sitehome}}
|
||||
{{/iscoursedisplaymultipage}}">
|
||||
{{#availability}}
|
||||
{{> core_courseformat/local/content/section/availability }}
|
||||
|
@ -57,9 +57,9 @@
|
||||
<a role="button" data-toggle="collapse"
|
||||
href="#coursecontentcollapse{{num}}"
|
||||
id="collapssesection{{num}}"
|
||||
aria-expanded="{{#isactive}}true{{/isactive}}{{^isactive}}false{{/isactive}}"
|
||||
aria-expanded="{{#contentexpanded}}true{{/contentexpanded}}{{^contentexpanded}}false{{/contentexpanded}}"
|
||||
aria-controls="coursecontentcollapse{{num}}"
|
||||
class="btn btn-icon mr-1 icons-collapse-expand {{^isactive}}collapsed{{/isactive}}"
|
||||
class="btn btn-icon mr-1 icons-collapse-expand {{^contentexpanded}}collapsed{{/contentexpanded}}"
|
||||
aria-label="{{name}}">
|
||||
<span class="expanded-icon icon-no-margin p-2" data-toggle="tooltip" title="{{#str}} collapse, core {{/str}}">
|
||||
{{#pix}} t/expandedchevron, core {{/pix}}
|
||||
@ -69,7 +69,8 @@
|
||||
</span>
|
||||
</a>
|
||||
<h3 class="sectionid-{{id}}-title sectionname course-content-item {{^visible}}dimmed{{/visible}}"
|
||||
id="coursecontentsection{{num}}" data-for="section_title" data-id="{{id}}" data-number="{{num}}">
|
||||
id="coursecontentsection{{num}}" data-for="section_title" data-id="{{id}}" data-number="{{num}}"
|
||||
data-action="togglecoursecontentsection">
|
||||
{{{title}}}
|
||||
</h3>
|
||||
</div>
|
||||
|
@ -254,6 +254,112 @@ class base_test extends advanced_testcase {
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for the default delete format data behaviour.
|
||||
*
|
||||
* @covers ::get_sections_preferences
|
||||
*/
|
||||
public function test_get_sections_preferences() {
|
||||
|
||||
$this->resetAfterTest();
|
||||
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$user = $generator->create_and_enrol($course, 'student');
|
||||
|
||||
// Create fake preferences generated by the frontend js module.
|
||||
$data = (object)[
|
||||
'pref1' => [1,2],
|
||||
'pref2' => [1],
|
||||
];
|
||||
set_user_preference('coursesectionspreferences_' . $course->id, json_encode($data), $user->id);
|
||||
|
||||
$format = course_get_format($course);
|
||||
|
||||
// Load data from user 1.
|
||||
$this->setUser($user);
|
||||
$preferences = $format->get_sections_preferences();
|
||||
|
||||
$this->assertEquals(
|
||||
(object)['pref1' => true, 'pref2' => true],
|
||||
$preferences[1]
|
||||
);
|
||||
$this->assertEquals(
|
||||
(object)['pref1' => true],
|
||||
$preferences[2]
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for the default delete format data behaviour.
|
||||
*
|
||||
* @covers ::delete_format_data
|
||||
* @dataProvider delete_format_data_provider
|
||||
* @param bool $usehook if it should use course_delete to trigger $format->delete_format_data as a hook
|
||||
*/
|
||||
public function test_delete_format_data(bool $usehook) {
|
||||
global $DB;
|
||||
|
||||
$this->resetAfterTest();
|
||||
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
course_create_sections_if_missing($course, [0, 1]);
|
||||
$user = $generator->create_and_enrol($course, 'student');
|
||||
|
||||
// Create a coursesectionspreferences_XX preference.
|
||||
$key = 'coursesectionspreferences_' . $course->id;
|
||||
$fakevalue = 'No dark sarcasm in the classroom';
|
||||
set_user_preference($key, $fakevalue, $user->id);
|
||||
$this->assertEquals(
|
||||
$fakevalue,
|
||||
$DB->get_field('user_preferences', 'value', ['name' => $key, 'userid' => $user->id])
|
||||
);
|
||||
|
||||
// Create another random user preference.
|
||||
$key2 = 'somepreference';
|
||||
$fakevalue2 = "All in all it's just another brick in the wall";
|
||||
set_user_preference($key2, $fakevalue2, $user->id);
|
||||
$this->assertEquals(
|
||||
$fakevalue2,
|
||||
$DB->get_field('user_preferences', 'value', ['name' => $key2, 'userid' => $user->id])
|
||||
);
|
||||
|
||||
if ($usehook) {
|
||||
delete_course($course, false);
|
||||
} else {
|
||||
$format = course_get_format($course);
|
||||
$format->delete_format_data();
|
||||
}
|
||||
|
||||
// Check which the preferences exists.
|
||||
$this->assertFalse(
|
||||
$DB->record_exists('user_preferences', ['name' => $key, 'userid' => $user->id])
|
||||
);
|
||||
set_user_preference($key2, $fakevalue2, $user->id);
|
||||
$this->assertEquals(
|
||||
$fakevalue2,
|
||||
$DB->get_field('user_preferences', 'value', ['name' => $key2, 'userid' => $user->id])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for test_delete_format_data.
|
||||
*
|
||||
* @return array the testing scenarios
|
||||
*/
|
||||
public function delete_format_data_provider(): array {
|
||||
return [
|
||||
'direct call' => [
|
||||
'usehook' => false
|
||||
],
|
||||
'use hook' => [
|
||||
'usehook' => true,
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
76
course/format/tests/behat/content_preferences.feature
Normal file
76
course/format/tests/behat/content_preferences.feature
Normal file
@ -0,0 +1,76 @@
|
||||
@core @core_course @core_courseformat
|
||||
Feature: Course content collapsed user preferences
|
||||
In order to quickly access the course content
|
||||
As a user
|
||||
I need to keep the course sections collapsed when I return to the course.
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
| student1 | Student | 1 | student1@example.com |
|
||||
And the following "course" exists:
|
||||
| fullname | Course 1 |
|
||||
| shortname | C1 |
|
||||
| category | 0 |
|
||||
| enablecompletion | 1 |
|
||||
| numsections | 5 |
|
||||
And the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | section |
|
||||
| assign | Activity sample 1 | Test assignment description | C1 | sample1 | 1 |
|
||||
| book | Activity sample 2 | Test book description | C1 | sample2 | 2 |
|
||||
| choice | Activity sample 3 | Test choice description | C1 | sample3 | 3 |
|
||||
| assign | Activity sample 4 | Test assignment description | C1 | sample1 | 4 |
|
||||
| assign | Activity sample 5 | Test assignment description | C1 | sample1 | 5 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| student1 | C1 | student |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
|
||||
@javascript
|
||||
Scenario: Course content preferences
|
||||
Given I am on the "C1" "Course" page logged in as "teacher1"
|
||||
And I should see "Topic 1" in the "region-main" "region"
|
||||
And I should see "Activity sample 1" in the "region-main" "region"
|
||||
And I should see "Topic 2" in the "region-main" "region"
|
||||
And I should see "Activity sample 2" in the "region-main" "region"
|
||||
And I should see "Topic 3" in the "region-main" "region"
|
||||
And I should see "Activity sample 3" in the "region-main" "region"
|
||||
And I click on "#collapssesection1" "css_element"
|
||||
When I reload the page
|
||||
Then I should see "Topic 1" in the "region-main" "region"
|
||||
And I should not see "Activity sample 1" in the "region-main" "region"
|
||||
And I should see "Topic 2" in the "region-main" "region"
|
||||
And I should see "Activity sample 2" in the "region-main" "region"
|
||||
And I should see "Topic 3" in the "region-main" "region"
|
||||
And I should see "Activity sample 3" in the "region-main" "region"
|
||||
And I click on "#collapssesection2" "css_element"
|
||||
And I reload the page
|
||||
And I should see "Topic 1" in the "region-main" "region"
|
||||
And I should not see "Activity sample 1" in the "region-main" "region"
|
||||
And I should see "Topic 2" in the "region-main" "region"
|
||||
And I should not see "Activity sample 2" in the "region-main" "region"
|
||||
And I should see "Topic 3" in the "region-main" "region"
|
||||
And I should see "Activity sample 3" in the "region-main" "region"
|
||||
And I click on "#collapssesection3" "css_element"
|
||||
And I reload the page
|
||||
And I should see "Topic 1" in the "region-main" "region"
|
||||
And I should not see "Activity sample 1" in the "region-main" "region"
|
||||
And I should see "Topic 2" in the "region-main" "region"
|
||||
And I should not see "Activity sample 2" in the "region-main" "region"
|
||||
And I should see "Topic 3" in the "region-main" "region"
|
||||
And I should not see "Activity sample 3" in the "region-main" "region"
|
||||
And I click on "#collapssesection2" "css_element"
|
||||
And I click on "#collapssesection3" "css_element"
|
||||
And I reload the page
|
||||
And I should see "Topic 1" in the "region-main" "region"
|
||||
And I should not see "Activity sample 1" in the "region-main" "region"
|
||||
And I click on "#collapssesection4" "css_element"
|
||||
And I turn editing mode on
|
||||
And I delete section "1"
|
||||
And I press "Delete"
|
||||
And I should not see "Activity sample 1" in the "region-main" "region"
|
||||
And I should see "Activity sample 2" in the "region-main" "region"
|
||||
And I should see "Activity sample 3" in the "region-main" "region"
|
||||
And I should not see "Activity sample 4" in the "region-main" "region"
|
||||
And I should see "Activity sample 5" in the "region-main" "region"
|
@ -1,4 +1,4 @@
|
||||
@core @core_course
|
||||
@core @core_course @core_courseformat
|
||||
Feature: Course index depending on role
|
||||
In order to quickly access the course structure
|
||||
As a user
|
||||
@ -165,3 +165,43 @@ Feature: Course index depending on role
|
||||
And I should see "Activity sample 2" in the "courseindex-content" "region"
|
||||
And I should see "Topic 3" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 3" in the "courseindex-content" "region"
|
||||
|
||||
@javascript
|
||||
Scenario: Course index section preferences
|
||||
Given I am on the "C1" "Course" page logged in as "teacher1"
|
||||
And I click on "Side panel" "button"
|
||||
When I click on "Open course index drawer" "button"
|
||||
Then I should see "Topic 1" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 1" in the "courseindex-content" "region"
|
||||
And I should see "Topic 2" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 2" in the "courseindex-content" "region"
|
||||
And I should see "Topic 3" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 3" in the "courseindex-content" "region"
|
||||
# Collapse section 1.
|
||||
And I click on "Collapse" "link" in the ".courseindex-section[data-number='1']" "css_element"
|
||||
And I reload the page
|
||||
And I should see "Topic 1" in the "courseindex-content" "region"
|
||||
And I should not see "Activity sample 1" in the "courseindex-content" "region"
|
||||
And I should see "Topic 2" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 2" in the "courseindex-content" "region"
|
||||
And I should see "Topic 3" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 3" in the "courseindex-content" "region"
|
||||
# Collapse section 3.
|
||||
And I click on "Collapse" "link" in the ".courseindex-section[data-number='3']" "css_element"
|
||||
And I reload the page
|
||||
And I should see "Topic 1" in the "courseindex-content" "region"
|
||||
And I should not see "Activity sample 1" in the "courseindex-content" "region"
|
||||
And I should see "Topic 2" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 2" in the "courseindex-content" "region"
|
||||
And I should see "Topic 3" in the "courseindex-content" "region"
|
||||
And I should not see "Activity sample 3" in the "courseindex-content" "region"
|
||||
# Delete section 1
|
||||
And I turn editing mode on
|
||||
And I delete section "1"
|
||||
And I click on "Delete" "button"
|
||||
And I reload the page
|
||||
And I should not see "Activity sample 1" in the "courseindex-content" "region"
|
||||
And I should see "Topic 1" in the "courseindex-content" "region"
|
||||
And I should see "Activity sample 2" in the "courseindex-content" "region"
|
||||
And I should see "Topic 2" in the "courseindex-content" "region"
|
||||
And I should not see "Activity sample 3" in the "courseindex-content" "region"
|
||||
|
70
course/format/tests/privacy_test.php
Normal file
70
course/format/tests/privacy_test.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?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/>.
|
||||
|
||||
/**
|
||||
* Privacy tests for core_courseformat.
|
||||
*
|
||||
* @package core_courseformat
|
||||
* @category test
|
||||
* @copyright 2021 Ferran Recio <ferran@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace core_courseformat\privacy;
|
||||
|
||||
use context_course;
|
||||
use core_privacy\local\request\writer;
|
||||
|
||||
class privacy_test extends \core_privacy\tests\provider_testcase {
|
||||
|
||||
/**
|
||||
* Test for provider::test_export_user_preferences().
|
||||
*/
|
||||
public function test_export_user_preferences() {
|
||||
$this->resetAfterTest();
|
||||
|
||||
// Test setup.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
course_create_sections_if_missing($course, [0, 1, 2]);
|
||||
$user = $generator->create_and_enrol($course, 'student');
|
||||
|
||||
$prefix = provider::SECTION_PREFERENCES_PREFIX;
|
||||
$preference = "{$prefix}_{$course->id}";
|
||||
$value = "Something";
|
||||
$preferencestring = get_string("preference:$prefix", 'courseformat', $course->fullname);
|
||||
|
||||
// Add a user home page preference for the User.
|
||||
set_user_preference($preference , $value, $user);
|
||||
|
||||
// Test the user preferences export contains 1 user preference record for the User.
|
||||
provider::export_user_preferences($user->id);
|
||||
$coursecontext = context_course::instance($course->id);
|
||||
$writer = writer::with_context($coursecontext);
|
||||
$this->assertTrue($writer->has_any_data());
|
||||
|
||||
$exportedpreferences = $writer->get_user_preferences('core_courseformat');
|
||||
$this->assertCount(1, (array) $exportedpreferences);
|
||||
$this->assertEquals(
|
||||
$value,
|
||||
$exportedpreferences->$preference->value
|
||||
);
|
||||
$this->assertEquals(
|
||||
$preferencestring,
|
||||
$exportedpreferences->{$preference}->description
|
||||
);
|
||||
}
|
||||
}
|
@ -65,6 +65,8 @@ class core_course_renderer extends plugin_renderer_base {
|
||||
*/
|
||||
public function __construct(moodle_page $page, $target) {
|
||||
$this->strings = new stdClass;
|
||||
$courseid = $page->course->id;
|
||||
user_preference_allow_ajax_update('coursesectionspreferences_' . $courseid, PARAM_RAW);
|
||||
parent::__construct($page, $target);
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@core @core_course
|
||||
@core @core_course @core_courseformat
|
||||
Feature: Collapse course sections
|
||||
In order to quickly access the course structure
|
||||
As a user
|
||||
|
@ -47,6 +47,7 @@ $string['cachedef_contentbank_enabled_extensions'] = 'Allowed extensions and its
|
||||
$string['cachedef_contentbank_context_extensions'] = 'Allowed extensions and its supporter plugins in a content bank context';
|
||||
$string['cachedef_coursecat'] = 'Course categories lists for particular user';
|
||||
$string['cachedef_coursecatrecords'] = 'Course categories records';
|
||||
$string['cachedef_coursesectionspreferences'] = 'Course section preferences';
|
||||
$string['cachedef_coursecattree'] = 'Course categories tree';
|
||||
$string['cachedef_coursecompletion'] = 'Course completion status';
|
||||
$string['cachedef_coursecontacts'] = 'List of course contacts';
|
||||
|
@ -23,4 +23,5 @@
|
||||
*/
|
||||
|
||||
$string['courseindex'] = 'Course index';
|
||||
$string['privacy:metadata'] = 'The course format subsystem does not store any personal data.';
|
||||
$string['preference:coursesectionspreferences'] = 'Section user preferences for course {$a}';
|
||||
$string['privacy:metadata:preference:coursesectionspreferences'] = 'Section user preferences like collapsed and expanded.';
|
||||
|
@ -197,6 +197,13 @@ $definitions = array(
|
||||
'changesincoursecat',
|
||||
),
|
||||
),
|
||||
// Used to store state of sections in course (collapsed or not).
|
||||
'coursesectionspreferences' => [
|
||||
'mode' => cache_store::MODE_REQUEST,
|
||||
'simplekeys' => true,
|
||||
'simpledata' => false,
|
||||
'staticacceleration' => true,
|
||||
],
|
||||
// Cache course contacts for the courses.
|
||||
'coursecontacts' => array(
|
||||
'mode' => cache_store::MODE_APPLICATION,
|
||||
|
@ -5247,6 +5247,10 @@ function remove_course_contents($courseid, $showfeedback = true, array $options
|
||||
// Delete course tags.
|
||||
core_tag_tag::remove_all_item_tags('core', 'course', $course->id);
|
||||
|
||||
// Give the course format the opportunity to remove its obscure data.
|
||||
$format = course_get_format($course);
|
||||
$format->delete_format_data();
|
||||
|
||||
// Notify the competency subsystem.
|
||||
\core_competency\api::hook_course_deleted($course);
|
||||
|
||||
|
@ -29,7 +29,7 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$version = 2021101900.00; // YYYYMMDD = weekly release date of this DEV branch.
|
||||
$version = 2021101900.01; // YYYYMMDD = weekly release date of this DEV branch.
|
||||
// RR = release increments - 00 in DEV branches.
|
||||
// .XX = incremental changes.
|
||||
$release = '4.0dev+ (Build: 20211019)'; // Human-friendly version name
|
||||
|
Loading…
x
Reference in New Issue
Block a user