lib/amd/src/local/reactive/reactive.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * A generic single state reactive module.
  17. *
  18. * @module core/local/reactive/reactive
  19. * @class core/local/reactive/reactive
  20. * @copyright 2021 Ferran Recio <ferran@moodle.com>
  21. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  22. */
  23. import log from 'core/log';
  24. import StateManager from 'core/local/reactive/statemanager';
  25. import Pending from 'core/pending';
  26. // Count the number of pending operations done to ensure we have a unique id for each one.
  27. let pendingCount = 0;
  28. /**
  29. * Set up general reactive class to create a single state application with components.
  30. *
  31. * The reactive class is used for registering new UI components and manage the access to the state values
  32. * and mutations.
  33. *
  34. * When a new reactive instance is created, it will contain an empty state and and empty mutations
  35. * lists. When the state data is ready, the initial state can be loaded using the "setInitialState"
  36. * method. This will protect the state from writing and will trigger all the components "stateReady"
  37. * methods.
  38. *
  39. * State can only be altered by mutations. To replace all the mutations with a specific class,
  40. * use "setMutations" method. If you need to just add some new mutation methods, use "addMutations".
  41. *
  42. * To register new components into a reactive instance, use "registerComponent".
  43. *
  44. * Inside a component, use "dispatch" to invoke a mutation on the state (components can only access
  45. * the state in read only mode).
  46. */
  47. export default class {
  48. /**
  49. * The component descriptor data structure.
  50. *
  51. * @typedef {object} description
  52. * @property {string} eventName the custom event name used for state changed events
  53. * @property {Function} eventDispatch the state update event dispatch function
  54. * @property {Element} [target] the target of the event dispatch. If not passed a fake element will be created
  55. * @property {Object} [mutations] an object with state mutations functions
  56. * @property {Object} [state] an object to initialize the state.
  57. */
  58. /**
  59. * Create a basic reactive manager.
  60. *
  61. * Note that if your state is not async loaded, you can pass directly on creation by using the
  62. * description.state attribute. However, this will initialize the state, this means
  63. * setInitialState will throw an exception because the state is already defined.
  64. *
  65. * @param {description} description reactive manager description.
  66. */
  67. constructor(description) {
  68. if (description.eventName === undefined || description.eventDispatch === undefined) {
  69. throw new Error(`Reactivity event required`);
  70. }
  71. if (description.name !== undefined) {
  72. this.name = description.name;
  73. }
  74. // Each reactive instance has its own element anchor to propagate state changes internally.
  75. // By default the module will create a fake DOM element to target custom events but
  76. // if all reactive components is constrait to a single element, this can be passed as
  77. // target in the description.
  78. this.target = description.target ?? document.createTextNode(null);
  79. this.eventName = description.eventName;
  80. this.eventDispatch = description.eventDispatch;
  81. // State manager is responsible for dispatch state change events when a mutation happens.
  82. this.stateManager = new StateManager(this.eventDispatch, this.target);
  83. // An internal registry of watchers and components.
  84. this.watchers = new Map([]);
  85. this.components = new Set([]);
  86. // Mutations can be overridden later using setMutations method.
  87. this.mutations = description.mutations ?? {};
  88. // Register the event to alert watchers when specific state change happens.
  89. this.target.addEventListener(this.eventName, this.callWatchersHandler.bind(this));
  90. // Add a pending operation waiting for the initial state.
  91. this.pendingState = new Pending(`core/reactive:registerInstance${pendingCount++}`);
  92. // Set initial state if we already have it.
  93. if (description.state !== undefined) {
  94. this.setInitialState(description.state);
  95. }
  96. // Check if we have a debug instance to register the instance.
  97. if (M.reactive !== undefined) {
  98. M.reactive.registerNewInstance(this);
  99. }
  100. }
  101. /**
  102. * State changed listener.
  103. *
  104. * This function take any state change and send it to the proper watchers.
  105. *
  106. * To prevent internal state changes from colliding with other reactive instances, only the
  107. * general "state changed" is triggered at document level. All the internal changes are
  108. * triggered at private target level without bubbling. This way any reactive instance can alert
  109. * only its own watchers.
  110. *
  111. * @param {CustomEvent} event
  112. */
  113. callWatchersHandler(event) {
  114. // Execute any registered component watchers.
  115. this.target.dispatchEvent(new CustomEvent(event.detail.action, {
  116. bubbles: false,
  117. detail: event.detail,
  118. }));
  119. }
  120. /**
  121. * Set the initial state.
  122. *
  123. * @param {object} stateData the initial state data.
  124. */
  125. setInitialState(stateData) {
  126. this.pendingState.resolve();
  127. this.stateManager.setInitialState(stateData);
  128. }
  129. /**
  130. * Add individual functions to the mutations.
  131. *
  132. * Note new mutations will be added to the existing ones. To replace the full mutation
  133. * object with a new one, use setMutations method.
  134. *
  135. * @method addMutations
  136. * @param {Object} newFunctions an object with new mutation functions.
  137. */
  138. addMutations(newFunctions) {
  139. // Mutations can provide an init method to do some setup in the statemanager.
  140. if (newFunctions.init !== undefined) {
  141. newFunctions.init(this.stateManager);
  142. }
  143. // Save all mutations.
  144. for (const [mutation, mutationFunction] of Object.entries(newFunctions)) {
  145. this.mutations[mutation] = mutationFunction.bind(newFunctions);
  146. }
  147. }
  148. /**
  149. * Replace the current mutations with a new object.
  150. *
  151. * This method is designed to override the full mutations class, for example by extending
  152. * the original one. To add some individual mutations, use addMutations instead.
  153. *
  154. * @param {object} manager the new mutations intance
  155. */
  156. setMutations(manager) {
  157. this.mutations = manager;
  158. // Mutations can provide an init method to do some setup in the statemanager.
  159. if (manager.init !== undefined) {
  160. manager.init(this.stateManager);
  161. }
  162. }
  163. /**
  164. * Return the current state.
  165. *
  166. * @return {object}
  167. */
  168. get state() {
  169. return this.stateManager.state;
  170. }
  171. /**
  172. * Get state data.
  173. *
  174. * Components access the state frequently. This convenience method is a shortcut to
  175. * this.reactive.state.stateManager.get() method.
  176. *
  177. * @param {String} name the state object name
  178. * @param {*} id an optional object id for state maps.
  179. * @return {Object|undefined} the state object found
  180. */
  181. get(name, id) {
  182. return this.stateManager.get(name, id);
  183. }
  184. /**
  185. * Return the initial state promise.
  186. *
  187. * Typically, components do not require to use this promise because registerComponent
  188. * will trigger their stateReady method automatically. But it could be useful for complex
  189. * components that require to combine state, template and string loadings.
  190. *
  191. * @method getState
  192. * @return {Promise}
  193. */
  194. getInitialStatePromise() {
  195. return this.stateManager.getInitialPromise();
  196. }
  197. /**
  198. * Register a new component.
  199. *
  200. * Component can provide some optional functions to the reactive module:
  201. * - getWatchers: returns an array of watchers
  202. * - stateReady: a method to call when the initial state is loaded
  203. *
  204. * It can also provide some optional attributes:
  205. * - name: the component name (default value: "Unkown component") to customize debug messages.
  206. *
  207. * The method will also use dispatchRegistrationSuccess and dispatchRegistrationFail. Those
  208. * are BaseComponent methods to inform parent components of the registration status.
  209. * Components should not override those methods.
  210. *
  211. * @method registerComponent
  212. * @param {object} component the new component
  213. * @param {string} [component.name] the component name to display in warnings and errors.
  214. * @param {Function} [component.dispatchRegistrationSuccess] method to notify registration success
  215. * @param {Function} [component.dispatchRegistrationFail] method to notify registration fail
  216. * @param {Function} [component.getWatchers] getter of the component watchers
  217. * @param {Function} [component.stateReady] method to call when the state is ready
  218. * @return {object} the registered component
  219. */
  220. registerComponent(component) {
  221. // Component name is an optional attribute to customize debug messages.
  222. const componentName = component.name ?? 'Unkown component';
  223. // Components can provide special methods to communicate registration to parent components.
  224. let dispatchSuccess = () => {
  225. return;
  226. };
  227. let dispatchFail = dispatchSuccess;
  228. if (component.dispatchRegistrationSuccess !== undefined) {
  229. dispatchSuccess = component.dispatchRegistrationSuccess.bind(component);
  230. }
  231. if (component.dispatchRegistrationFail !== undefined) {
  232. dispatchFail = component.dispatchRegistrationFail.bind(component);
  233. }
  234. // Components can be registered only one time.
  235. if (this.components.has(component)) {
  236. dispatchSuccess();
  237. return component;
  238. }
  239. // Components are fully registered only when the state ready promise is resolved.
  240. const pendingPromise = new Pending(`core/reactive:registerComponent${pendingCount++}`);
  241. // Keep track of the event listeners.
  242. let listeners = [];
  243. // Register watchers.
  244. let handlers = [];
  245. if (component.getWatchers !== undefined) {
  246. handlers = component.getWatchers();
  247. }
  248. handlers.forEach(({watch, handler}) => {
  249. if (watch === undefined) {
  250. dispatchFail();
  251. throw new Error(`Missing watch attribute in ${componentName} watcher`);
  252. }
  253. if (handler === undefined) {
  254. dispatchFail();
  255. throw new Error(`Missing handler for watcher ${watch} in ${componentName}`);
  256. }
  257. const listener = (event) => {
  258. // Prevent any watcher from losing the page focus.
  259. const currentFocus = document.activeElement;
  260. // Execute watcher.
  261. handler.apply(component, [event.detail]);
  262. // Restore focus in case it is lost.
  263. if (document.activeElement === document.body && document.body.contains(currentFocus)) {
  264. currentFocus.focus();
  265. }
  266. };
  267. // Save the listener information in case the component must be unregistered later.
  268. listeners.push({target: this.target, watch, listener});
  269. // The state manager triggers a general "state changed" event at a document level. However,
  270. // for the internal watchers, each component can listen to specific state changed custom events
  271. // in the target element. This way we can use the native event loop without colliding with other
  272. // reactive instances.
  273. this.target.addEventListener(watch, listener);
  274. });
  275. // Register state ready function. There's the possibility a component is registered after the initial state
  276. // is loaded. For those cases we have a state promise to handle this specific state change.
  277. if (component.stateReady !== undefined) {
  278. this.getInitialStatePromise()
  279. .then(state => {
  280. component.stateReady(state);
  281. pendingPromise.resolve();
  282. return true;
  283. })
  284. .catch(reason => {
  285. pendingPromise.resolve();
  286. log.error(`Initial state in ${componentName} rejected due to: ${reason}`);
  287. log.error(reason);
  288. });
  289. }
  290. // Save unregister data.
  291. this.watchers.set(component, listeners);
  292. this.components.add(component);
  293. // Dispatch an event to communicate the registration to the debug module.
  294. this.target.dispatchEvent(new CustomEvent('registerComponent:success', {
  295. bubbles: false,
  296. detail: {component},
  297. }));
  298. dispatchSuccess();
  299. return component;
  300. }
  301. /**
  302. * Unregister a component and its watchers.
  303. *
  304. * @param {object} component the object instance to unregister
  305. * @returns {object} the deleted component
  306. */
  307. unregisterComponent(component) {
  308. if (!this.components.has(component)) {
  309. return component;
  310. }
  311. this.components.delete(component);
  312. // Remove event listeners.
  313. const listeners = this.watchers.get(component);
  314. if (listeners === undefined) {
  315. return component;
  316. }
  317. listeners.forEach(({target, watch, listener}) => {
  318. target.removeEventListener(watch, listener);
  319. });
  320. this.watchers.delete(component);
  321. return component;
  322. }
  323. /**
  324. * Dispatch a change in the state.
  325. *
  326. * This method is the only way for components to alter the state. Watchers will receive a
  327. * read only state to prevent illegal changes. If some user action require a state change, the
  328. * component should dispatch a mutation to trigger all the necessary logic to alter the state.
  329. *
  330. * @method dispatch
  331. * @param {string} actionName the action name (usually the mutation name)
  332. * @param {mixed} params any number of params the mutation needs.
  333. */
  334. async dispatch(actionName, ...params) {
  335. if (typeof actionName !== 'string') {
  336. throw new Error(`Dispatch action name must be a string`);
  337. }
  338. // JS does not have private methods yet. However, we prevent any component from calling
  339. // a method starting with "_" because the most accepted convention for private methods.
  340. if (actionName.charAt(0) === '_') {
  341. throw new Error(`Illegal Private ${actionName} mutation method dispatch`);
  342. }
  343. if (this.mutations[actionName] === undefined) {
  344. throw new Error(`Unkown ${actionName} mutation`);
  345. }
  346. const pendingPromise = new Pending(`core/reactive:${actionName}${pendingCount++}`);
  347. const mutationFunction = this.mutations[actionName];
  348. try {
  349. await mutationFunction.apply(this.mutations, [this.stateManager, ...params]);
  350. pendingPromise.resolve();
  351. } catch (error) {
  352. // Ensure the state is locked.
  353. this.stateManager.setReadOnly(true);
  354. pendingPromise.resolve();
  355. throw error;
  356. }
  357. }
  358. }