MDL-71134 core: add new reactive modules

The new course creation for Moodle 4.0 requires to add
some leavel of reactivity to the frontend. Instead of
building a specific solution only for the course editor,
in this commit there's a generic solution that can be
used in other places in Moodle to implement single
state reactive components.
This commit is contained in:
Ferran Recio 2021-04-01 15:44:39 +02:00
parent 5f91cbb611
commit b85cba317e
12 changed files with 1553 additions and 0 deletions

View File

@ -0,0 +1,2 @@
define ("core/local/reactive/basecomponent",["exports","core/templates"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);function c(a,b){return h(a)||g(a,b)||e(a,b)||d()}function d(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function e(a,b){if(!a)return;if("string"==typeof a)return f(a,b);var c=Object.prototype.toString.call(a).slice(8,-1);if("Object"===c&&a.constructor)c=a.constructor.name;if("Map"===c||"Set"===c)return Array.from(c);if("Arguments"===c||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(c))return f(a,b)}function f(a,b){if(null==b||b>a.length)b=a.length;for(var c=0,d=Array(b);c<b;c++){d[c]=a[c]}return d}function g(a,b){if("undefined"==typeof Symbol||!(Symbol.iterator in Object(a)))return;var c=[],d=!0,e=!1,f=void 0;try{for(var g=a[Symbol.iterator](),h;!(d=(h=g.next()).done);d=!0){c.push(h.value);if(b&&c.length===b)break}}catch(a){e=!0;f=a}finally{try{if(!d&&null!=g["return"])g["return"]()}finally{if(e)throw f}}return c}function h(a){if(Array.isArray(a))return a}function i(a,b){if(!(a instanceof b)){throw new TypeError("Cannot call a class as a function")}}function j(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 k(a,b,c){if(b)j(a.prototype,b);if(c)j(a,c);return a}var l=function(){function a(b){i(this,a);if(b.element===void 0||!(b.element instanceof HTMLElement)){throw Error("Reactive components needs a main DOM element to dispatch events")}if(b.reactive===void 0){throw Error("Reactive components needs a reactive module to work with")}this.reactive=b.reactive;this.element=b.element;this.eventHandlers=new Map([]);this.eventListeners=[];this.selectors={};this.events=this.constructor.getEvents();this.create(b);if(b.selectors!==void 0){this.addSelectors(b.selectors)}this.reactive.registerComponent(this)}k(a,[{key:"create",value:function create(){}},{key:"destroy",value:function destroy(){}},{key:"getWatchers",value:function getWatchers(){return[]}},{key:"stateReady",value:function stateReady(){}},{key:"getElement",value:function getElement(a,b){if(a===void 0&&b===void 0){return this.element}var c=b?"[data-id='".concat(b,"']"):"",d="".concat(null!==a&&void 0!==a?a:"").concat(c);return this.element.querySelector(d)}},{key:"getElements",value:function getElements(a,b){var c=b?"[data-id='".concat(b,"']"):"",d="".concat(null!==a&&void 0!==a?a:"").concat(c);return this.element.querySelectorAll(d)}},{key:"addSelectors",value:function addSelectors(a){for(var b=0,d=Object.entries(a);b<d.length;b++){var e=c(d[b],2),f=e[0],g=e[1];this.selectors[f]=g}}},{key:"getSelector",value:function getSelector(a){return this.selectors[a]}},{key:"dispatchEvent",value:function dispatchEvent(a,b){this.element.dispatchEvent(new CustomEvent(a,{bubbles:!0,detail:b}))}},{key:"renderComponent",value:function renderComponent(a,c,d){return new Promise(function(e,f){a.addEventListener("ComponentRegistration:Success",function(a){var b=a.detail;e(b.component)});a.addEventListener("ComponentRegistration:Fail",function(){f("Registration of ".concat(c," fails."))});b.default.renderForPromise(c,d).then(function(c){var d=c.html,e=c.js;b.default.replaceNodeContents(a,d,e);return!0}).catch(function(a){f("Rendering of ".concat(c," throws an error."));throw a})})}},{key:"addEventListener",value:function addEventListener(a,b,c){var d=this.eventHandlers.get(c);if(d===void 0){d=c.bind(this);this.eventHandlers.set(c,d)}a.addEventListener(b,d);this.eventListeners.push({target:a,type:b,bindListener:d})}},{key:"removeEventListener",value:function removeEventListener(a,b,c){var d=this.eventHandlers.get(c);if(d===void 0){return}a.removeEventListener(b,d)}},{key:"removeAllEventListeners",value:function removeAllEventListeners(){this.eventListeners.forEach(function(a){var b=a.target,c=a.type,d=a.bindListener;b.removeEventListener(c,d)});this.eventListeners=[]}},{key:"remove",value:function remove(){this.unregister();this.element.remove()}},{key:"unregister",value:function unregister(){this.reactive.unregisterComponent(this);this.removeAllEventListeners();this.destroy()}},{key:"dispatchRegistrationSuccess",value:function dispatchRegistrationSuccess(){if(this.element.parentNode===void 0){return}this.element.parentNode.dispatchEvent(new CustomEvent("ComponentRegistration:Success",{bubbles:!1,detail:{component:this}}))}},{key:"dispatchRegistrationFail",value:function dispatchRegistrationFail(){if(this.element.parentNode===void 0){return}this.element.parentNode.dispatchEvent(new CustomEvent("ComponentRegistration:Fail",{bubbles:!1,detail:{component:this}}))}}],[{key:"getEvents",value:function getEvents(){return{}}}]);return a}();a.default=l;return a.default});
//# sourceMappingURL=basecomponent.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

2
lib/amd/build/reactive.min.js vendored Normal file
View File

@ -0,0 +1,2 @@
define ("core/reactive",["exports","core/local/reactive/basecomponent","core/local/reactive/reactive"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});Object.defineProperty(a,"BaseComponent",{enumerable:!0,get:function get(){return b.default}});Object.defineProperty(a,"Reactive",{enumerable:!0,get:function get(){return c.default}});b=d(b);c=d(c);function d(a){return a&&a.__esModule?a:{default:a}}});
//# sourceMappingURL=reactive.min.js.map

