Rev 1 | Ir a la última revisión | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
// 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';import jQuery from 'jquery';import ContentTree from 'core_courseformat/local/courseeditor/contenttree';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_CMLIST: `[data-for='cmlist']`,CM: `[data-for='cm']`,TOGGLER: `[data-action="togglecourseindexsection"]`,COLLAPSE: `[data-toggle="collapse"]`,DRAWER: `.drawer`,};// Default classes to toggle on refresh.this.classes = {SECTIONHIDDEN: 'dimmed',CMHIDDEN: 'dimmed',SECTIONCURRENT: 'current',COLLAPSED: `collapsed`,SHOW: `show`,};// 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 this({element: document.getElementById(target),reactive: getCurrentCourseEditor(),selectors,});}/*** Initial state ready method.** @param {Object} state the state data*/stateReady(state) {// Activate section togglers.this.addEventListener(this.element, 'click', this._sectionTogglers);// 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;});this._expandPageCmSectionIfNecessary(state);this._refreshPageItem({element: state.course, state});// Configure Aria Tree.this.contentTree = new ContentTree(this.element, this.selectors, this.reactive.isEditing);}getWatchers() {return [{watch: `section.indexcollapsed:updated`, handler: this._refreshSectionCollapsed},{watch: `cm:created`, handler: this._createCm},{watch: `cm:deleted`, handler: this._deleteCm},{watch: `section:created`, handler: this._createSection},{watch: `section:deleted`, handler: this._deleteSection},{watch: `course.pageItem:created`, handler: this._refreshPageItem},{watch: `course.pageItem:updated`, handler: this._refreshPageItem},// 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*/_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;// Update the state.const sectionId = section.getAttribute('data-id');if (!sectionlink || isCollapsed) {this.reactive.dispatch('sectionIndexCollapsed',[sectionId],!isCollapsed);}}}/*** Update section collapsed.** @param {object} args* @param {object} args.element The leement to be expanded*/_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.indexcollapsed !== isCollapsed) {this._expandSectionNode(element);}}/*** Expand a section node.** By default the method will use element.indexcollapsed to decide if the* section is opened or closed. However, using forceValue it is possible* to open or close a section independant from the indexcollapsed 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.indexcollapsed) ? false : true;}// 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 (when MDL-71979 is integrated).const togglerValue = (forceValue) ? 'show' : 'hide';jQuery(collapsible).collapse(togglerValue);}/*** Handle a page item update.** @param {Object} details the update details* @param {Object} details.state the state data.* @param {Object} details.element the course state data.*/_refreshPageItem({element, state}) {if (!element?.pageItem?.isStatic || element.pageItem.type != 'cm') {return;}// Check if we need to uncollapse the section and scroll to the element.const section = state.section.get(element.pageItem.sectionId);if (section.indexcollapsed) {this._expandSectionNode(section, true);setTimeout(() => this.cms[element.pageItem.id]?.scrollIntoView({block: "nearest"}),250);}}/*** Expand a section if the current page is a section's cm.** @private* @param {Object} state the course state.*/_expandPageCmSectionIfNecessary(state) {const pageCmInfo = this.reactive.getPageAnchorCmInfo();if (!pageCmInfo) {return;}this._expandSectionNode(state.section.get(pageCmInfo.sectionid), true);}/*** Create a newcm instance.** @param {object} param* @param {Object} param.state* @param {Object} param.element*/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 = ' ';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);}/*** Create a new section instance.** @param {Object} details the update details.* @param {Object} details.state the state data.* @param {Object} details.element the element data.*/async _createSection({state, element}) {// Create a fake node while the component is loading.const fakeelement = document.createElement('div');fakeelement.classList.add('bg-pulse-grey', 'w-100');fakeelement.innerHTML = ' ';this.sections[element.id] = fakeelement;// Place the fake node on the correct position.this._refreshCourseSectionlist({state,element: state.course,});// Collect render data.const exporter = this.reactive.getExporter();const data = exporter.section(state, element);// Create the new content.const newcomponent = await this.renderComponent(fakeelement, 'core_courseformat/local/courseindex/section', data);// Replace the fake node with the real content.const newelement = newcomponent.getElement();this.sections[element.id] = newelement;fakeelement.parentNode.replaceChild(newelement, fakeelement);}/*** Refresh a section cm list.** @param {object} param* @param {Object} param.element*/_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} param* @param {Object} param.state*/_refreshCourseSectionlist({state}) {const sectionlist = this.reactive.getExporter().listedSectionIds(state);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 && 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} param* @param {Object} param.element*/_deleteCm({element}) {delete this.cms[element.id];}/*** Remove a section from the list.** The actual DOM element removal is delegated to the section component.** @param {Object} details the update details.* @param {Object} details.element the element data.*/_deleteSection({element}) {delete this.sections[element.id];}}