lib/amd/src/local/reactive/statemanager.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. * Reactive simple state manager.
  17. *
  18. * The state manager contains the state data, trigger update events and
  19. * can lock and unlock the state data.
  20. *
  21. * This file contains the three main elements of the state manager:
  22. * - State manager: the public class to alter the state, dispatch events and process update messages.
  23. * - Proxy handler: a private class to keep track of the state object changes.
  24. * - StateMap class: a private class extending Map class that triggers event when a state list is modifed.
  25. *
  26. * @module core/local/reactive/statemanager
  27. * @class StateManager
  28. * @copyright 2021 Ferran Recio <ferran@moodle.com>
  29. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  30. */
  31. import Logger from 'core/local/reactive/logger';
  32. /**
  33. * State manager class.
  34. *
  35. * This class handle the reactive state and ensure only valid mutations can modify the state.
  36. * It also provide methods to apply batch state update messages (see processUpdates function doc
  37. * for more details on update messages).
  38. *
  39. * Implementing a deep state manager is complex and will require many frontend resources. To keep
  40. * the state fast and simple, the state can ONLY store two kind of data:
  41. * - Object with attributes
  42. * - Sets of objects with id attributes.
  43. *
  44. * This is an example of a valid state:
  45. *
  46. * {
  47. * course: {
  48. * name: 'course name',
  49. * shortname: 'courseshort',
  50. * sectionlist: [21, 34]
  51. * },
  52. * sections: [
  53. * {id: 21, name: 'Topic 1', visible: true},
  54. * {id: 34, name: 'Topic 2', visible: false,
  55. * ],
  56. * }
  57. *
  58. * The following cases are NOT allowed at a state ROOT level (throws an exception if they are assigned):
  59. * - Simple values (strings, boolean...).
  60. * - Arrays of simple values.
  61. * - Array of objects without ID attribute (all arrays will be converted to maps and requires an ID).
  62. *
  63. * Thanks to those limitations it can simplify the state update messages and the event names. If You
  64. * need to store simple data, just group them in an object.
  65. *
  66. * To grant any state change triggers the proper events, the class uses two private structures:
  67. * - proxy handler: any object stored in the state is proxied using this class.
  68. * - StateMap class: any object set in the state will be converted to StateMap using the
  69. * objects id attribute.
  70. */
  71. export default class StateManager {
  72. /**
  73. * Create a basic reactive state store.
  74. *
  75. * The state manager is meant to work with native JS events. To ensure each reactive module can use
  76. * it in its own way, the parent element must provide a valid event dispatcher function and an optional
  77. * DOM element to anchor the event.
  78. *
  79. * @param {function} dispatchEvent the function to dispatch the custom event when the state changes.
  80. * @param {element} target the state changed custom event target (document if none provided)
  81. */
  82. constructor(dispatchEvent, target) {
  83. // The dispatch event function.
  84. /** @package */
  85. this.dispatchEvent = dispatchEvent;
  86. // The DOM container to trigger events.
  87. /** @package */
  88. this.target = target ?? document;
  89. // State can be altered freely until initial state is set.
  90. /** @package */
  91. this.readonly = false;
  92. // List of state changes pending to be published as events.
  93. /** @package */
  94. this.eventsToPublish = [];
  95. // The update state types functions.
  96. /** @package */
  97. this.updateTypes = {
  98. "create": this.defaultCreate.bind(this),
  99. "update": this.defaultUpdate.bind(this),
  100. "delete": this.defaultDelete.bind(this),
  101. "put": this.defaultPut.bind(this),
  102. "override": this.defaultOverride.bind(this),
  103. "remove": this.defaultRemove.bind(this),
  104. "prepareFields": this.defaultPrepareFields.bind(this),
  105. };
  106. // The state_loaded event is special because it only happens one but all components
  107. // may react to that state, even if they are registered after the setIinitialState.
  108. // For these reason we use a promise for that event.
  109. this.initialPromise = new Promise((resolve) => {
  110. const initialStateDone = (event) => {
  111. resolve(event.detail.state);
  112. };
  113. this.target.addEventListener('state:loaded', initialStateDone);
  114. });
  115. this.logger = new Logger();
  116. }
  117. /**
  118. * Loads the initial state.
  119. *
  120. * Note this method will trigger a state changed event with "state:loaded" actionname.
  121. *
  122. * The state mode will be set to read only when the initial state is loaded.
  123. *
  124. * @param {object} initialState
  125. */
  126. setInitialState(initialState) {
  127. if (this.state !== undefined) {
  128. throw Error('Initial state can only be initialized ones');
  129. }
  130. // Create the state object.
  131. const state = new Proxy({}, new Handler('state', this, true));
  132. for (const [prop, propValue] of Object.entries(initialState)) {
  133. state[prop] = propValue;
  134. }
  135. this.state = state;
  136. // When the state is loaded we can lock it to prevent illegal changes.
  137. this.readonly = true;
  138. this.dispatchEvent({
  139. action: 'state:loaded',
  140. state: this.state,
  141. }, this.target);
  142. }
  143. /**
  144. * Generate a promise that will be resolved when the initial state is loaded.
  145. *
  146. * In most cases the final state will be loaded using an ajax call. This is the reason
  147. * why states manager are created unlocked and won't be reactive until the initial state is set.
  148. *
  149. * @return {Promise} the resulting promise
  150. */
  151. getInitialPromise() {
  152. return this.initialPromise;
  153. }
  154. /**
  155. * Locks or unlocks the state to prevent illegal updates.
  156. *
  157. * Mutations use this method to modify the state. Once the state is updated, they must
  158. * block again the state.
  159. *
  160. * All changes done while the state is writable will be registered using registerStateAction.
  161. * When the state is set again to read only the method will trigger _publishEvents to communicate
  162. * changes to all watchers.
  163. *
  164. * @param {bool} readonly if the state is in read only mode enabled
  165. */
  166. setReadOnly(readonly) {
  167. this.readonly = readonly;
  168. let mode = 'off';
  169. // When the state is in readonly again is time to publish all pending events.
  170. if (this.readonly) {
  171. mode = 'on';
  172. this._publishEvents();
  173. }
  174. // Dispatch a read only event.
  175. this.dispatchEvent({
  176. action: `readmode:${mode}`,
  177. state: this.state,
  178. element: null,
  179. }, this.target);
  180. }
  181. /**
  182. * Add methods to process update state messages.
  183. *
  184. * The state manager provide a default update, create and delete methods. However,
  185. * some applications may require to override the default methods or even add new ones
  186. * like "refresh" or "error".
  187. *
  188. * @param {Object} newFunctions the new update types functions.
  189. */
  190. addUpdateTypes(newFunctions) {
  191. for (const [updateType, updateFunction] of Object.entries(newFunctions)) {
  192. if (typeof updateFunction === 'function') {
  193. this.updateTypes[updateType] = updateFunction.bind(newFunctions);
  194. }
  195. }
  196. }
  197. /**
  198. * Process a state updates array and do all the necessary changes.
  199. *
  200. * Note this method unlocks the state while it is executing and relocks it
  201. * when finishes.
  202. *
  203. * @param {array} updates
  204. * @param {Object} updateTypes optional functions to override the default update types.
  205. */
  206. processUpdates(updates, updateTypes) {
  207. if (!Array.isArray(updates)) {
  208. throw Error('State updates must be an array');
  209. }
  210. this.setReadOnly(false);
  211. updates.forEach((update) => {
  212. if (update.name === undefined) {
  213. throw Error('Missing state update name');
  214. }
  215. this.processUpdate(
  216. update.name,
  217. update.action,
  218. update.fields,
  219. updateTypes
  220. );
  221. });
  222. this.setReadOnly(true);
  223. }
  224. /**
  225. * Process a single state update.
  226. *
  227. * Note this method will not lock or unlock the state by itself.
  228. *
  229. * @param {string} updateName the state element to update
  230. * @param {string} action to action to perform
  231. * @param {object} fields the new data
  232. * @param {Object} updateTypes optional functions to override the default update types.
  233. */
  234. processUpdate(updateName, action, fields, updateTypes) {
  235. if (!fields) {
  236. throw Error('Missing state update fields');
  237. }
  238. if (updateTypes === undefined) {
  239. updateTypes = {};
  240. }
  241. action = action ?? 'update';
  242. const method = updateTypes[action] ?? this.updateTypes[action];
  243. if (method === undefined) {
  244. throw Error(`Unkown update action ${action}`);
  245. }
  246. // Some state data may require some cooking before sending to the
  247. // state. Reactive instances can overrdide the default fieldDefaults
  248. // method to add extra logic to all updates.
  249. const prepareFields = updateTypes.prepareFields ?? this.updateTypes.prepareFields;
  250. method(this, updateName, prepareFields(this, updateName, fields));
  251. }
  252. /**
  253. * Prepare fields for processing.
  254. *
  255. * This method is used to add default values or calculations from the frontend side.
  256. *
  257. * @param {Object} stateManager the state manager
  258. * @param {String} updateName the state element to update
  259. * @param {Object} fields the new data
  260. * @returns {Object} final fields data
  261. */
  262. defaultPrepareFields(stateManager, updateName, fields) {
  263. return fields;
  264. }
  265. /**
  266. * Process a create state message.
  267. *
  268. * @param {Object} stateManager the state manager
  269. * @param {String} updateName the state element to update
  270. * @param {Object} fields the new data
  271. */
  272. defaultCreate(stateManager, updateName, fields) {
  273. let state = stateManager.state;
  274. // Create can be applied only to lists, not to objects.
  275. if (state[updateName] instanceof StateMap) {
  276. state[updateName].add(fields);
  277. return;
  278. }
  279. state[updateName] = fields;
  280. }
  281. /**
  282. * Process a delete state message.
  283. *
  284. * @param {Object} stateManager the state manager
  285. * @param {String} updateName the state element to update
  286. * @param {Object} fields the new data
  287. */
  288. defaultDelete(stateManager, updateName, fields) {
  289. // Get the current value.
  290. let current = stateManager.get(updateName, fields.id);
  291. if (!current) {
  292. throw Error(`Inexistent ${updateName} ${fields.id}`);
  293. }
  294. // Process deletion.
  295. let state = stateManager.state;
  296. if (state[updateName] instanceof StateMap) {
  297. state[updateName].delete(fields.id);
  298. return;
  299. }
  300. delete state[updateName];
  301. }
  302. /**
  303. * Process a remove state message.
  304. *
  305. * @param {Object} stateManager the state manager
  306. * @param {String} updateName the state element to update
  307. * @param {Object} fields the new data
  308. */
  309. defaultRemove(stateManager, updateName, fields) {
  310. // Get the current value.
  311. let current = stateManager.get(updateName, fields.id);
  312. if (!current) {
  313. return;
  314. }
  315. // Process deletion.
  316. let state = stateManager.state;
  317. if (state[updateName] instanceof StateMap) {
  318. state[updateName].delete(fields.id);
  319. return;
  320. }
  321. delete state[updateName];
  322. }
  323. /**
  324. * Process a update state message.
  325. *
  326. * @param {Object} stateManager the state manager
  327. * @param {String} updateName the state element to update
  328. * @param {Object} fields the new data
  329. */
  330. defaultUpdate(stateManager, updateName, fields) {
  331. // Get the current value.
  332. let current = stateManager.get(updateName, fields.id);
  333. if (!current) {
  334. throw Error(`Inexistent ${updateName} ${fields.id}`);
  335. }
  336. // Execute updates.
  337. for (const [fieldName, fieldValue] of Object.entries(fields)) {
  338. current[fieldName] = fieldValue;
  339. }
  340. }
  341. /**
  342. * Process a put state message.
  343. *
  344. * @param {Object} stateManager the state manager
  345. * @param {String} updateName the state element to update
  346. * @param {Object} fields the new data
  347. */
  348. defaultPut(stateManager, updateName, fields) {
  349. // Get the current value.
  350. let current = stateManager.get(updateName, fields.id);
  351. if (current) {
  352. // Update attributes.
  353. for (const [fieldName, fieldValue] of Object.entries(fields)) {
  354. current[fieldName] = fieldValue;
  355. }
  356. } else {
  357. // Create new object.
  358. let state = stateManager.state;
  359. if (state[updateName] instanceof StateMap) {
  360. state[updateName].add(fields);
  361. return;
  362. }
  363. state[updateName] = fields;
  364. }
  365. }
  366. /**
  367. * Process an override state message.
  368. *
  369. * @param {Object} stateManager the state manager
  370. * @param {String} updateName the state element to update
  371. * @param {Object} fields the new data
  372. */
  373. defaultOverride(stateManager, updateName, fields) {
  374. // Get the current value.
  375. let current = stateManager.get(updateName, fields.id);
  376. if (current) {
  377. // Remove any unnecessary fields.
  378. for (const [fieldName] of Object.entries(current)) {
  379. if (fields[fieldName] === undefined) {
  380. delete current[fieldName];
  381. }
  382. }
  383. // Update field.
  384. for (const [fieldName, fieldValue] of Object.entries(fields)) {
  385. current[fieldName] = fieldValue;
  386. }
  387. } else {
  388. // Create the element if not exists.
  389. let state = stateManager.state;
  390. if (state[updateName] instanceof StateMap) {
  391. state[updateName].add(fields);
  392. return;
  393. }
  394. state[updateName] = fields;
  395. }
  396. }
  397. /**
  398. * Set the logger class instance.
  399. *
  400. * Reactive instances can provide alternative loggers to provide advanced logging.
  401. * @param {Logger} logger
  402. */
  403. setLogger(logger) {
  404. this.logger = logger;
  405. }
  406. /**
  407. * Add a new log entry into the reactive logger.
  408. * @param {LoggerEntry} entry
  409. */
  410. addLoggerEntry(entry) {
  411. this.logger.add(entry);
  412. }
  413. /**
  414. * Get an element from the state or form an alternative state object.
  415. *
  416. * The altstate param is used by external update functions that gets the current
  417. * state as param.
  418. *
  419. * @param {String} name the state object name
  420. * @param {*} id and object id for state maps.
  421. * @return {Object|undefined} the state object found
  422. */
  423. get(name, id) {
  424. const state = this.state;
  425. let current = state[name];
  426. if (current instanceof StateMap) {
  427. if (id === undefined) {
  428. throw Error(`Missing id for ${name} state update`);
  429. }
  430. current = state[name].get(id);
  431. }
  432. return current;
  433. }
  434. /**
  435. * Get all element ids from the given state.
  436. *
  437. * @param {String} name the state object name
  438. * @return {Array} the element ids.
  439. */
  440. getIds(name) {
  441. const state = this.state;
  442. const current = state[name];
  443. if (!(current instanceof StateMap)) {
  444. throw Error(`${name} is not an instance of StateMap`);
  445. }
  446. return [...state[name].keys()];
  447. }
  448. /**
  449. * Register a state modification and generate the necessary events.
  450. *
  451. * This method is used mainly by proxy helpers to dispatch state change event.
  452. * However, mutations can use it to inform components about non reactive changes
  453. * in the state (only the two first levels of the state are reactive).
  454. *
  455. * Each action can produce several events:
  456. * - The specific attribute updated, created or deleter (example: "cm.visible:updated")
  457. * - The general state object updated, created or deleted (example: "cm:updated")
  458. * - If the element has an ID attribute, the specific event with id (example: "cm[42].visible:updated")
  459. * - If the element has an ID attribute, the general event with id (example: "cm[42]:updated")
  460. * - A generic state update event "state:update"
  461. *
  462. * @param {string} field the affected state field name
  463. * @param {string|null} prop the affecter field property (null if affect the full object)
  464. * @param {string} action the action done (created/updated/deleted)
  465. * @param {*} data the affected data
  466. */
  467. registerStateAction(field, prop, action, data) {
  468. let parentAction = 'updated';
  469. if (prop !== null) {
  470. this.eventsToPublish.push({
  471. eventName: `${field}.${prop}:${action}`,
  472. eventData: data,
  473. action,
  474. });
  475. } else {
  476. parentAction = action;
  477. }
  478. // Trigger extra events if the element has an ID attribute.
  479. if (data.id !== undefined) {
  480. if (prop !== null) {
  481. this.eventsToPublish.push({
  482. eventName: `${field}[${data.id}].${prop}:${action}`,
  483. eventData: data,
  484. action,
  485. });
  486. }
  487. this.eventsToPublish.push({
  488. eventName: `${field}[${data.id}]:${parentAction}`,
  489. eventData: data,
  490. action: parentAction,
  491. });
  492. }
  493. // Register the general change.
  494. this.eventsToPublish.push({
  495. eventName: `${field}:${parentAction}`,
  496. eventData: data,
  497. action: parentAction,
  498. });
  499. // Register state updated event.
  500. this.eventsToPublish.push({
  501. eventName: `state:updated`,
  502. eventData: data,
  503. action: 'updated',
  504. });
  505. }
  506. /**
  507. * Internal method to publish events.
  508. *
  509. * This is a private method, it will be invoked when the state is set back to read only mode.
  510. */
  511. _publishEvents() {
  512. const fieldChanges = this.eventsToPublish;
  513. this.eventsToPublish = [];
  514. // Dispatch a transaction start event.
  515. this.dispatchEvent({
  516. action: 'transaction:start',
  517. state: this.state,
  518. element: null,
  519. changes: fieldChanges,
  520. }, this.target);
  521. // State changes can be registered in any order. However it will avoid many
  522. // components errors if they are sorted to have creations-updates-deletes in case
  523. // some component needs to create or destroy DOM elements before updating them.
  524. fieldChanges.sort((a, b) => {
  525. const weights = {
  526. created: 0,
  527. updated: 1,
  528. deleted: 2,
  529. };
  530. const aweight = weights[a.action] ?? 0;
  531. const bweight = weights[b.action] ?? 0;
  532. // In case both have the same weight, the eventName length decide.
  533. if (aweight === bweight) {
  534. return a.eventName.length - b.eventName.length;
  535. }
  536. return aweight - bweight;
  537. });
  538. // List of the published events to prevent redundancies.
  539. let publishedEvents = new Set();
  540. let transactionEvents = [];
  541. fieldChanges.forEach((event) => {
  542. const eventkey = `${event.eventName}.${event.eventData.id ?? 0}`;
  543. if (!publishedEvents.has(eventkey)) {
  544. this.dispatchEvent({
  545. action: event.eventName,
  546. state: this.state,
  547. element: event.eventData
  548. }, this.target);
  549. publishedEvents.add(eventkey);
  550. transactionEvents.push(event);
  551. }
  552. });
  553. // Dispatch a transaction end event.
  554. this.dispatchEvent({
  555. action: 'transaction:end',
  556. state: this.state,
  557. element: null,
  558. changes: transactionEvents,
  559. }, this.target);
  560. }
  561. }
  562. // Proxy helpers.
  563. /**
  564. * The proxy handler.
  565. *
  566. * This class will inform any value change directly to the state manager.
  567. *
  568. * The proxied variable will throw an error if it is altered when the state manager is
  569. * in read only mode.
  570. */
  571. class Handler {
  572. /**
  573. * Class constructor.
  574. *
  575. * @param {string} name the variable name used for identify triggered actions
  576. * @param {StateManager} stateManager the state manager object
  577. * @param {boolean} proxyValues if new values must be proxied (used only at state root level)
  578. */
  579. constructor(name, stateManager, proxyValues) {
  580. this.name = name;
  581. this.stateManager = stateManager;
  582. this.proxyValues = proxyValues ?? false;
  583. }
  584. /**
  585. * Set trap to trigger events when the state changes.
  586. *
  587. * @param {object} obj the source object (not proxied)
  588. * @param {string} prop the attribute to set
  589. * @param {*} value the value to save
  590. * @param {*} receiver the proxied element to be attached to events
  591. * @returns {boolean} if the value is set
  592. */
  593. set(obj, prop, value, receiver) {
  594. // Only mutations should be able to set state values.
  595. if (this.stateManager.readonly) {
  596. throw new Error(`State locked. Use mutations to change ${prop} value in ${this.name}.`);
  597. }
  598. // Check any data change.
  599. if (JSON.stringify(obj[prop]) === JSON.stringify(value)) {
  600. return true;
  601. }
  602. const action = (obj[prop] !== undefined) ? 'updated' : 'created';
  603. // Proxy value if necessary (used at state root level).
  604. if (this.proxyValues) {
  605. if (Array.isArray(value)) {
  606. obj[prop] = new StateMap(prop, this.stateManager).loadValues(value);
  607. } else {
  608. obj[prop] = new Proxy(value, new Handler(prop, this.stateManager));
  609. }
  610. } else {
  611. obj[prop] = value;
  612. }
  613. // If the state is not ready yet means the initial state is not yet loaded.
  614. if (this.stateManager.state === undefined) {
  615. return true;
  616. }
  617. this.stateManager.registerStateAction(this.name, prop, action, receiver);
  618. return true;
  619. }
  620. /**
  621. * Delete property trap to trigger state change events.
  622. *
  623. * @param {*} obj the affected object (not proxied)
  624. * @param {*} prop the prop to delete
  625. * @returns {boolean} if prop is deleted
  626. */
  627. deleteProperty(obj, prop) {
  628. // Only mutations should be able to set state values.
  629. if (this.stateManager.readonly) {
  630. throw new Error(`State locked. Use mutations to delete ${prop} in ${this.name}.`);
  631. }
  632. if (prop in obj) {
  633. delete obj[prop];
  634. this.stateManager.registerStateAction(this.name, prop, 'deleted', obj);
  635. }
  636. return true;
  637. }
  638. }
  639. /**
  640. * Class to add events dispatching to the JS Map class.
  641. *
  642. * When the state has a list of objects (with IDs) it will be converted into a StateMap.
  643. * StateMap is used almost in the same way as a regular JS map. Because all elements have an
  644. * id attribute, it has some specific methods:
  645. * - add: a convenient method to add an element without specifying the key ("id" attribute will be used as a key).
  646. * - loadValues: to add many elements at once wihout specifying keys ("id" attribute will be used).
  647. *
  648. * Apart, the main difference between regular Map and MapState is that this one will inform any change to the
  649. * state manager.
  650. */
  651. class StateMap extends Map {
  652. /**
  653. * Create a reactive Map.
  654. *
  655. * @param {string} name the property name
  656. * @param {StateManager} stateManager the state manager
  657. * @param {iterable} iterable an iterable object to create the Map
  658. */
  659. constructor(name, stateManager, iterable) {
  660. // We don't have any "this" until be call super.
  661. super(iterable);
  662. this.name = name;
  663. this.stateManager = stateManager;
  664. }
  665. /**
  666. * Set an element into the map.
  667. *
  668. * Each value needs it's own id attribute. Objects without id will be rejected.
  669. * The function will throw an error if the value id and the key are not the same.
  670. *
  671. * @param {*} key the key to store
  672. * @param {*} value the value to store
  673. * @returns {Map} the resulting Map object
  674. */
  675. set(key, value) {
  676. // Only mutations should be able to set state values.
  677. if (this.stateManager.readonly) {
  678. throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);
  679. }
  680. // Normalize keys as string to prevent json decoding errors.
  681. key = this.normalizeKey(key);
  682. this.checkValue(value);
  683. if (key === undefined || key === null) {
  684. throw Error('State lists keys cannot be null or undefined');
  685. }
  686. // ID is mandatory and should be the same as the key.
  687. if (this.normalizeKey(value.id) !== key) {
  688. throw new Error(`State error: ${this.name} list element ID (${value.id}) and key (${key}) mismatch`);
  689. }
  690. const action = (super.has(key)) ? 'updated' : 'created';
  691. // Save proxied data into the list.
  692. const result = super.set(key, new Proxy(value, new Handler(this.name, this.stateManager)));
  693. // If the state is not ready yet means the initial state is not yet loaded.
  694. if (this.stateManager.state === undefined) {
  695. return result;
  696. }
  697. this.stateManager.registerStateAction(this.name, null, action, super.get(key));
  698. return result;
  699. }
  700. /**
  701. * Check if a value is valid to be stored in a a State List.
  702. *
  703. * Only objects with id attribute can be stored in State lists.
  704. *
  705. * This method throws an error if the value is not valid.
  706. *
  707. * @param {object} value (with ID)
  708. */
  709. checkValue(value) {
  710. if (!typeof value === 'object' && value !== null) {
  711. throw Error('State lists can contain objects only');
  712. }
  713. if (value.id === undefined) {
  714. throw Error('State lists elements must contain at least an id attribute');
  715. }
  716. }
  717. /**
  718. * Return a normalized key value for state map.
  719. *
  720. * Regular maps uses strict key comparissons but state maps are indexed by ID.JSON conversions
  721. * and webservices sometimes do unexpected types conversions so we convert any integer key to string.
  722. *
  723. * @param {*} key the provided key
  724. * @returns {string}
  725. */
  726. normalizeKey(key) {
  727. return String(key).valueOf();
  728. }
  729. /**
  730. * Insert a new element int a list.
  731. *
  732. * Each value needs it's own id attribute. Objects withouts id will be rejected.
  733. *
  734. * @param {object} value the value to add (needs an id attribute)
  735. * @returns {Map} the resulting Map object
  736. */
  737. add(value) {
  738. this.checkValue(value);
  739. return this.set(value.id, value);
  740. }
  741. /**
  742. * Return a state map element.
  743. *
  744. * @param {*} key the element id
  745. * @return {Object}
  746. */
  747. get(key) {
  748. return super.get(this.normalizeKey(key));
  749. }
  750. /**
  751. * Check whether an element with the specified key exists or not.
  752. *
  753. * @param {*} key the key to find
  754. * @return {boolean}
  755. */
  756. has(key) {
  757. return super.has(this.normalizeKey(key));
  758. }
  759. /**
  760. * Delete an element from the map.
  761. *
  762. * @param {*} key
  763. * @returns {boolean}
  764. */
  765. delete(key) {
  766. // State maps uses only string keys to avoid strict comparisons.
  767. key = this.normalizeKey(key);
  768. // Only mutations should be able to set state values.
  769. if (this.stateManager.readonly) {
  770. throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);
  771. }
  772. const previous = super.get(key);
  773. const result = super.delete(key);
  774. if (!result) {
  775. return result;
  776. }
  777. this.stateManager.registerStateAction(this.name, null, 'deleted', previous);
  778. return result;
  779. }
  780. /**
  781. * Return a suitable structure for JSON conversion.
  782. *
  783. * This function is needed because new values are compared in JSON. StateMap has Private
  784. * attributes which cannot be stringified (like this.stateManager which will produce an
  785. * infinite recursivity).
  786. *
  787. * @returns {array}
  788. */
  789. toJSON() {
  790. let result = [];
  791. this.forEach((value) => {
  792. result.push(value);
  793. });
  794. return result;
  795. }
  796. /**
  797. * Insert a full list of values using the id attributes as keys.
  798. *
  799. * This method is used mainly to initialize the list. Note each element is indexed by its "id" attribute.
  800. * This is a basic restriction of StateMap. All elements need an id attribute, otherwise it won't be saved.
  801. *
  802. * @param {iterable} values the values to load
  803. * @returns {StateMap} return the this value
  804. */
  805. loadValues(values) {
  806. values.forEach((data) => {
  807. this.checkValue(data);
  808. let key = data.id;
  809. let newvalue = new Proxy(data, new Handler(this.name, this.stateManager));
  810. this.set(key, newvalue);
  811. });
  812. return this;
  813. }
  814. }