View File

@ -0,0 +1 @@
{"version":3,"sources":["../src/reactive.js"],"names":[],"mappings":"4WAuBA,OACA,O","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Generic reactive module used in the course editor.\n *\n * @module core/reactive\n * @copyright 2021 Ferran Recio <ferran@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport BaseComponent from 'core/local/reactive/basecomponent';\nimport Reactive from 'core/local/reactive/reactive';\n\nexport {Reactive, BaseComponent};\n"],"file":"reactive.min.js"}

View File

@ -0,0 +1,406 @@
// 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 Templates from 'core/templates';
/**
* Reactive UI component base class.
*
* Each UI reactive component should extend this class to interact with a reactive state.
*
* @module core/local/reactive/basecomponent
* @class core/local/reactive/basecomponent
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class {
/**
* The component descriptor data structure.
*
* This structure is used by any component and init method to define the way the component will interact
* with the interface and whith reactive instance operates. The logic behind this object is to avoid
* unnecessary dependancies between the final interface and the state logic.
*
* Any component interacts with a single main DOM element (description.element) but it can use internal
* selector to select elements within this main element (descriptor.selectors). By default each component
* will provide it's own default selectors, but those can be overridden by the "descriptor.selectors"
* property in case the mustache wants to reuse the same component logic but with a different interface.
*
* @typedef {object} descriptor
* @property {Reactive} reactive mandatory reactive module to register in
* @property {DOMElement} element all components needs an element to anchor events
* @property {object} [selectors] an optional object to override query selectors
*/
/**
* The class constructor.
*
* The only param this method gets is a constructor with all the mandatory
* and optional component data. Component will receive the same descriptor
* as create method param.
*
* This method will call the "create" method before registering the component into
* the reactive module. This way any component can add default selectors and events.
*
* @param {descriptor} descriptor data to create the object.
*/
constructor(descriptor) {
if (descriptor.element === undefined || !(descriptor.element instanceof HTMLElement)) {
throw Error(`Reactive components needs a main DOM element to dispatch events`);
}
if (descriptor.reactive === undefined) {
throw Error(`Reactive components needs a reactive module to work with`);
}
this.reactive = descriptor.reactive;
this.element = descriptor.element;
// Variable to track event listeners.
this.eventHandlers = new Map([]);
this.eventListeners = [];
// Empty default component selectors.
this.selectors = {};
// Empty default event list from the static method.
this.events = this.constructor.getEvents();
// Call create function to get the component defaults.
this.create(descriptor);
// Overwrite the components selectors if necessary.
if (descriptor.selectors !== undefined) {
this.addSelectors(descriptor.selectors);
}
// Register the component.
this.reactive.registerComponent(this);
}
/**
* Return the component custom event names.
*
* Components may override this method to provide their own events.
*
* Component custom events is an important part of component reusability. This function
* is static because is part of the component definition and should be accessible from
* outsite the instances. However, values will be available at instance level in the
* this.events object.
*
* @returns {Object} the component events.
*/
static getEvents() {
return {};
}
/**
* Component create function.
*
* Default init method will call "create" when all internal attributes are set
* but before the component is not yet registered in the reactive module.
*
* In this method any component can define its own defaults such as:
* - this.selectors {object} the default query selectors of this component.
* - this.events {object} a list of event names this component dispatch
* - extract any data from the main dom element (this.element)
* - set any other data the component uses
*
* @param {descriptor} descriptor the component descriptor
*/
// eslint-disable-next-line no-unused-vars
create(descriptor) {
// Components may override this method to initialize selects, events or other data.
}
/**
* Component destroy hook.
*
* BaseComponent call this method when a component is unregistered or removed.
*
* Components may override this method to clean the HTML or do some action when the
* component is unregistered or removed.
*/
destroy() {
// Components can override this method.
}
/**
* Return the list of watchers that component has.
*
* Each watcher is represented by an object with two attributes:
* - watch (string) the specific state event to watch. Example 'section.visible:updated'
* - handler (function) the function to call when the watching state change happens
*
* Any component shoudl override this method to define their state watchers.
*
* @returns {array} array of watchers.
*/
getWatchers() {
return [];
}
/**
* Reactive module will call this method when the state is ready.
*
* Component can override this method to update/load the component HTML or to bind
* listeners to HTML entities.
*/
stateReady() {
// Components can override this method.
}
/**
* Get the main DOM element of this component or a subelement.
*
* @param {string|undefined} query optional subelement query
* @param {string|undefined} dataId optional data-id value
* @returns {element|undefined} the DOM element (if any)
*/
getElement(query, dataId) {
if (query === undefined && dataId === undefined) {
return this.element;
}
const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';
const selector = `${query ?? ''}${dataSelector}`;
return this.element.querySelector(selector);
}
/**
* Get the all subelement that match a query selector.
*
* @param {string|undefined} query optional subelement query
* @param {string|undefined} dataId optional data-id value
* @returns {NodeList} the DOM elements
*/
getElements(query, dataId) {
const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';
const selector = `${query ?? ''}${dataSelector}`;
return this.element.querySelectorAll(selector);
}
/**
* Add or update the component selectors.
*
* @param {Object} newSelectors an object of new selectors.
*/
addSelectors(newSelectors) {
for (const [selectorName, selector] of Object.entries(newSelectors)) {
this.selectors[selectorName] = selector;
}
}
/**
* Return a component selector.
*
* @param {string} selectorName the selector name
* @return {string|undefined} the query selector
*/
getSelector(selectorName) {
return this.selectors[selectorName];
}
/**
* Dispatch a custom event on this.element.
*
* This is just a convenient method to dispatch custom events from within a component.
* Components are free to use an alternative function to dispatch custom
* events. The only restriction is that it should be dispatched on this.element
* and specify "bubbles:true" to alert any component listeners.
*
* @param {string} eventName the event name
* @param {*} detail event detail data
*/
dispatchEvent(eventName, detail) {
this.element.dispatchEvent(new CustomEvent(eventName, {
bubbles: true,
detail: detail,
}));
}
/**
* Render a new Component using a mustache file.
*
* It is important to note that this method should NOT be used for loading regular mustache files
* as it returns a Promise that will only be resolved if the mustache registers a component instance.
*
* @param {element} target the DOM element that contains the component
* @param {string} file the component mustache file to render
* @param {*} data the mustache data
* @return {Promise} a promise of the resulting component instance
*/
renderComponent(target, file, data) {
return new Promise((resolve, reject) => {
target.addEventListener('ComponentRegistration:Success', ({detail}) => {
resolve(detail.component);
});
target.addEventListener('ComponentRegistration:Fail', () => {
reject(`Registration of ${file} fails.`);
});
Templates.renderForPromise(
file,
data
).then(({html, js}) => {
Templates.replaceNodeContents(target, html, js);
return true;
}).catch(error => {
reject(`Rendering of ${file} throws an error.`);
throw error;
});
});
}
/**
* Add and bind an event listener to a target and keep track of all event listeners.
*
* The native element.addEventListener method is not object oriented friently as the
* "this" represents the element that triggers the event and not the listener class.
* As components can be unregister and removed at any time, the BaseComponent provides
* this method to keep track of all component listeners and do all of the bind stuff.
*
* @param {Element} target the event target
* @param {string} type the event name
* @param {function} listener the class method that recieve the event
*/
addEventListener(target, type, listener) {
// Check if we have the bind version of that listener.
let bindListener = this.eventHandlers.get(listener);
if (bindListener === undefined) {
bindListener = listener.bind(this);
this.eventHandlers.set(listener, bindListener);
}
target.addEventListener(type, bindListener);
// Keep track of all component event listeners in case we need to remove them.
this.eventListeners.push({
target,
type,
bindListener,
});
}
/**
* Remove an event listener from a component.
*
* This method allows components to remove listeners without keeping track of the
* listeners bind versions of the method. Both addEventListener and removeEventListener
* keeps internally the relation between the original class method and the bind one.
*
* @param {Element} target the event target
* @param {string} type the event name
* @param {function} listener the class method that recieve the event
*/
removeEventListener(target, type, listener) {
// Check if we have the bind version of that listener.
let bindListener = this.eventHandlers.get(listener);
if (bindListener === undefined) {
// This listener has not been added.
return;
}
target.removeEventListener(type, bindListener);
}
/**
* Remove all event listeners from this component.
*
* This method is called also when the component is unregistered or removed.
*
* Note that only listeners registered with the addEventListener method
* will be removed. Other manual listeners will keep active.
*/
removeAllEventListeners() {
this.eventListeners.forEach(({target, type, bindListener}) => {
target.removeEventListener(type, bindListener);
});
this.eventListeners = [];
}
/**
* Remove a previously rendered component instance.
*
* This method will remove the component HTML and unregister it from the
* reactive module.
*/
remove() {
this.unregister();
this.element.remove();
}
/**
* Unregister the component from the reactive module.
*
* This method will disable the component logic, event listeners and watchers
* but it won't remove any HTML created by the component. However, it will trigger
* the destroy hook to allow the component to clean parts of the interface.
*/
unregister() {
this.reactive.unregisterComponent(this);
this.removeAllEventListeners();
this.destroy();
}
/**
* Dispatch a component registration event to inform the parent node.
*
* The registration event is different from the rest of the component events because
* is the only way in which components can communicate its existence to a possible parent.
* Most components will be created by including a mustache file, child components
* must emit a registration event to the parent DOM element to alert about the registration.
*/
dispatchRegistrationSuccess() {
// The registration event does not bubble because we just want to comunicate with the parentNode.
// Otherwise, any component can get multiple registrations events and could not differentiate
// between child components and grand child components.
if (this.element.parentNode === undefined) {
return;
}
// This custom element is captured by renderComponent method.
this.element.parentNode.dispatchEvent(new CustomEvent(
'ComponentRegistration:Success',
{
bubbles: false,
detail: {component: this},
}
));
}
/**
* Dispatch a component registration fail event to inform the parent node.
*
* As dispatchRegistrationSuccess, this method will communicate the registration fail to the
* parent node to inform the possible parent component.
*/
dispatchRegistrationFail() {
if (this.element.parentNode === undefined) {
return;
}
// This custom element is captured only by renderComponent method.
this.element.parentNode.dispatchEvent(new CustomEvent(
'ComponentRegistration:Fail',
{
bubbles: false,
detail: {component: this},
}
));
}
}

