From b85cba317e1fd4e433f91a5f8cdf0972624f80a8 Mon Sep 17 00:00:00 2001 From: Ferran Recio Date: Thu, 1 Apr 2021 15:44:39 +0200 Subject: [PATCH] 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. --- .../build/local/reactive/basecomponent.min.js | 2 + .../local/reactive/basecomponent.min.js.map | 1 + lib/amd/build/local/reactive/reactive.min.js | 2 + .../build/local/reactive/reactive.min.js.map | 1 + .../build/local/reactive/statemanager.min.js | 2 + .../local/reactive/statemanager.min.js.map | 1 + lib/amd/build/reactive.min.js | 2 + lib/amd/build/reactive.min.js.map | 1 + lib/amd/src/local/reactive/basecomponent.js | 406 +++++++++ lib/amd/src/local/reactive/reactive.js | 340 ++++++++ lib/amd/src/local/reactive/statemanager.js | 768 ++++++++++++++++++ lib/amd/src/reactive.js | 27 + 12 files changed, 1553 insertions(+) create mode 100644 lib/amd/build/local/reactive/basecomponent.min.js create mode 100644 lib/amd/build/local/reactive/basecomponent.min.js.map create mode 100644 lib/amd/build/local/reactive/reactive.min.js create mode 100644 lib/amd/build/local/reactive/reactive.min.js.map create mode 100644 lib/amd/build/local/reactive/statemanager.min.js create mode 100644 lib/amd/build/local/reactive/statemanager.min.js.map create mode 100644 lib/amd/build/reactive.min.js create mode 100644 lib/amd/build/reactive.min.js.map create mode 100644 lib/amd/src/local/reactive/basecomponent.js create mode 100644 lib/amd/src/local/reactive/reactive.js create mode 100644 lib/amd/src/local/reactive/statemanager.js create mode 100644 lib/amd/src/reactive.js diff --git a/lib/amd/build/local/reactive/basecomponent.min.js b/lib/amd/build/local/reactive/basecomponent.min.js new file mode 100644 index 00000000000..47a1df0af24 --- /dev/null +++ b/lib/amd/build/local/reactive/basecomponent.min.js @@ -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.\n\nimport Templates from 'core/templates';\n\n/**\n * Reactive UI component base class.\n *\n * Each UI reactive component should extend this class to interact with a reactive state.\n *\n * @module core/local/reactive/basecomponent\n * @class core/local/reactive/basecomponent\n * @copyright 2020 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class {\n\n /**\n * The component descriptor data structure.\n *\n * This structure is used by any component and init method to define the way the component will interact\n * with the interface and whith reactive instance operates. The logic behind this object is to avoid\n * unnecessary dependancies between the final interface and the state logic.\n *\n * Any component interacts with a single main DOM element (description.element) but it can use internal\n * selector to select elements within this main element (descriptor.selectors). By default each component\n * will provide it's own default selectors, but those can be overridden by the \"descriptor.selectors\"\n * property in case the mustache wants to reuse the same component logic but with a different interface.\n *\n * @typedef {object} descriptor\n * @property {Reactive} reactive mandatory reactive module to register in\n * @property {DOMElement} element all components needs an element to anchor events\n * @property {object} [selectors] an optional object to override query selectors\n */\n\n /**\n * The class constructor.\n *\n * The only param this method gets is a constructor with all the mandatory\n * and optional component data. Component will receive the same descriptor\n * as create method param.\n *\n * This method will call the \"create\" method before registering the component into\n * the reactive module. This way any component can add default selectors and events.\n *\n * @param {descriptor} descriptor data to create the object.\n */\n constructor(descriptor) {\n\n if (descriptor.element === undefined || !(descriptor.element instanceof HTMLElement)) {\n throw Error(`Reactive components needs a main DOM element to dispatch events`);\n }\n\n if (descriptor.reactive === undefined) {\n throw Error(`Reactive components needs a reactive module to work with`);\n }\n\n this.reactive = descriptor.reactive;\n this.element = descriptor.element;\n\n // Variable to track event listeners.\n this.eventHandlers = new Map([]);\n this.eventListeners = [];\n\n // Empty default component selectors.\n this.selectors = {};\n\n // Empty default event list from the static method.\n this.events = this.constructor.getEvents();\n\n // Call create function to get the component defaults.\n this.create(descriptor);\n\n // Overwrite the components selectors if necessary.\n if (descriptor.selectors !== undefined) {\n this.addSelectors(descriptor.selectors);\n }\n\n // Register the component.\n this.reactive.registerComponent(this);\n }\n\n /**\n * Return the component custom event names.\n *\n * Components may override this method to provide their own events.\n *\n * Component custom events is an important part of component reusability. This function\n * is static because is part of the component definition and should be accessible from\n * outsite the instances. However, values will be available at instance level in the\n * this.events object.\n *\n * @returns {Object} the component events.\n */\n static getEvents() {\n return {};\n }\n\n /**\n * Component create function.\n *\n * Default init method will call \"create\" when all internal attributes are set\n * but before the component is not yet registered in the reactive module.\n *\n * In this method any component can define its own defaults such as:\n * - this.selectors {object} the default query selectors of this component.\n * - this.events {object} a list of event names this component dispatch\n * - extract any data from the main dom element (this.element)\n * - set any other data the component uses\n *\n * @param {descriptor} descriptor the component descriptor\n */\n // eslint-disable-next-line no-unused-vars\n create(descriptor) {\n // Components may override this method to initialize selects, events or other data.\n }\n\n /**\n * Component destroy hook.\n *\n * BaseComponent call this method when a component is unregistered or removed.\n *\n * Components may override this method to clean the HTML or do some action when the\n * component is unregistered or removed.\n */\n destroy() {\n // Components can override this method.\n }\n\n /**\n * Return the list of watchers that component has.\n *\n * Each watcher is represented by an object with two attributes:\n * - watch (string) the specific state event to watch. Example 'section.visible:updated'\n * - handler (function) the function to call when the watching state change happens\n *\n * Any component shoudl override this method to define their state watchers.\n *\n * @returns {array} array of watchers.\n */\n getWatchers() {\n return [];\n }\n\n /**\n * Reactive module will call this method when the state is ready.\n *\n * Component can override this method to update/load the component HTML or to bind\n * listeners to HTML entities.\n */\n stateReady() {\n // Components can override this method.\n }\n\n /**\n * Get the main DOM element of this component or a subelement.\n *\n * @param {string|undefined} query optional subelement query\n * @param {string|undefined} dataId optional data-id value\n * @returns {element|undefined} the DOM element (if any)\n */\n getElement(query, dataId) {\n if (query === undefined && dataId === undefined) {\n return this.element;\n }\n const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';\n const selector = `${query ?? ''}${dataSelector}`;\n return this.element.querySelector(selector);\n }\n\n /**\n * Get the all subelement that match a query selector.\n *\n * @param {string|undefined} query optional subelement query\n * @param {string|undefined} dataId optional data-id value\n * @returns {NodeList} the DOM elements\n */\n getElements(query, dataId) {\n const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';\n const selector = `${query ?? ''}${dataSelector}`;\n return this.element.querySelectorAll(selector);\n }\n\n /**\n * Add or update the component selectors.\n *\n * @param {Object} newSelectors an object of new selectors.\n */\n addSelectors(newSelectors) {\n for (const [selectorName, selector] of Object.entries(newSelectors)) {\n this.selectors[selectorName] = selector;\n }\n }\n\n /**\n * Return a component selector.\n *\n * @param {string} selectorName the selector name\n * @return {string|undefined} the query selector\n */\n getSelector(selectorName) {\n return this.selectors[selectorName];\n }\n\n /**\n * Dispatch a custom event on this.element.\n *\n * This is just a convenient method to dispatch custom events from within a component.\n * Components are free to use an alternative function to dispatch custom\n * events. The only restriction is that it should be dispatched on this.element\n * and specify \"bubbles:true\" to alert any component listeners.\n *\n * @param {string} eventName the event name\n * @param {*} detail event detail data\n */\n dispatchEvent(eventName, detail) {\n this.element.dispatchEvent(new CustomEvent(eventName, {\n bubbles: true,\n detail: detail,\n }));\n }\n\n /**\n * Render a new Component using a mustache file.\n *\n * It is important to note that this method should NOT be used for loading regular mustache files\n * as it returns a Promise that will only be resolved if the mustache registers a component instance.\n *\n * @param {element} target the DOM element that contains the component\n * @param {string} file the component mustache file to render\n * @param {*} data the mustache data\n * @return {Promise} a promise of the resulting component instance\n */\n renderComponent(target, file, data) {\n return new Promise((resolve, reject) => {\n target.addEventListener('ComponentRegistration:Success', ({detail}) => {\n resolve(detail.component);\n });\n target.addEventListener('ComponentRegistration:Fail', () => {\n reject(`Registration of ${file} fails.`);\n });\n Templates.renderForPromise(\n file,\n data\n ).then(({html, js}) => {\n Templates.replaceNodeContents(target, html, js);\n return true;\n }).catch(error => {\n reject(`Rendering of ${file} throws an error.`);\n throw error;\n });\n });\n }\n\n /**\n * Add and bind an event listener to a target and keep track of all event listeners.\n *\n * The native element.addEventListener method is not object oriented friently as the\n * \"this\" represents the element that triggers the event and not the listener class.\n * As components can be unregister and removed at any time, the BaseComponent provides\n * this method to keep track of all component listeners and do all of the bind stuff.\n *\n * @param {Element} target the event target\n * @param {string} type the event name\n * @param {function} listener the class method that recieve the event\n */\n addEventListener(target, type, listener) {\n\n // Check if we have the bind version of that listener.\n let bindListener = this.eventHandlers.get(listener);\n\n if (bindListener === undefined) {\n bindListener = listener.bind(this);\n this.eventHandlers.set(listener, bindListener);\n }\n\n target.addEventListener(type, bindListener);\n\n // Keep track of all component event listeners in case we need to remove them.\n this.eventListeners.push({\n target,\n type,\n bindListener,\n });\n\n }\n\n /**\n * Remove an event listener from a component.\n *\n * This method allows components to remove listeners without keeping track of the\n * listeners bind versions of the method. Both addEventListener and removeEventListener\n * keeps internally the relation between the original class method and the bind one.\n *\n * @param {Element} target the event target\n * @param {string} type the event name\n * @param {function} listener the class method that recieve the event\n */\n removeEventListener(target, type, listener) {\n // Check if we have the bind version of that listener.\n let bindListener = this.eventHandlers.get(listener);\n\n if (bindListener === undefined) {\n // This listener has not been added.\n return;\n }\n\n target.removeEventListener(type, bindListener);\n }\n\n /**\n * Remove all event listeners from this component.\n *\n * This method is called also when the component is unregistered or removed.\n *\n * Note that only listeners registered with the addEventListener method\n * will be removed. Other manual listeners will keep active.\n */\n removeAllEventListeners() {\n this.eventListeners.forEach(({target, type, bindListener}) => {\n target.removeEventListener(type, bindListener);\n });\n this.eventListeners = [];\n }\n\n /**\n * Remove a previously rendered component instance.\n *\n * This method will remove the component HTML and unregister it from the\n * reactive module.\n */\n remove() {\n this.unregister();\n this.element.remove();\n }\n\n /**\n * Unregister the component from the reactive module.\n *\n * This method will disable the component logic, event listeners and watchers\n * but it won't remove any HTML created by the component. However, it will trigger\n * the destroy hook to allow the component to clean parts of the interface.\n */\n unregister() {\n this.reactive.unregisterComponent(this);\n this.removeAllEventListeners();\n this.destroy();\n }\n\n /**\n * Dispatch a component registration event to inform the parent node.\n *\n * The registration event is different from the rest of the component events because\n * is the only way in which components can communicate its existence to a possible parent.\n * Most components will be created by including a mustache file, child components\n * must emit a registration event to the parent DOM element to alert about the registration.\n */\n dispatchRegistrationSuccess() {\n // The registration event does not bubble because we just want to comunicate with the parentNode.\n // Otherwise, any component can get multiple registrations events and could not differentiate\n // between child components and grand child components.\n if (this.element.parentNode === undefined) {\n return;\n }\n // This custom element is captured by renderComponent method.\n this.element.parentNode.dispatchEvent(new CustomEvent(\n 'ComponentRegistration:Success',\n {\n bubbles: false,\n detail: {component: this},\n }\n ));\n }\n\n /**\n * Dispatch a component registration fail event to inform the parent node.\n *\n * As dispatchRegistrationSuccess, this method will communicate the registration fail to the\n * parent node to inform the possible parent component.\n */\n dispatchRegistrationFail() {\n if (this.element.parentNode === undefined) {\n return;\n }\n // This custom element is captured only by renderComponent method.\n this.element.parentNode.dispatchEvent(new CustomEvent(\n 'ComponentRegistration:Fail',\n {\n bubbles: false,\n detail: {component: this},\n }\n ));\n }\n}\n"],"file":"basecomponent.min.js"} \ No newline at end of file diff --git a/lib/amd/build/local/reactive/reactive.min.js b/lib/amd/build/local/reactive/reactive.min.js new file mode 100644 index 00000000000..a8dc5bbdda8 --- /dev/null +++ b/lib/amd/build/local/reactive/reactive.min.js @@ -0,0 +1,2 @@ +define ("core/local/reactive/reactive",["exports","core/log","core/local/reactive/statemanager"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=d(b);c=d(c);function d(a){return a&&a.__esModule?a:{default:a}}function e(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function f(a){return function(){var b=this,c=arguments;return new Promise(function(d,f){var i=a.apply(b,c);function g(a){e(i,d,f,g,h,"next",a)}function h(a){e(i,d,f,g,h,"throw",a)}g(void 0)})}}function g(a,b){return m(a)||l(a,b)||j(a,b)||h()}function h(){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 j(a,b){if(!a)return;if("string"==typeof a)return k(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 k(a,b)}function k(a,b){if(null==b||b>a.length)b=a.length;for(var c=0,d=Array(b);c.\n\n/**\n * A generic single state reactive module.\n *\n * @module core/reactive/local/reactive/reactive\n * @class core/reactive/local/reactive/reactive\n * @copyright 2021 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport log from 'core/log';\nimport StateManager from 'core/local/reactive/statemanager';\n\n/**\n * Set up general reactive class to create a single state application with components.\n *\n * The reactive class is used for registering new UI components and manage the access to the state values\n * and mutations.\n *\n * When a new reactive instance is created, it will contain an empty state and and empty mutations\n * lists. When the state data is ready, the initial state can be loaded using the \"setInitialState\"\n * method. This will protect the state from writing and will trigger all the components \"stateReady\"\n * methods.\n *\n * State can only be altered by mutations. To replace all the mutations with a specific class,\n * use \"setMutations\" method. If you need to just add some new mutation methods, use \"addMutations\".\n *\n * To register new components into a reactive instance, use \"registerComponent\".\n *\n * Inside a component, use \"dispatch\" to invoke a mutation on the state (components can only access\n * the state in read only mode).\n */\nexport default class {\n\n /**\n * The component descriptor data structure.\n *\n * @typedef {object} description\n * @property {string} eventName the custom event name used for state changed events\n * @property {Function} eventDispatch the state update event dispatch function\n * @property {Element} [target] the target of the event dispatch. If not passed a fake element will be created\n * @property {Object} [mutations] an object with state mutations functions\n * @property {Object} [state] an object to initialize the state.\n */\n\n /**\n * Create a basic reactive manager.\n *\n * Note that if your state is not async loaded, you can pass directly on creation by using the\n * description.state attribute. However, this will initialize the state, this means\n * setInitialState will throw an exception because the state is already defined.\n *\n * @param {description} description reactive manager description.\n */\n constructor(description) {\n\n if (description.eventName === undefined || description.eventDispatch === undefined) {\n throw new Error(`Reactivity event required`);\n }\n\n // Each reactive instance has its own element anchor to propagate state changes internally.\n // By default the module will create a fake DOM element to target custom events but\n // if all reactive components is constrait to a single element, this can be passed as\n // target in the description.\n this.target = description.target ?? document.createTextNode(null);\n\n this.eventName = description.eventName;\n this.eventDispatch = description.eventDispatch;\n\n // State manager is responsible for dispatch state change events when a mutation happens.\n this.stateManager = new StateManager(this.eventDispatch, this.target);\n\n // An internal registry of watchers and components.\n this.watchers = new Map([]);\n this.components = new Set([]);\n\n // Mutations can be overridden later using setMutations method.\n this.mutations = description.mutations ?? {};\n\n // Register the event to alert watchers when specific state change happens.\n this.target.addEventListener(this.eventName, this.callWatchersHandler.bind(this));\n\n // Set initial state if we already have it.\n if (description.state !== undefined) {\n this.setInitialState(description.state);\n }\n }\n\n /**\n * State changed listener.\n *\n * This function take any state change and send it to the proper watchers.\n *\n * To prevent internal state changes from colliding with other reactive instances, only the\n * general \"state changed\" is triggered at document level. All the internal changes are\n * triggered at private target level without bubbling. This way any reactive instance can alert\n * only its own watchers.\n *\n * @param {CustomEvent} event\n */\n callWatchersHandler(event) {\n // Execute any registered component watchers.\n this.target.dispatchEvent(new CustomEvent(event.detail.action, {\n bubbles: false,\n detail: event.detail,\n }));\n }\n\n /**\n * Set the initial state.\n *\n * @param {object} stateData the initial state data.\n */\n setInitialState(stateData) {\n this.stateManager.setInitialState(stateData);\n }\n\n /**\n * Add individual functions to the mutations.\n *\n * Note new mutations will be added to the existing ones. To replace the full mutation\n * object with a new one, use setMutations method.\n *\n * @method addMutations\n * @param {Object} newFunctions an object with new mutation functions.\n */\n addMutations(newFunctions) {\n for (const [mutation, mutationFunction] of Object.entries(newFunctions)) {\n this.mutations[mutation] = mutationFunction.bind(newFunctions);\n }\n }\n\n /**\n * Replace the current mutations with a new object.\n *\n * This method is designed to override the full mutations class, for example by extending\n * the original one. To add some individual mutations, use addMutations instead.\n *\n * @param {object} manager the new mutations intance\n */\n setMutations(manager) {\n this.mutations = manager;\n }\n\n /**\n * Return the current state.\n *\n * @return {object}\n */\n get state() {\n return this.stateManager.state;\n }\n\n /**\n * Return the initial state promise.\n *\n * Typically, components do not require to use this promise because registerComponent\n * will trigger their stateReady method automatically. But it could be useful for complex\n * components that require to combine state, template and string loadings.\n *\n * @method getState\n * @return {Promise}\n */\n getInitialStatePromise() {\n return this.stateManager.getInitialPromise();\n }\n\n /**\n * Register a new component.\n *\n * Component can provide some optional functions to the reactive module:\n * - getWatchers: returns an array of watchers\n * - stateReady: a method to call when the initial state is loaded\n *\n * It can also provide some optional attributes:\n * - name: the component name (default value: \"Unkown component\") to customize debug messages.\n *\n * The method will also use dispatchRegistrationSuccess and dispatchRegistrationFail. Those\n * are BaseComponent methods to inform parent components of the registration status.\n * Components should not override those methods.\n *\n * @method registerComponent\n * @param {object} component the new component\n * @property {string} [component.name] the component name to display in warnings and errors.\n * @property {Function} [component.dispatchRegistrationSuccess] method to notify registration success\n * @property {Function} [component.dispatchRegistrationFail] method to notify registration fail\n * @property {Function} [component.getWatchers] getter of the component watchers\n * @property {Function} [component.stateReady] method to call when the state is ready\n * @return {object} the registered component\n */\n registerComponent(component) {\n\n // Component name is an optional attribute to customize debug messages.\n const componentName = component.name ?? 'Unkown component';\n\n // Components can provide special methods to communicate registration to parent components.\n let dispatchSuccess = () => {\n return;\n };\n let dispatchFail = dispatchSuccess;\n if (component.dispatchRegistrationSuccess !== undefined) {\n dispatchSuccess = component.dispatchRegistrationSuccess.bind(component);\n }\n if (component.dispatchRegistrationFail !== undefined) {\n dispatchFail = component.dispatchRegistrationFail.bind(component);\n }\n\n // Components can be registered only one time.\n if (this.components.has(component)) {\n dispatchSuccess();\n return component;\n }\n\n // Keep track of the event listeners.\n let listeners = [];\n\n // Register watchers.\n let handlers = [];\n if (component.getWatchers !== undefined) {\n handlers = component.getWatchers();\n }\n handlers.forEach(({watch, handler}) => {\n\n if (watch === undefined) {\n dispatchFail();\n throw new Error(`Missing watch attribute in ${componentName} watcher`);\n }\n if (handler === undefined) {\n dispatchFail();\n throw new Error(`Missing handler for watcher ${watch} in ${componentName}`);\n }\n\n const listener = (event) => {\n handler.apply(component, [event.detail]);\n };\n\n // Save the listener information in case the component must be unregistered later.\n listeners.push({target: this.target, watch, listener});\n\n // The state manager triggers a general \"state changed\" event at a document level. However,\n // for the internal watchers, each component can listen to specific state changed custom events\n // in the target element. This way we can use the native event loop without colliding with other\n // reactive instances.\n this.target.addEventListener(watch, listener);\n });\n\n // Register state ready function. There's the possibility a component is registered after the initial state\n // is loaded. For those cases we have a state promise to handle this specific state change.\n if (component.stateReady !== undefined) {\n this.getInitialStatePromise()\n .then(component.stateReady.bind(component))\n .catch(reason => {\n log.error(`Initial state in ${componentName} rejected due to: ${reason}`);\n log.error(reason);\n });\n }\n\n // Save unregister data.\n this.watchers.set(component, listeners);\n this.components.add(component);\n\n dispatchSuccess();\n return component;\n }\n\n /**\n * Unregister a component and its watchers.\n *\n * @param {object} component the object instance to unregister\n * @returns {object} the deleted component\n */\n unregisterComponent(component) {\n if (!this.components.has(component)) {\n return component;\n }\n\n this.components.delete(component);\n\n // Remove event listeners.\n const listeners = this.watchers.get(component);\n if (listeners === undefined) {\n return component;\n }\n\n listeners.forEach(({target, watch, listener}) => {\n target.removeEventListener(watch, listener);\n });\n\n this.watchers.delete(component);\n\n return component;\n }\n\n /**\n * Dispatch a change in the state.\n *\n * This method is the only way for components to alter the state. Watchers will receive a\n * read only state to prevent illegal changes. If some user action require a state change, the\n * component should dispatch a mutation to trigger all the necessary logic to alter the state.\n *\n * @method dispatch\n * @param {string} actionName the action name (usually the mutation name)\n * @param {*} param any number of params the mutation needs.\n */\n async dispatch(actionName, ...params) {\n if (typeof actionName !== 'string') {\n throw new Error(`Dispatch action name must be a string`);\n }\n // JS does not have private methods yet. However, we prevent any component from calling\n // a method starting with \"_\" because the most accepted convention for private methods.\n if (actionName.charAt(0) === '_') {\n throw new Error(`Illegal Private ${actionName} mutation method dispatch`);\n }\n if (this.mutations[actionName] === undefined) {\n throw new Error(`Unkown ${actionName} mutation`);\n }\n const mutationFunction = this.mutations[actionName];\n try {\n await mutationFunction.apply(this.mutations, [this.stateManager, ...params]);\n } catch (error) {\n // Ensure the state is locked.\n this.stateManager.setReadOnly(true);\n throw error;\n }\n }\n}\n"],"file":"reactive.min.js"} \ No newline at end of file diff --git a/lib/amd/build/local/reactive/statemanager.min.js b/lib/amd/build/local/reactive/statemanager.min.js new file mode 100644 index 00000000000..008e2d86242 --- /dev/null +++ b/lib/amd/build/local/reactive/statemanager.min.js @@ -0,0 +1,2 @@ +define ("core/local/reactive/statemanager",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;function b(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){b=function(a){return typeof a}}else{b=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return b(a)}function c(a,b,e){if("undefined"!=typeof Reflect&&Reflect.get){c=Reflect.get}else{c=function(a,b,c){var e=d(a,b);if(!e)return;var f=Object.getOwnPropertyDescriptor(e,b);if(f.get){return f.get.call(c)}return f.value}}return c(a,b,e||a)}function d(a,b){while(!Object.prototype.hasOwnProperty.call(a,b)){a=n(a);if(null===a)break}return a}function e(a,b){if("function"!=typeof b&&null!==b){throw new TypeError("Super expression must either be null or a function")}a.prototype=Object.create(b&&b.prototype,{constructor:{value:a,writable:!0,configurable:!0}});if(b)m(a,b)}function f(a){return function(){var b=n(a),c;if(k()){var d=n(this).constructor;c=Reflect.construct(b,arguments,d)}else{c=b.apply(this,arguments)}return g(this,c)}}function g(a,c){if(c&&("object"===b(c)||"function"==typeof c)){return c}return h(a)}function h(a){if(void 0===a){throw new ReferenceError("this hasn't been initialised - super() hasn't been called")}return a}function i(a){var b="function"==typeof Map?new Map:void 0;i=function(a){if(null===a||!l(a))return a;if("function"!=typeof a){throw new TypeError("Super expression must either be null or a function")}if("undefined"!=typeof b){if(b.has(a))return b.get(a);b.set(a,c)}function c(){return j(a,arguments,n(this).constructor)}c.prototype=Object.create(a.prototype,{constructor:{value:c,enumerable:!1,writable:!0,configurable:!0}});return m(c,a)};return i(a)}function j(){if(k()){j=Reflect.construct}else{j=function(b,c,d){var e=[null];e.push.apply(e,c);var a=Function.bind.apply(b,e),f=new a;if(d)m(f,d.prototype);return f}}return j.apply(null,arguments)}function k(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{Date.prototype.toString.call(Reflect.construct(Date,[],function(){}));return!0}catch(a){return!1}}function l(a){return-1!==Function.toString.call(a).indexOf("[native code]")}function m(a,b){m=Object.setPrototypeOf||function(a,b){a.__proto__=b;return a};return m(a,b)}function n(a){n=Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||Object.getPrototypeOf(a)};return n(a)}function o(a,b){return t(a)||s(a,b)||q(a,b)||p()}function p(){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 q(a,b){if(!a)return;if("string"==typeof a)return r(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 r(a,b)}function r(a,b){if(null==b||b>a.length)b=a.length;for(var c=0,d=Array(b);c.\n\n/**\n * Reactive simple state manager.\n *\n * The state manager contains the state data, trigger update events and\n * can lock and unlock the state data.\n *\n * This file contains the three main elements of the state manager:\n * - State manager: the public class to alter the state, dispatch events and process update messages.\n * - Proxy handler: a private class to keep track of the state object changes.\n * - StateMap class: a private class extending Map class that triggers event when a state list is modifed.\n *\n * @module core/local/reactive/stateManager\n * @class core/local/reactive/stateManager\n * @copyright 2021 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * State manager class.\n *\n * This class handle the reactive state and ensure only valid mutations can modify the state.\n * It also provide methods to apply batch state update messages (see processUpdates function doc\n * for more details on update messages).\n *\n * Implementing a deep state manager is complex and will require many frontend resources. To keep\n * the state fast and simple, the state can ONLY store two kind of data:\n * - Object with attributes\n * - Sets of objects with id attributes.\n *\n * This is an example of a valid state:\n *\n * {\n * course: {\n * name: 'course name',\n * shortname: 'courseshort',\n * sectionlist: [21, 34]\n * },\n * sections: [\n * {id: 21, name: 'Topic 1', visible: true},\n * {id: 34, name: 'Topic 2', visible: false,\n * ],\n * }\n *\n * The following cases are NOT allowed at a state ROOT level (throws an exception if they are assigned):\n * - Simple values (strings, boolean...).\n * - Arrays of simple values.\n * - Array of objects without ID attribute (all arrays will be converted to maps and requires an ID).\n *\n * Thanks to those limitations it can simplify the state update messages and the event names. If You\n * need to store simple data, just group them in an object.\n *\n * To grant any state change triggers the proper events, the class uses two private structures:\n * - proxy handler: any object stored in the state is proxied using this class.\n * - StateMap class: any object set in the state will be converted to StateMap using the\n * objects id attribute.\n */\nexport default class StateManager {\n\n /**\n * Create a basic reactive state store.\n *\n * The state manager is meant to work with native JS events. To ensure each reactive module can use\n * it in its own way, the parent element must provide a valid event dispatcher function and an optional\n * DOM element to anchor the event.\n *\n * @param {function} dispatchEvent the function to dispatch the custom event when the state changes.\n * @param {element} target the state changed custom event target (document if none provided)\n */\n constructor(dispatchEvent, target) {\n\n // The dispatch event function.\n /** @package */\n this.dispatchEvent = dispatchEvent;\n\n // The DOM container to trigger events.\n /** @package */\n this.target = target ?? document;\n\n // State can be altered freely until initial state is set.\n /** @package */\n this.readonly = false;\n\n // List of state changes pending to be published as events.\n /** @package */\n this.eventsToPublish = [];\n\n // The update state types functions.\n /** @package */\n this.updateTypes = {\n \"create\": this.defaultCreate.bind(this),\n \"update\": this.defaultUpdate.bind(this),\n \"delete\": this.defaultDelete.bind(this),\n };\n\n // The state_loaded event is special because it only happens one but all components\n // may react to that state, even if they are registered after the setIinitialState.\n // For these reason we use a promise for that event.\n this.initialPromise = new Promise((resolve) => {\n const initialStateDone = (event) => {\n resolve(event.detail.state);\n };\n this.target.addEventListener('state:loaded', initialStateDone);\n });\n }\n\n /**\n * Loads the initial state.\n *\n * Note this method will trigger a state changed event with \"state:loaded\" actionname.\n *\n * The state mode will be set to read only when the initial state is loaded.\n *\n * @param {object} initialState\n */\n setInitialState(initialState) {\n\n if (this.state !== undefined) {\n throw Error('Initial state can only be initialized ones');\n }\n\n // Create the state object.\n const state = new Proxy({}, new Handler('state', this, true));\n for (const [prop, propValue] of Object.entries(initialState)) {\n state[prop] = propValue;\n }\n this.state = state;\n\n // When the state is loaded we can lock it to prevent illegal changes.\n this.readonly = true;\n\n this.dispatchEvent({\n action: 'state:loaded',\n state: this.state,\n }, this.target);\n }\n\n /**\n * Generate a promise that will be resolved when the initial state is loaded.\n *\n * In most cases the final state will be loaded using an ajax call. This is the reason\n * why states manager are created unlocked and won't be reactive until the initial state is set.\n *\n * @return {Promise} the resulting promise\n */\n getInitialPromise() {\n return this.initialPromise;\n }\n\n /**\n * Locks or unlocks the state to prevent illegal updates.\n *\n * Mutations use this method to modify the state. Once the state is updated, they must\n * block again the state.\n *\n * All changes done while the state is writable will be registered using registerStateAction.\n * When the state is set again to read only the method will trigger _publishEvents to communicate\n * changes to all watchers.\n *\n * @param {bool} readonly if the state is in read only mode enabled\n */\n setReadOnly(readonly) {\n\n this.readonly = readonly;\n\n // When the state is in readonly again is time to publish all pending events.\n if (this.readonly) {\n this._publishEvents();\n }\n }\n\n /**\n * Add methods to process update state messages.\n *\n * The state manager provide a default update, create and delete methods. However,\n * some applications may require to override the default methods or even add new ones\n * like \"refresh\" or \"error\".\n *\n * @param {Object} newFunctions the new update types functions.\n */\n addUpdateTypes(newFunctions) {\n for (const [updateType, updateFunction] of Object.entries(newFunctions)) {\n if (typeof updateFunction === 'function') {\n this.updateTypes[updateType] = updateFunction.bind(newFunctions);\n }\n }\n }\n\n /**\n * Process a state updates array and do all the necessary changes.\n *\n * Note this method unlocks the state while it is executing and relocks it\n * when finishes.\n *\n * @param {array} updates\n * @param {Object} updateTypes optional functions to override the default update types.\n */\n processUpdates(updates, updateTypes) {\n if (!Array.isArray(updates)) {\n throw Error('State updates must be an array');\n }\n this.setReadOnly(false);\n updates.forEach((update) => {\n if (update.name === undefined) {\n throw Error('Missing state update name');\n }\n this.processUpdate(\n update.name,\n update.action,\n update.fields,\n updateTypes\n );\n });\n this.setReadOnly(true);\n }\n\n /**\n * Process a single state update.\n *\n * Note this method will not lock or unlock the state by itself.\n *\n * @param {string} updateName the state element to update\n * @param {string} action to action to perform\n * @param {object} fields the new data\n * @param {Object} updateTypes optional functions to override the default update types.\n */\n processUpdate(updateName, action, fields, updateTypes) {\n\n if (!fields) {\n throw Error('Missing state update fields');\n }\n\n if (updateTypes === undefined) {\n updateTypes = {};\n }\n\n action = action ?? 'update';\n\n const method = updateTypes[action] ?? this.updateTypes[action];\n\n if (method === undefined) {\n throw Error(`Unkown update action ${action}`);\n }\n\n method(this, updateName, fields);\n }\n\n /**\n * Process a create state message.\n *\n * @param {Object} stateManager the state manager\n * @param {String} updateName the state element to update\n * @param {Object} fields the new data\n */\n defaultCreate(stateManager, updateName, fields) {\n\n let state = stateManager.state;\n\n // Create can be applied only to lists, not to objects.\n if (state[updateName] instanceof StateMap) {\n state[updateName].add(fields);\n return;\n }\n state[updateName] = fields;\n }\n\n /**\n * Process a delete state message.\n *\n * @param {Object} stateManager the state manager\n * @param {String} updateName the state element to update\n * @param {Object} fields the new data\n */\n defaultDelete(stateManager, updateName, fields) {\n\n // Get the current value.\n let current = stateManager.get(updateName, fields.id);\n if (!current) {\n throw Error(`Inexistent ${updateName} ${fields.id}`);\n }\n\n // Process deletion.\n let state = stateManager.state;\n\n if (state[updateName] instanceof StateMap) {\n state[updateName].delete(fields.id);\n return;\n }\n delete state[updateName];\n }\n\n /**\n * Process a update state message.\n *\n * @param {Object} stateManager the state manager\n * @param {String} updateName the state element to update\n * @param {Object} fields the new data\n */\n defaultUpdate(stateManager, updateName, fields) {\n\n // Get the current value.\n let current = stateManager.get(updateName, fields.id);\n if (!current) {\n throw Error(`Inexistent ${updateName} ${fields.id}`);\n }\n\n // Execute updates.\n for (const [fieldName, fieldValue] of Object.entries(fields)) {\n current[fieldName] = fieldValue;\n }\n }\n\n /**\n * Get an element from the state or form an alternative state object.\n *\n * The altstate param is used by external update functions that gets the current\n * state as param.\n *\n * @param {String} name the state object name\n * @param {*} id and object id for state maps.\n * @return {Object|undefined} the state object found\n */\n get(name, id) {\n const state = this.state;\n\n let current = state[name];\n if (current instanceof StateMap) {\n if (id === undefined) {\n throw Error(`Missing id for ${name} state update`);\n }\n current = state[name].get(id);\n }\n\n return current;\n }\n\n /**\n * Register a state modification and generate the necessary events.\n *\n * This method is used mainly by proxy helpers to dispatch state change event.\n * However, mutations can use it to inform components about non reactive changes\n * in the state (only the two first levels of the state are reactive).\n *\n * Each action can produce several events:\n * - The specific attribute updated, created or deleter (example: \"cm.visible:updated\")\n * - The general state object updated, created or deleted (example: \"cm:updated\")\n * - If the element has an ID attribute, the specific event with id (example: \"cm[42].visible:updated\")\n * - If the element has an ID attribute, the general event with id (example: \"cm[42]:updated\")\n * - A generic state update event \"state:update\"\n *\n * @param {string} field the affected state field name\n * @param {string|null} prop the affecter field property (null if affect the full object)\n * @param {string} action the action done (created/updated/deleted)\n * @param {*} data the affected data\n */\n registerStateAction(field, prop, action, data) {\n\n let parentAction = 'updated';\n\n if (prop !== null) {\n this.eventsToPublish.push({\n eventName: `${field}.${prop}:${action}`,\n eventData: data,\n action,\n });\n } else {\n parentAction = action;\n }\n\n // Trigger extra events if the element has an ID attribute.\n if (data.id !== undefined) {\n if (prop !== null) {\n this.eventsToPublish.push({\n eventName: `${field}[${data.id}].${prop}:${action}`,\n eventData: data,\n action,\n });\n }\n this.eventsToPublish.push({\n eventName: `${field}[${data.id}]:${parentAction}`,\n eventData: data,\n action: parentAction,\n });\n }\n\n // Register the general change.\n this.eventsToPublish.push({\n eventName: `${field}:${parentAction}`,\n eventData: data,\n action: parentAction,\n });\n\n // Register state updated event.\n this.eventsToPublish.push({\n eventName: `state:updated`,\n eventData: data,\n action: 'updated',\n });\n }\n\n /**\n * Internal method to publish events.\n *\n * This is a private method, it will be invoked when the state is set back to read only mode.\n */\n _publishEvents() {\n const fieldChanges = this.eventsToPublish;\n this.eventsToPublish = [];\n\n // Dispatch a transaction start event.\n this.dispatchEvent({\n action: 'transaction:start',\n state: this.state,\n element: null,\n }, this.target);\n\n // State changes can be registered in any order. However it will avoid many\n // components errors if they are sorted to have creations-updates-deletes in case\n // some component needs to create or destroy DOM elements before updating them.\n fieldChanges.sort((a, b) => {\n const weights = {\n created: 0,\n updated: 1,\n deleted: 2,\n };\n const aweight = weights[a.action] ?? 0;\n const bweight = weights[b.action] ?? 0;\n // In case both have the same weight, the eventName length decide.\n if (aweight === bweight) {\n return a.eventName.length - b.eventName.length;\n }\n return aweight - bweight;\n });\n\n // List of the published events to prevent redundancies.\n let publishedEvents = new Set();\n\n fieldChanges.forEach((event) => {\n\n const eventkey = `${event.eventName}.${event.eventData.id ?? 0}`;\n\n if (!publishedEvents.has(eventkey)) {\n this.dispatchEvent({\n action: event.eventName,\n state: this.state,\n element: event.eventData\n }, this.target);\n\n publishedEvents.add(eventkey);\n }\n });\n\n // Dispatch a transaction end event.\n this.dispatchEvent({\n action: 'transaction:end',\n state: this.state,\n element: null,\n }, this.target);\n }\n}\n\n// Proxy helpers.\n\n/**\n * The proxy handler.\n *\n * This class will inform any value change directly to the state manager.\n *\n * The proxied variable will throw an error if it is altered when the state manager is\n * in read only mode.\n */\nclass Handler {\n\n /**\n * Class constructor.\n *\n * @param {string} name the variable name used for identify triggered actions\n * @param {StateManager} stateManager the state manager object\n * @param {boolean} proxyValues if new values must be proxied (used only at state root level)\n */\n constructor(name, stateManager, proxyValues) {\n this.name = name;\n this.stateManager = stateManager;\n this.proxyValues = proxyValues ?? false;\n }\n\n /**\n * Set trap to trigger events when the state changes.\n *\n * @param {object} obj the source object (not proxied)\n * @param {string} prop the attribute to set\n * @param {*} value the value to save\n * @param {*} receiver the proxied element to be attached to events\n * @returns {boolean} if the value is set\n */\n set(obj, prop, value, receiver) {\n\n // Only mutations should be able to set state values.\n if (this.stateManager.readonly) {\n throw new Error(`State locked. Use mutations to change ${prop} value in ${this.name}.`);\n }\n\n // Check any data change.\n if (JSON.stringify(obj[prop]) === JSON.stringify(value)) {\n return true;\n }\n\n const action = (obj[prop] !== undefined) ? 'updated' : 'created';\n\n // Proxy value if necessary (used at state root level).\n if (this.proxyValues) {\n if (Array.isArray(value)) {\n obj[prop] = new StateMap(prop, this.stateManager).loadValues(value);\n } else {\n obj[prop] = new Proxy(value, new Handler(prop, this.stateManager));\n }\n } else {\n obj[prop] = value;\n }\n\n // If the state is not ready yet means the initial state is not yet loaded.\n if (this.stateManager.state === undefined) {\n return true;\n }\n\n this.stateManager.registerStateAction(this.name, prop, action, receiver);\n\n return true;\n }\n\n /**\n * Delete property trap to trigger state change events.\n *\n * @param {*} obj the affected object (not proxied)\n * @param {*} prop the prop to delete\n * @returns {boolean} if prop is deleted\n */\n deleteProperty(obj, prop) {\n // Only mutations should be able to set state values.\n if (this.stateManager.readonly) {\n throw new Error(`State locked. Use mutations to delete ${prop} in ${this.name}.`);\n }\n if (prop in obj) {\n\n delete obj[prop];\n\n this.stateManager.registerStateAction(this.name, prop, 'deleted', obj);\n }\n return true;\n }\n}\n\n/**\n * Class to add events dispatching to the JS Map class.\n *\n * When the state has a list of objects (with IDs) it will be converted into a StateMap.\n * StateMap is used almost in the same way as a regular JS map. Because all elements have an\n * id attribute, it has some specific methods:\n * - add: a convenient method to add an element without specifying the key (\"id\" attribute will be used as a key).\n * - loadValues: to add many elements at once wihout specifying keys (\"id\" attribute will be used).\n *\n * Apart, the main difference between regular Map and MapState is that this one will inform any change to the\n * state manager.\n */\nclass StateMap extends Map {\n\n /**\n * Create a reactive Map.\n *\n * @param {string} name the property name\n * @param {StateManager} stateManager the state manager\n * @param {iterable} iterable an iterable object to create the Map\n */\n constructor(name, stateManager, iterable) {\n // We don't have any \"this\" until be call super.\n super(iterable);\n this.name = name;\n this.stateManager = stateManager;\n }\n\n /**\n * Set an element into the map.\n *\n * Each value needs it's own id attribute. Objects without id will be rejected.\n * The function will throw an error if the value id and the key are not the same.\n *\n * @param {*} key the key to store\n * @param {*} value the value to store\n * @returns {Map} the resulting Map object\n */\n set(key, value) {\n\n // Only mutations should be able to set state values.\n if (this.stateManager.readonly) {\n throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);\n }\n\n // Normalize keys as string to prevent json decoding errors.\n key = this.normalizeKey(key);\n\n this.checkValue(value);\n\n if (key === undefined || key === null) {\n throw Error('State lists keys cannot be null or undefined');\n }\n\n // ID is mandatory and should be the same as the key.\n if (this.normalizeKey(value.id) !== key) {\n throw new Error(`State error: ${this.name} list element ID (${value.id}) and key (${key}) mismatch`);\n }\n\n const action = (super.has(key)) ? 'updated' : 'created';\n\n // Save proxied data into the list.\n const result = super.set(key, new Proxy(value, new Handler(this.name, this.stateManager)));\n\n // If the state is not ready yet means the initial state is not yet loaded.\n if (this.stateManager.state === undefined) {\n return result;\n }\n\n this.stateManager.registerStateAction(this.name, null, action, super.get(key));\n\n return result;\n }\n\n /**\n * Check if a value is valid to be stored in a a State List.\n *\n * Only objects with id attribute can be stored in State lists.\n *\n * This method throws an error if the value is not valid.\n *\n * @param {object} value (with ID)\n */\n checkValue(value) {\n if (!typeof value === 'object' && value !== null) {\n throw Error('State lists can contain objects only');\n }\n\n if (value.id === undefined) {\n throw Error('State lists elements must contain at least an id attribute');\n }\n }\n\n /**\n * Return a normalized key value for state map.\n *\n * Regular maps uses strict key comparissons but state maps are indexed by ID.JSON conversions\n * and webservices sometimes do unexpected types conversions so we convert any integer key to string.\n *\n * @param {*} key the provided key\n * @returns {string}\n */\n normalizeKey(key) {\n return String(key).valueOf();\n }\n\n /**\n * Insert a new element int a list.\n *\n * Each value needs it's own id attribute. Objects withouts id will be rejected.\n *\n * @param {object} value the value to add (needs an id attribute)\n * @returns {Map} the resulting Map object\n */\n add(value) {\n this.checkValue(value);\n return this.set(value.id, value);\n }\n\n /**\n * Return a state map element.\n *\n * @param {*} key the element id\n * @return {Object}\n */\n get(key) {\n return super.get(this.normalizeKey(key));\n }\n\n /**\n * Check whether an element with the specified key exists or not.\n *\n * @param {*} key the key to find\n * @return {boolean}\n */\n has(key) {\n return super.has(this.normalizeKey(key));\n }\n\n /**\n * Delete an element from the map.\n *\n * @param {*} key\n * @returns {boolean}\n */\n delete(key) {\n // State maps uses only string keys to avoid strict comparisons.\n key = this.normalizeKey(key);\n\n // Only mutations should be able to set state values.\n if (this.stateManager.readonly) {\n throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);\n }\n\n const previous = super.get(key);\n\n const result = super.delete(key);\n if (!result) {\n return result;\n }\n\n this.stateManager.registerStateAction(this.name, null, 'deleted', previous);\n\n return result;\n }\n\n /**\n * Return a suitable structure for JSON conversion.\n *\n * This function is needed because new values are compared in JSON. StateMap has Private\n * attributes which cannot be stringified (like this.stateManager which will produce an\n * infinite recursivity).\n *\n * @returns {array}\n */\n toJSON() {\n let result = [];\n this.forEach((value) => {\n result.push(value);\n });\n return result;\n }\n\n /**\n * Insert a full list of values using the id attributes as keys.\n *\n * This method is used mainly to initialize the list. Note each element is indexed by its \"id\" attribute.\n * This is a basic restriction of StateMap. All elements need an id attribute, otherwise it won't be saved.\n *\n * @param {iterable} values the values to load\n * @returns {StateMap} return the this value\n */\n loadValues(values) {\n values.forEach((data) => {\n this.checkValue(data);\n let key = data.id;\n let newvalue = new Proxy(data, new Handler(this.name, this.stateManager));\n this.set(key, newvalue);\n });\n return this;\n }\n}\n"],"file":"statemanager.min.js"} \ No newline at end of file diff --git a/lib/amd/build/reactive.min.js b/lib/amd/build/reactive.min.js new file mode 100644 index 00000000000..3c8bf71f586 --- /dev/null +++ b/lib/amd/build/reactive.min.js @@ -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 diff --git a/lib/amd/build/reactive.min.js.map b/lib/amd/build/reactive.min.js.map new file mode 100644 index 00000000000..9444c6216ef --- /dev/null +++ b/lib/amd/build/reactive.min.js.map @@ -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 .\n\n/**\n * Generic reactive module used in the course editor.\n *\n * @module core/reactive\n * @copyright 2021 Ferran Recio \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"} \ No newline at end of file diff --git a/lib/amd/src/local/reactive/basecomponent.js b/lib/amd/src/local/reactive/basecomponent.js new file mode 100644 index 00000000000..583b79587cc --- /dev/null +++ b/lib/amd/src/local/reactive/basecomponent.js @@ -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 . + +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 + * @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}, + } + )); + } +} diff --git a/lib/amd/src/local/reactive/reactive.js b/lib/amd/src/local/reactive/reactive.js new file mode 100644 index 00000000000..fa481f7ece7 --- /dev/null +++ b/lib/amd/src/local/reactive/reactive.js @@ -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 . + +/** + * A generic single state reactive module. + * + * @module core/reactive/local/reactive/reactive + * @class core/reactive/local/reactive/reactive + * @copyright 2021 Ferran Recio + * @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; + } + } +} diff --git a/lib/amd/src/local/reactive/statemanager.js b/lib/amd/src/local/reactive/statemanager.js new file mode 100644 index 00000000000..00628b0380a --- /dev/null +++ b/lib/amd/src/local/reactive/statemanager.js @@ -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 . + +/** + * 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 + * @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; + } +} diff --git a/lib/amd/src/reactive.js b/lib/amd/src/reactive.js new file mode 100644 index 00000000000..ac621b8e841 --- /dev/null +++ b/lib/amd/src/reactive.js @@ -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 . + +/** + * Generic reactive module used in the course editor. + * + * @module core/reactive + * @copyright 2021 Ferran Recio + * @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};