%PDF- %PDF-
Direktori : /home/vacivi36/ava/lib/amd/src/local/reactive/ |
Current File : /home/vacivi36/ava/lib/amd/src/local/reactive/statemanager.js |
// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Reactive simple state manager. * * The state manager contains the state data, trigger update events and * can lock and unlock the state data. * * This file contains the three main elements of the state manager: * - State manager: the public class to alter the state, dispatch events and process update messages. * - Proxy handler: a private class to keep track of the state object changes. * - StateMap class: a private class extending Map class that triggers event when a state list is modifed. * * @module core/local/reactive/stateManager * @class core/local/reactive/stateManager * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * State manager class. * * This class handle the reactive state and ensure only valid mutations can modify the state. * It also provide methods to apply batch state update messages (see processUpdates function doc * for more details on update messages). * * Implementing a deep state manager is complex and will require many frontend resources. To keep * the state fast and simple, the state can ONLY store two kind of data: * - Object with attributes * - Sets of objects with id attributes. * * This is an example of a valid state: * * { * course: { * name: 'course name', * shortname: 'courseshort', * sectionlist: [21, 34] * }, * sections: [ * {id: 21, name: 'Topic 1', visible: true}, * {id: 34, name: 'Topic 2', visible: false, * ], * } * * The following cases are NOT allowed at a state ROOT level (throws an exception if they are assigned): * - Simple values (strings, boolean...). * - Arrays of simple values. * - Array of objects without ID attribute (all arrays will be converted to maps and requires an ID). * * Thanks to those limitations it can simplify the state update messages and the event names. If You * need to store simple data, just group them in an object. * * To grant any state change triggers the proper events, the class uses two private structures: * - proxy handler: any object stored in the state is proxied using this class. * - StateMap class: any object set in the state will be converted to StateMap using the * objects id attribute. */ export default class StateManager { /** * Create a basic reactive state store. * * The state manager is meant to work with native JS events. To ensure each reactive module can use * it in its own way, the parent element must provide a valid event dispatcher function and an optional * DOM element to anchor the event. * * @param {function} dispatchEvent the function to dispatch the custom event when the state changes. * @param {element} target the state changed custom event target (document if none provided) */ constructor(dispatchEvent, target) { // The dispatch event function. /** @package */ this.dispatchEvent = dispatchEvent; // The DOM container to trigger events. /** @package */ this.target = target ?? document; // State can be altered freely until initial state is set. /** @package */ this.readonly = false; // List of state changes pending to be published as events. /** @package */ this.eventsToPublish = []; // The update state types functions. /** @package */ this.updateTypes = { "create": this.defaultCreate.bind(this), "update": this.defaultUpdate.bind(this), "delete": this.defaultDelete.bind(this), "put": this.defaultPut.bind(this), "override": this.defaultOverride.bind(this), "remove": this.defaultRemove.bind(this), "prepareFields": this.defaultPrepareFields.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; let mode = 'off'; // When the state is in readonly again is time to publish all pending events. if (this.readonly) { mode = 'on'; this._publishEvents(); } // Dispatch a read only event. this.dispatchEvent({ action: `readmode:${mode}`, state: this.state, element: null, }, this.target); } /** * 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}`); } // Some state data may require some cooking before sending to the // state. Reactive instances can overrdide the default fieldDefaults // method to add extra logic to all updates. const prepareFields = updateTypes.prepareFields ?? this.updateTypes.prepareFields; method(this, updateName, prepareFields(this, updateName, fields)); } /** * Prepare fields for processing. * * This method is used to add default values or calculations from the frontend side. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data * @returns {Object} final fields data */ defaultPrepareFields(stateManager, updateName, fields) { return 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 remove state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultRemove(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (!current) { return; } // 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; } } /** * Process a put state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultPut(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (current) { // Update attributes. for (const [fieldName, fieldValue] of Object.entries(fields)) { current[fieldName] = fieldValue; } } else { // Create new object. let state = stateManager.state; if (state[updateName] instanceof StateMap) { state[updateName].add(fields); return; } state[updateName] = fields; } } /** * Process an override state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultOverride(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (current) { // Remove any unnecessary fields. for (const [fieldName] of Object.entries(current)) { if (fields[fieldName] === undefined) { delete current[fieldName]; } } // Update field. for (const [fieldName, fieldValue] of Object.entries(fields)) { current[fieldName] = fieldValue; } } else { // Create the element if not exists. let state = stateManager.state; if (state[updateName] instanceof StateMap) { state[updateName].add(fields); return; } state[updateName] = fields; } } /** * 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, changes: fieldChanges, }, 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; } }