Commit 0350d426 authored by ilya's avatar ilya Committed by Ferran Recio Calderó
Browse files

MDL-71211 core_course: Keep the status of course index.

parent 8885e22a
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -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.