Commit 830c3eb9 authored by Ferran Recio Calderó's avatar Ferran Recio Calderó Committed by Amaia
Browse files

MDL-71209 courseformat: add course index modules

The course index is the first UI component that implements the new
drawers and the reactive components. The course index uses the course
state to present the current course structure and changes whenever
that structure change.
parent 804e138c
// 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/>.
/**
* Course index cm component.
*
* This component is used to control specific course modules interactions like drag and drop.
*
* @module core_courseformat/local/courseindex/cm
* @class core_courseformat/local/courseindex/cm
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'courseindex_cm';
// Default query selectors.
this.selectors = {
};
// We need our id to watch specific events.
this.id = this.element.dataset.id;
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new Component({
element: document.getElementById(target),
selectors,
});
}
/**
* Initial state ready method.
*/
stateReady() {
// Activate drag and drop soon.
}
getWatchers() {
return [
{watch: `cm[${this.id}]:deleted`, handler: this.remove},
];
}
}
// 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/>.
/**
* Course index main component.
*
* @module core_courseformat/local/courseindex/courseindex
* @class core_courseformat/local/courseindex/courseindex
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'courseindex';
// Default query selectors.
this.selectors = {
SECTION: `[data-for='section']`,
SECTION_ITEM: `[data-for='section_item']`,
SECTION_TITLE: `[data-for='section_title']`,
SECTION_CMLIST: `[data-for='cmlist']`,
CM: `[data-for='cm']`,
CM_NAME: `[data-for='cm_name']`,
TOGGLER: `[data-action="togglecourseindexsection"]`,
COLLAPSE: `[data-toggle="collapse"]`,
};
// Default classes to toggle on refresh.
this.classes = {
SECTIONHIDDEN: 'dimmed',
CMHIDDEN: 'dimmed',
SECTIONCURRENT: 'current',
};
// Arrays to keep cms and sections elements.
this.sections = {};
this.cms = {};
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new Component({
element: document.getElementById(target),
reactive: getCurrentCourseEditor(),
selectors,
});
}
/**
* Initial state ready method.
*/
stateReady() {
// Activate section togglers.
this.addEventListener(this.element, 'click', this._setupSectionTogglers);
// Get cms and sections elements.
const sections = this.getElements(this.selectors.SECTION);
sections.forEach((section) => {
this.sections[section.dataset.id] = section;
});
const cms = this.getElements(this.selectors.CM);
cms.forEach((cm) => {
this.cms[cm.dataset.id] = cm;
});
}
getWatchers() {
return [
{watch: `section:updated`, handler: this._refreshSection},
{watch: `cm:updated`, handler: this._refreshCm},
{watch: `cm:created`, handler: this._createCm},
{watch: `cm:deleted`, handler: this._deleteCm},
// Sections and cm sorting.
{watch: `course.sectionlist:updated`, handler: this._refreshCourseSectionlist},
{watch: `section.cmlist:updated`, handler: this._refreshSectionCmlist},
];
}
/**
* Setup sections toggler.
*
* Toggler click is delegated to the main course index element because new sections can
* appear at any moment and this way we prevent accidental double bindings.
*
* @param {Event} event the triggered event
*/
_setupSectionTogglers(event) {
const sectionlink = event.target.closest(this.selectors.TOGGLER);
if (sectionlink) {
event.preventDefault();
sectionlink.parentNode.querySelector(this.selectors.COLLAPSE).click();
}
}
/**
* Update a course index section using the state information.
*
* @param {Object} details the update details.
*/
_refreshSection({element}) {
// Find the element.
const target = this.sections[element.id];
if (!target) {
throw new Error(`Unkown section with ID ${element.id}`);
}
// Update classes.
const sectionitem = target.querySelector(this.selectors.SECTION_ITEM);
sectionitem.classList.toggle(this.classes.SECTIONHIDDEN, !element.visible);
target.classList.toggle(this.classes.SECTIONCURRENT, element.current);
// Update title.
target.querySelector(this.selectors.SECTION_TITLE).innerHTML = element.title;
}
/**
* Update a course index cm using the state information.
*
* @param {Object} details the update details.
*/
_refreshCm({element}) {
// Find the element.
const target = this.cms[element.id];
if (!target) {
throw new Error(`Unkown course module with ID ${element.id}`);
}
// Update classes.
target.classList.toggle(this.classes.CMHIDDEN, !element.visible);
target.querySelector(this.selectors.CM_NAME).innerHTML = element.name;
}
/**
* Create a newcm instance.
*
* @param {Object} details the update details.
*/
async _createCm({state, element}) {
// Create a fake node while the component is loading.
const fakeelement = document.createElement('li');
fakeelement.classList.add('bg-pulse-grey', 'w-100');
fakeelement.innerHTML = '&nbsp;';
this.cms[element.id] = fakeelement;
// Place the fake node on the correct position.
this._refreshSectionCmlist({
state,
element: state.section.get(element.sectionid),
});
// Collect render data.
const exporter = this.reactive.getExporter();
const data = exporter.cm(state, element);
// Create the new content.
const newcomponent = await this.renderComponent(fakeelement, 'core_courseformat/local/courseindex/cm', data);
// Replace the fake node with the real content.
const newelement = newcomponent.getElement();
this.cms[element.id] = newelement;
fakeelement.parentNode.replaceChild(newelement, fakeelement);
}
/**
* Refresh a section cm list.
*
* @param {Object} details the update details.
*/
_refreshSectionCmlist({element}) {
const cmlist = element.cmlist ?? [];
const listparent = this.getElement(this.selectors.SECTION_CMLIST, element.id);
this._fixOrder(listparent, cmlist, this.cms);
}
/**
* Refresh the section list.
*
* @param {Object} details the update details.
*/
_refreshCourseSectionlist({element}) {
const sectionlist = element.sectionlist ?? [];
this._fixOrder(this.element, sectionlist, this.sections);
}
/**
* Fix/reorder the section or cms order.
*
* @param {Element} container the HTML element to reorder.
* @param {Array} neworder an array with the ids order
* @param {Array} allitems the list of html elements that can be placed in the container
*/
_fixOrder(container, neworder, allitems) {
// Empty lists should not be visible.
if (!neworder.length) {
container.classList.add('hidden');
container.innerHTML = '';
return;
}
// Grant the list is visible (in case it was empty).
container.classList.remove('hidden');
// Move the elements in order at the beginning of the list.
neworder.forEach((itemid, index) => {
const item = allitems[itemid];
// Get the current element at that position.
const currentitem = container.children[index];
if (currentitem === undefined) {
container.append(item);
return;
}
if (currentitem !== item) {
container.insertBefore(item, currentitem);
}
});
// Remove the remaining elements.
while (container.children.length > neworder.length) {
container.removeChild(container.lastChild);
}
}
/**
* Remove a cm from the list.
*
* The actual DOM element removal is delegated to the cm component.
*
* @param {Object} details the update details.
*/
_deleteCm({element}) {
delete this.cms[element.id];
}
}
// 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/>.
/**
* Course index drawer wrap.
*
* This component is mostly used to ensure all subcomponents find a parent
* compoment with a reactive instance defined.
*
* @module core_courseformat/local/courseindex/drawer
* @class core_courseformat/local/courseindex/drawer
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
export default class Component extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'courseindex-drawer';
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new Component({
element: document.getElementById(target),
reactive: getCurrentCourseEditor(),
selectors,
});
}
}
// 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/>.
/**
* Course index placeholder replacer.
*
* @module core_courseformat/local/courseindex/placeholder
* @class core_courseformat/local/courseindex/placeholder
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import Templates from 'core/templates';
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
export default class Component extends BaseComponent {
/**
* Static method to create a component instance form the mustache template.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
* @return {Component}
*/
static init(target, selectors) {
return new Component({
element: document.getElementById(target),
reactive: getCurrentCourseEditor(),
selectors,
});
}
/**
* Initial state ready method.
*
* This stateReady to be async because it loads the real courseindex.
*
* @param {object} state the initial state
*/
async stateReady(state) {
// Collect section information from the state.
const exporter = this.reactive.getExporter();
const data = exporter.course(state);
try {
// To render an HTML into our component we just use the regular Templates module.
const {html, js} = await Templates.renderForPromise(
'core_courseformat/local/courseindex/courseindex',
data,
);
Templates.replaceNode(this.element, html, js);
} catch (error) {
throw error;
}
}
}
......@@ -371,6 +371,18 @@ abstract class base {
return false;
}
/**
* Returns true if this course format uses course index
*
* This function may be called without specifying the course id
* i.e. in course_index_drawer()
*
* @return bool
*/
public function uses_course_index() {
return false;
}
/**
* Returns a list of sections used in the course
*
......
......@@ -74,8 +74,21 @@ class cm implements renderable {
'id' => $cm->id,
'name' => $cm->name,
'visible' => !empty($cm->visible),
'sectionid' => $section->id,
'sectionnumber' => $section->section,
'uservisible' => $cm->uservisible,
];
// Check the user access type to this cm.
$conditionalhidden = $output->is_cm_conditionally_hidden($cm);
$data->accessvisible = ($data->visible && !$conditionalhidden);
// Add url if the activity is compatible.
$url = $cm->url;
if ($url) {
$data->url = $url->out();
}
if ($this->exportcontent) {
$data->content = $output->course_section_updated_cm_item($format, $section, $cm);
}
......
......@@ -55,15 +55,19 @@ class section implements renderable {
*/
public function export_for_template(\renderer_base $output): stdClass {
$format = $this->format;
$course = $format->get_course();
$section = $this->section;
$modinfo = $format->get_modinfo();
$data = (object)[
'id' => $section->id,
'section' => $section->section,
'number' => $section->section,
'title' => $format->get_section_name($section),
'cmlist' => [],
'visible' => !empty($section->visible),
'sectionurl' => course_get_url($course, $section->section)->out(),
'current' => $format->is_section_current($section),
];
if (empty($modinfo->sections[$section->section])) {
......
......@@ -179,6 +179,25 @@ abstract class section_renderer extends core_course_renderer {
return $this->render($cmitem);
}
/**
* Get the course index drawer with placeholder.
*
* The default course index is loaded after the page is ready. Format plugins can override
* this method to provide an alternative course index.
*
* If the format is not compatible with the course index, this method will return an empty string.
*
* @param course_format $format the course format
* @return String the course index HTML.
*/
public function course_index_drawer(course_format $format): ?String {
if ($format->uses_course_index()) {
include_course_editor($format);
return $this->render_from_template('core_courseformat/local/courseindex/drawer', []);
}
return '';
}
/**
* Generate the edit control action menu
*
......
......@@ -78,14 +78,14 @@ class stateactions {
foreach (array_keys($cmids) as $cmid) {
// Add this action to updates array.
$updates->add_cm_update($cmid);
$updates->add_cm_put($cmid);
$cm = $modinfo->get_cm($cmid);
$sectionids[$cm->section] = true;
}
foreach (array_keys($sectionids) as $sectionid) {
$updates->add_section_update($sectionid);
$updates->add_section_put($sectionid);
}
}
......@@ -129,7 +129,7 @@ class stateactions {
foreach (array_keys($sectionids) as $sectionid) {
$sectioninfo = $modinfo->get_section_info_by_id($sectionid);
$updates->add_section_update($sectionid);
$updates->add_section_put($sectionid);
// Add cms.
if (empty($modinfo->sections[$sectioninfo->section])) {
continue;
......@@ -145,7 +145,7 @@ class stateactions {
foreach (array_keys($cmids) as $cmid) {
// Add this action to updates array.
$updates->add_cm_update($cmid);
$updates->add_cm_put($cmid);
}
}
......@@ -171,7 +171,7 @@ class stateactions {
$modinfo = course_modinfo::instance($course);
$updates->add_course_update();
$updates->add_course_put();
// Add sections updates.
$sections = $modinfo->get_section_info_all();
......
......@@ -73,19 +73,19 @@ class stateupdates implements JsonSerializable {
/**
* Add track about a general course state change.
*/
public function add_course_update(): void {
public function add_course_put(): void {
$courseclass = $this->format->get_output_classname('state\\course');
$currentstate = new $courseclass($this->format);
$this->add_update('course', 'update', $currentstate->export_for_template($this->output));
$this->add_update('course', 'put', $currentstate->export_for_template($this->output));
}
/**
* Add track about a section state update.
* Add track about a section state put.
*
* @param int $sectionid The affected section id.
*/
public function add_section_update(int $sectionid): void {
$this->create_or_update_section($sectionid, 'update');