View File

@ -0,0 +1,340 @@
// 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/>.
/**
* A generic single state reactive module.
*
* @module core/reactive/local/reactive/reactive
* @class core/reactive/local/reactive/reactive
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import log from 'core/log';
import StateManager from 'core/local/reactive/statemanager';
/**
* Set up general reactive class to create a single state application with components.
*
* The reactive class is used for registering new UI components and manage the access to the state values
* and mutations.
*
* When a new reactive instance is created, it will contain an empty state and and empty mutations
* lists. When the state data is ready, the initial state can be loaded using the "setInitialState"
* method. This will protect the state from writing and will trigger all the components "stateReady"
* methods.
*
* State can only be altered by mutations. To replace all the mutations with a specific class,
* use "setMutations" method. If you need to just add some new mutation methods, use "addMutations".
*
* To register new components into a reactive instance, use "registerComponent".
*
* Inside a component, use "dispatch" to invoke a mutation on the state (components can only access
* the state in read only mode).
*/
export default class {
/**
* The component descriptor data structure.
*
* @typedef {object} description
* @property {string} eventName the custom event name used for state changed events
* @property {Function} eventDispatch the state update event dispatch function
* @property {Element} [target] the target of the event dispatch. If not passed a fake element will be created
* @property {Object} [mutations] an object with state mutations functions
* @property {Object} [state] an object to initialize the state.
*/
/**
* Create a basic reactive manager.
*
* Note that if your state is not async loaded, you can pass directly on creation by using the
* description.state attribute. However, this will initialize the state, this means
* setInitialState will throw an exception because the state is already defined.
*
* @param {description} description reactive manager description.
*/
constructor(description) {
if (description.eventName === undefined || description.eventDispatch === undefined) {
throw new Error(`Reactivity event required`);
}
// Each reactive instance has its own element anchor to propagate state changes internally.
// By default the module will create a fake DOM element to target custom events but
// if all reactive components is constrait to a single element, this can be passed as
// target in the description.
this.target = description.target ?? document.createTextNode(null);
this.eventName = description.eventName;
this.eventDispatch = description.eventDispatch;
// State manager is responsible for dispatch state change events when a mutation happens.
this.stateManager = new StateManager(this.eventDispatch, this.target);
// An internal registry of watchers and components.
this.watchers = new Map([]);
this.components = new Set([]);
// Mutations can be overridden later using setMutations method.
this.mutations = description.mutations ?? {};
// Register the event to alert watchers when specific state change happens.
this.target.addEventListener(this.eventName, this.callWatchersHandler.bind(this));
// Set initial state if we already have it.
if (description.state !== undefined) {
this.setInitialState(description.state);
}
}
/**
* State changed listener.
*
* This function take any state change and send it to the proper watchers.
*
* To prevent internal state changes from colliding with other reactive instances, only the
* general "state changed" is triggered at document level. All the internal changes are
* triggered at private target level without bubbling. This way any reactive instance can alert
* only its own watchers.
*
* @param {CustomEvent} event
*/
callWatchersHandler(event) {
// Execute any registered component watchers.
this.target.dispatchEvent(new CustomEvent(event.detail.action, {
bubbles: false,
detail: event.detail,
}));
}
/**
* Set the initial state.
*
* @param {object} stateData the initial state data.
*/
setInitialState(stateData) {
this.stateManager.setInitialState(stateData);
}
/**
* Add individual functions to the mutations.
*
* Note new mutations will be added to the existing ones. To replace the full mutation
* object with a new one, use setMutations method.
*
* @method addMutations
* @param {Object} newFunctions an object with new mutation functions.
*/
addMutations(newFunctions) {
for (const [mutation, mutationFunction] of Object.entries(newFunctions)) {
this.mutations[mutation] = mutationFunction.bind(newFunctions);
}
}
/**
* Replace the current mutations with a new object.
*
* This method is designed to override the full mutations class, for example by extending
* the original one. To add some individual mutations, use addMutations instead.
*
* @param {object} manager the new mutations intance
*/
setMutations(manager) {
this.mutations = manager;
}
/**
* Return the current state.
*
* @return {object}
*/
get state() {
return this.stateManager.state;
}
/**
* Return the initial state promise.
*
* Typically, components do not require to use this promise because registerComponent
* will trigger their stateReady method automatically. But it could be useful for complex
* components that require to combine state, template and string loadings.
*
* @method getState
* @return {Promise}
*/
getInitialStatePromise() {
return this.stateManager.getInitialPromise();
}
/**
* Register a new component.
*
* Component can provide some optional functions to the reactive module:
* - getWatchers: returns an array of watchers
* - stateReady: a method to call when the initial state is loaded
*
* It can also provide some optional attributes:
* - name: the component name (default value: "Unkown component") to customize debug messages.
*
* The method will also use dispatchRegistrationSuccess and dispatchRegistrationFail. Those
* are BaseComponent methods to inform parent components of the registration status.
* Components should not override those methods.
*
* @method registerComponent
* @param {object} component the new component
* @property {string} [component.name] the component name to display in warnings and errors.
* @property {Function} [component.dispatchRegistrationSuccess] method to notify registration success
* @property {Function} [component.dispatchRegistrationFail] method to notify registration fail
* @property {Function} [component.getWatchers] getter of the component watchers
* @property {Function} [component.stateReady] method to call when the state is ready
* @return {object} the registered component
*/
registerComponent(component) {
// Component name is an optional attribute to customize debug messages.
const componentName = component.name ?? 'Unkown component';
// Components can provide special methods to communicate registration to parent components.
let dispatchSuccess = () => {
return;
};
let dispatchFail = dispatchSuccess;
if (component.dispatchRegistrationSuccess !== undefined) {
dispatchSuccess = component.dispatchRegistrationSuccess.bind(component);
}
if (component.dispatchRegistrationFail !== undefined) {
dispatchFail = component.dispatchRegistrationFail.bind(component);
}
// Components can be registered only one time.
if (this.components.has(component)) {
dispatchSuccess();
return component;
}
// Keep track of the event listeners.
let listeners = [];
// Register watchers.
let handlers = [];
if (component.getWatchers !== undefined) {
handlers = component.getWatchers();
}
handlers.forEach(({watch, handler}) => {
if (watch === undefined) {
dispatchFail();
throw new Error(`Missing watch attribute in ${componentName} watcher`);
}
if (handler === undefined) {
dispatchFail();
throw new Error(`Missing handler for watcher ${watch} in ${componentName}`);
}
const listener = (event) => {
handler.apply(component, [event.detail]);
};
// Save the listener information in case the component must be unregistered later.
listeners.push({target: this.target, watch, listener});
// The state manager triggers a general "state changed" event at a document level. However,
// for the internal watchers, each component can listen to specific state changed custom events
// in the target element. This way we can use the native event loop without colliding with other
// reactive instances.
this.target.addEventListener(watch, listener);
});
// Register state ready function. There's the possibility a component is registered after the initial state
// is loaded. For those cases we have a state promise to handle this specific state change.
if (component.stateReady !== undefined) {
this.getInitialStatePromise()
.then(component.stateReady.bind(component))
.catch(reason => {
log.error(`Initial state in ${componentName} rejected due to: ${reason}`);
log.error(reason);
});
}
// Save unregister data.
this.watchers.set(component, listeners);
this.components.add(component);
dispatchSuccess();
return component;
}
/**
* Unregister a component and its watchers.
*
* @param {object} component the object instance to unregister
* @returns {object} the deleted component
*/
unregisterComponent(component) {
if (!this.components.has(component)) {
return component;
}
this.components.delete(component);
// Remove event listeners.
const listeners = this.watchers.get(component);
if (listeners === undefined) {
return component;
}
listeners.forEach(({target, watch, listener}) => {
target.removeEventListener(watch, listener);
});
this.watchers.delete(component);
return component;
}
/**
* Dispatch a change in the state.
*
* This method is the only way for components to alter the state. Watchers will receive a
* read only state to prevent illegal changes. If some user action require a state change, the
* component should dispatch a mutation to trigger all the necessary logic to alter the state.
*
* @method dispatch
* @param {string} actionName the action name (usually the mutation name)
* @param {*} param any number of params the mutation needs.
*/
async dispatch(actionName, ...params) {
if (typeof actionName !== 'string') {
throw new Error(`Dispatch action name must be a string`);
}
// JS does not have private methods yet. However, we prevent any component from calling
// a method starting with "_" because the most accepted convention for private methods.
if (actionName.charAt(0) === '_') {
throw new Error(`Illegal Private ${actionName} mutation method dispatch`);
}
if (this.mutations[actionName] === undefined) {
throw new Error(`Unkown ${actionName} mutation`);
}
const mutationFunction = this.mutations[actionName];
try {
await mutationFunction.apply(this.mutations, [this.stateManager, ...params]);
} catch (error) {
// Ensure the state is locked.
this.stateManager.setReadOnly(true);
throw error;
}
}
}

View File

@ -0,0 +1,768 @@
// 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/>.
/**
* Reactive simple state manager.
*
* The state manager contains the state data, trigger update events and
* can lock and unlock the state data.
*
* This file contains the three main elements of the state manager:
* - State manager: the public class to alter the state, dispatch events and process update messages.
* - Proxy handler: a private class to keep track of the state object changes.
* - StateMap class: a private class extending Map class that triggers event when a state list is modifed.
*
* @module core/local/reactive/stateManager
* @class core/local/reactive/stateManager
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* State manager class.
*
* This class handle the reactive state and ensure only valid mutations can modify the state.
* It also provide methods to apply batch state update messages (see processUpdates function doc
* for more details on update messages).
*
* Implementing a deep state manager is complex and will require many frontend resources. To keep
* the state fast and simple, the state can ONLY store two kind of data:
* - Object with attributes
* - Sets of objects with id attributes.
*
* This is an example of a valid state:
*
* {
* course: {
* name: 'course name',
* shortname: 'courseshort',
* sectionlist: [21, 34]
* },
* sections: [
* {id: 21, name: 'Topic 1', visible: true},
* {id: 34, name: 'Topic 2', visible: false,
* ],
* }
*
* The following cases are NOT allowed at a state ROOT level (throws an exception if they are assigned):
* - Simple values (strings, boolean...).
* - Arrays of simple values.
* - Array of objects without ID attribute (all arrays will be converted to maps and requires an ID).
*
* Thanks to those limitations it can simplify the state update messages and the event names. If You
* need to store simple data, just group them in an object.
*
* To grant any state change triggers the proper events, the class uses two private structures:
* - proxy handler: any object stored in the state is proxied using this class.
* - StateMap class: any object set in the state will be converted to StateMap using the
* objects id attribute.
*/
export default class StateManager {
/**
* Create a basic reactive state store.
*
* The state manager is meant to work with native JS events. To ensure each reactive module can use
* it in its own way, the parent element must provide a valid event dispatcher function and an optional
* DOM element to anchor the event.
*
* @param {function} dispatchEvent the function to dispatch the custom event when the state changes.
* @param {element} target the state changed custom event target (document if none provided)
*/
constructor(dispatchEvent, target) {
// The dispatch event function.
/** @package */
this.dispatchEvent = dispatchEvent;
// The DOM container to trigger events.
/** @package */
this.target = target ?? document;
// State can be altered freely until initial state is set.
/** @package */
this.readonly = false;
// List of state changes pending to be published as events.
/** @package */
this.eventsToPublish = [];
// The update state types functions.
/** @package */
this.updateTypes = {
"create": this.defaultCreate.bind(this),
"update": this.defaultUpdate.bind(this),
"delete": this.defaultDelete.bind(this),
};
// The state_loaded event is special because it only happens one but all components
// may react to that state, even if they are registered after the setIinitialState.
// For these reason we use a promise for that event.
this.initialPromise = new Promise((resolve) => {
const initialStateDone = (event) => {
resolve(event.detail.state);
};
this.target.addEventListener('state:loaded', initialStateDone);
});
}
/**
* Loads the initial state.
*
* Note this method will trigger a state changed event with "state:loaded" actionname.
*
* The state mode will be set to read only when the initial state is loaded.
*
* @param {object} initialState
*/
setInitialState(initialState) {
if (this.state !== undefined) {
throw Error('Initial state can only be initialized ones');
}
// Create the state object.
const state = new Proxy({}, new Handler('state', this, true));
for (const [prop, propValue] of Object.entries(initialState)) {
state[prop] = propValue;
}
this.state = state;
// When the state is loaded we can lock it to prevent illegal changes.
this.readonly = true;
this.dispatchEvent({
action: 'state:loaded',
state: this.state,
}, this.target);
}
/**
* Generate a promise that will be resolved when the initial state is loaded.
*
* In most cases the final state will be loaded using an ajax call. This is the reason
* why states manager are created unlocked and won't be reactive until the initial state is set.
*
* @return {Promise} the resulting promise
*/
getInitialPromise() {
return this.initialPromise;
}
/**
* Locks or unlocks the state to prevent illegal updates.
*
* Mutations use this method to modify the state. Once the state is updated, they must
* block again the state.
*
* All changes done while the state is writable will be registered using registerStateAction.
* When the state is set again to read only the method will trigger _publishEvents to communicate
* changes to all watchers.
*
* @param {bool} readonly if the state is in read only mode enabled
*/
setReadOnly(readonly) {
this.readonly = readonly;
// When the state is in readonly again is time to publish all pending events.
if (this.readonly) {
this._publishEvents();
}
}
/**
* Add methods to process update state messages.
*
* The state manager provide a default update, create and delete methods. However,
* some applications may require to override the default methods or even add new ones
* like "refresh" or "error".
*
* @param {Object} newFunctions the new update types functions.
*/
addUpdateTypes(newFunctions) {
for (const [updateType, updateFunction] of Object.entries(newFunctions)) {
if (typeof updateFunction === 'function') {
this.updateTypes[updateType] = updateFunction.bind(newFunctions);
}
}
}
/**
* Process a state updates array and do all the necessary changes.
*
* Note this method unlocks the state while it is executing and relocks it
* when finishes.
*
* @param {array} updates
* @param {Object} updateTypes optional functions to override the default update types.
*/
processUpdates(updates, updateTypes) {
if (!Array.isArray(updates)) {
throw Error('State updates must be an array');
}
this.setReadOnly(false);
updates.forEach((update) => {
if (update.name === undefined) {
throw Error('Missing state update name');
}
this.processUpdate(
update.name,
update.action,
update.fields,
updateTypes
);
});
this.setReadOnly(true);
}
/**
* Process a single state update.
*
* Note this method will not lock or unlock the state by itself.
*
* @param {string} updateName the state element to update
* @param {string} action to action to perform
* @param {object} fields the new data
* @param {Object} updateTypes optional functions to override the default update types.
*/
processUpdate(updateName, action, fields, updateTypes) {
if (!fields) {
throw Error('Missing state update fields');
}
if (updateTypes === undefined) {
updateTypes = {};
}
action = action ?? 'update';
const method = updateTypes[action] ?? this.updateTypes[action];
if (method === undefined) {
throw Error(`Unkown update action ${action}`);
}
method(this, updateName, fields);
}
/**
* Process a create state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultCreate(stateManager, updateName, fields) {
let state = stateManager.state;
// Create can be applied only to lists, not to objects.
if (state[updateName] instanceof StateMap) {
state[updateName].add(fields);
return;
}
state[updateName] = fields;
}
/**
* Process a delete state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultDelete(stateManager, updateName, fields) {
// Get the current value.
let current = stateManager.get(updateName, fields.id);
if (!current) {
throw Error(`Inexistent ${updateName} ${fields.id}`);
}
// Process deletion.
let state = stateManager.state;
if (state[updateName] instanceof StateMap) {
state[updateName].delete(fields.id);
return;
}
delete state[updateName];
}
/**
* Process a update state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultUpdate(stateManager, updateName, fields) {
// Get the current value.
let current = stateManager.get(updateName, fields.id);
if (!current) {
throw Error(`Inexistent ${updateName} ${fields.id}`);
}
// Execute updates.
for (const [fieldName, fieldValue] of Object.entries(fields)) {
current[fieldName] = fieldValue;
}
}
/**
* Get an element from the state or form an alternative state object.
*
* The altstate param is used by external update functions that gets the current
* state as param.
*
* @param {String} name the state object name
* @param {*} id and object id for state maps.
* @return {Object|undefined} the state object found
*/
get(name, id) {
const state = this.state;
let current = state[name];
if (current instanceof StateMap) {
if (id === undefined) {
throw Error(`Missing id for ${name} state update`);
}
current = state[name].get(id);
}
return current;
}
/**
* Register a state modification and generate the necessary events.
*
* This method is used mainly by proxy helpers to dispatch state change event.
* However, mutations can use it to inform components about non reactive changes
* in the state (only the two first levels of the state are reactive).
*
* Each action can produce several events:
* - The specific attribute updated, created or deleter (example: "cm.visible:updated")
* - The general state object updated, created or deleted (example: "cm:updated")
* - If the element has an ID attribute, the specific event with id (example: "cm[42].visible:updated")
* - If the element has an ID attribute, the general event with id (example: "cm[42]:updated")
* - A generic state update event "state:update"
*
* @param {string} field the affected state field name
* @param {string|null} prop the affecter field property (null if affect the full object)
* @param {string} action the action done (created/updated/deleted)
* @param {*} data the affected data
*/
registerStateAction(field, prop, action, data) {
let parentAction = 'updated';
if (prop !== null) {
this.eventsToPublish.push({
eventName: `${field}.${prop}:${action}`,
eventData: data,
action,
});
} else {
parentAction = action;
}
// Trigger extra events if the element has an ID attribute.
if (data.id !== undefined) {
if (prop !== null) {
this.eventsToPublish.push({
eventName: `${field}[${data.id}].${prop}:${action}`,
eventData: data,
action,
});
}
this.eventsToPublish.push({
eventName: `${field}[${data.id}]:${parentAction}`,
eventData: data,
action: parentAction,
});
}
// Register the general change.
this.eventsToPublish.push({
eventName: `${field}:${parentAction}`,
eventData: data,
action: parentAction,
});
// Register state updated event.
this.eventsToPublish.push({
eventName: `state:updated`,
eventData: data,
action: 'updated',
});
}
/**
* Internal method to publish events.
*
* This is a private method, it will be invoked when the state is set back to read only mode.
*/
_publishEvents() {
const fieldChanges = this.eventsToPublish;
this.eventsToPublish = [];
// Dispatch a transaction start event.
this.dispatchEvent({
action: 'transaction:start',
state: this.state,
element: null,
}, this.target);
// State changes can be registered in any order. However it will avoid many
// components errors if they are sorted to have creations-updates-deletes in case
// some component needs to create or destroy DOM elements before updating them.
fieldChanges.sort((a, b) => {
const weights = {
created: 0,
updated: 1,
deleted: 2,
};
const aweight = weights[a.action] ?? 0;
const bweight = weights[b.action] ?? 0;
// In case both have the same weight, the eventName length decide.
if (aweight === bweight) {
return a.eventName.length - b.eventName.length;
}
return aweight - bweight;
});
// List of the published events to prevent redundancies.
let publishedEvents = new Set();
fieldChanges.forEach((event) => {
const eventkey = `${event.eventName}.${event.eventData.id ?? 0}`;
if (!publishedEvents.has(eventkey)) {
this.dispatchEvent({
action: event.eventName,
state: this.state,
element: event.eventData
}, this.target);
publishedEvents.add(eventkey);
}
});
// Dispatch a transaction end event.
this.dispatchEvent({
action: 'transaction:end',
state: this.state,
element: null,
}, this.target);
}
}
// Proxy helpers.
/**
* The proxy handler.
*
* This class will inform any value change directly to the state manager.
*
* The proxied variable will throw an error if it is altered when the state manager is
* in read only mode.
*/
class Handler {
/**
* Class constructor.
*
* @param {string} name the variable name used for identify triggered actions
* @param {StateManager} stateManager the state manager object
* @param {boolean} proxyValues if new values must be proxied (used only at state root level)
*/
constructor(name, stateManager, proxyValues) {
this.name = name;
this.stateManager = stateManager;
this.proxyValues = proxyValues ?? false;
}
/**
* Set trap to trigger events when the state changes.
*
* @param {object} obj the source object (not proxied)
* @param {string} prop the attribute to set
* @param {*} value the value to save
* @param {*} receiver the proxied element to be attached to events
* @returns {boolean} if the value is set
*/
set(obj, prop, value, receiver) {
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to change ${prop} value in ${this.name}.`);
}
// Check any data change.
if (JSON.stringify(obj[prop]) === JSON.stringify(value)) {
return true;
}
const action = (obj[prop] !== undefined) ? 'updated' : 'created';
// Proxy value if necessary (used at state root level).
if (this.proxyValues) {
if (Array.isArray(value)) {
obj[prop] = new StateMap(prop, this.stateManager).loadValues(value);
} else {
obj[prop] = new Proxy(value, new Handler(prop, this.stateManager));
}
} else {
obj[prop] = value;
}
// If the state is not ready yet means the initial state is not yet loaded.
if (this.stateManager.state === undefined) {
return true;
}
this.stateManager.registerStateAction(this.name, prop, action, receiver);
return true;
}
/**
* Delete property trap to trigger state change events.
*
* @param {*} obj the affected object (not proxied)
* @param {*} prop the prop to delete
* @returns {boolean} if prop is deleted
*/
deleteProperty(obj, prop) {
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to delete ${prop} in ${this.name}.`);
}
if (prop in obj) {
delete obj[prop];
this.stateManager.registerStateAction(this.name, prop, 'deleted', obj);
}
return true;
}
}
/**
* Class to add events dispatching to the JS Map class.
*
* When the state has a list of objects (with IDs) it will be converted into a StateMap.
* StateMap is used almost in the same way as a regular JS map. Because all elements have an
* id attribute, it has some specific methods:
* - add: a convenient method to add an element without specifying the key ("id" attribute will be used as a key).
* - loadValues: to add many elements at once wihout specifying keys ("id" attribute will be used).
*
* Apart, the main difference between regular Map and MapState is that this one will inform any change to the
* state manager.
*/
class StateMap extends Map {
/**
* Create a reactive Map.
*
* @param {string} name the property name
* @param {StateManager} stateManager the state manager
* @param {iterable} iterable an iterable object to create the Map
*/
constructor(name, stateManager, iterable) {
// We don't have any "this" until be call super.
super(iterable);
this.name = name;
this.stateManager = stateManager;
}
/**
* Set an element into the map.
*
* Each value needs it's own id attribute. Objects without id will be rejected.
* The function will throw an error if the value id and the key are not the same.
*
* @param {*} key the key to store
* @param {*} value the value to store
* @returns {Map} the resulting Map object
*/
set(key, value) {
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);
}
// Normalize keys as string to prevent json decoding errors.
key = this.normalizeKey(key);
this.checkValue(value);
if (key === undefined || key === null) {
throw Error('State lists keys cannot be null or undefined');
}
// ID is mandatory and should be the same as the key.
if (this.normalizeKey(value.id) !== key) {
throw new Error(`State error: ${this.name} list element ID (${value.id}) and key (${key}) mismatch`);
}
const action = (super.has(key)) ? 'updated' : 'created';
// Save proxied data into the list.
const result = super.set(key, new Proxy(value, new Handler(this.name, this.stateManager)));
// If the state is not ready yet means the initial state is not yet loaded.
if (this.stateManager.state === undefined) {
return result;
}
this.stateManager.registerStateAction(this.name, null, action, super.get(key));
return result;
}
/**
* Check if a value is valid to be stored in a a State List.
*
* Only objects with id attribute can be stored in State lists.
*
* This method throws an error if the value is not valid.
*
* @param {object} value (with ID)
*/
checkValue(value) {
if (!typeof value === 'object' && value !== null) {
throw Error('State lists can contain objects only');
}
if (value.id === undefined) {
throw Error('State lists elements must contain at least an id attribute');
}
}
/**
* Return a normalized key value for state map.
*
* Regular maps uses strict key comparissons but state maps are indexed by ID.JSON conversions
* and webservices sometimes do unexpected types conversions so we convert any integer key to string.
*
* @param {*} key the provided key
* @returns {string}
*/
normalizeKey(key) {
return String(key).valueOf();
}
/**
* Insert a new element int a list.
*
* Each value needs it's own id attribute. Objects withouts id will be rejected.
*
* @param {object} value the value to add (needs an id attribute)
* @returns {Map} the resulting Map object
*/
add(value) {
this.checkValue(value);
return this.set(value.id, value);
}
/**
* Return a state map element.
*
* @param {*} key the element id
* @return {Object}
*/
get(key) {
return super.get(this.normalizeKey(key));
}
/**
* Check whether an element with the specified key exists or not.
*
* @param {*} key the key to find
* @return {boolean}
*/
has(key) {
return super.has(this.normalizeKey(key));
}
/**
* Delete an element from the map.
*
* @param {*} key
* @returns {boolean}
*/
delete(key) {
// State maps uses only string keys to avoid strict comparisons.
key = this.normalizeKey(key);
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);
}
const previous = super.get(key);
const result = super.delete(key);
if (!result) {
return result;
}
this.stateManager.registerStateAction(this.name, null, 'deleted', previous);
return result;
}
/**
* Return a suitable structure for JSON conversion.
*
* This function is needed because new values are compared in JSON. StateMap has Private
* attributes which cannot be stringified (like this.stateManager which will produce an
* infinite recursivity).
*
* @returns {array}
*/
toJSON() {
let result = [];
this.forEach((value) => {
result.push(value);
});
return result;
}
/**
* Insert a full list of values using the id attributes as keys.
*
* This method is used mainly to initialize the list. Note each element is indexed by its "id" attribute.
* This is a basic restriction of StateMap. All elements need an id attribute, otherwise it won't be saved.
*
* @param {iterable} values the values to load
* @returns {StateMap} return the this value
*/
loadValues(values) {
values.forEach((data) => {
this.checkValue(data);
let key = data.id;
let newvalue = new Proxy(data, new Handler(this.name, this.stateManager));
this.set(key, newvalue);
});
return this;
}
}

27
lib/amd/src/reactive.js Normal file
View File

@ -0,0 +1,27 @@
// 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/>.
/**
* Generic reactive module used in the course editor.
*
* @module core/reactive
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import BaseComponent from 'core/local/reactive/basecomponent';
import Reactive from 'core/local/reactive/reactive';
export {Reactive, BaseComponent};