UNPKG

impera-js

Version:

Tiny, Proxy based App State Managment for custom-elements

394 lines (393 loc) 16.5 kB
import onChangeProxy from "./onChange.js"; var _isCallback_locked = false; var _under_transition = false; const _transitions_callbackMap = new Map(); class BaseState { constructor(NAME) { this.name = NAME; this.callbackMap = new Map(); if (typeof (this.name) !== "string") throw Error("Variable name must be a string."); } lock_callbacks() { if (_isCallback_locked) { this.unlock_callbacks(); throw Error('Forbidden multiple-update during an update callback loop.'); } else _isCallback_locked = true; } unlock_callbacks() { _isCallback_locked = false; } _call_watchers(input) { for (let update_callback of this.callbackMap.values()) { if (input === undefined) update_callback(); else update_callback(input); } } /** * Attach a callback to be fired when this stateVariable (or Transition) changes (is dispatched). * @param target Element that holds the callback * @param callback the callback function needs to be bound to the element if using **this** */ attachWatcher(target, callback) { if (target === null || target === undefined) throw Error("Target is undefined."); // add element to the watcher list this.callbackMap.set(target, callback); } /** * Removes the element from the watcher list * @param target element to be removed */ detachWatcher(target) { if (target === null || target === undefined) throw Error("Target is undefined."); // remove element from watcher list this.callbackMap.delete(target); } } /** * A stateTransition is a global function that is meant to apply simultaneously an overall state change, * this can be made of just one variable change or multiple stateVariables changes at the same time, so that the initial and final * states are always well defined, it guarantees that UI updates are made at transition completion (final state) only. */ export class StateTransition extends BaseState { constructor(NAME, func) { super(NAME); if (typeof func === "function") this.usrDefined_transition = func; } /** * User defined transition to be overwritten. * @param input Any meaningfull data. */ usrDefined_transition(input) { } /** * Fires the user defined transition and calls the callbacks of all watchers. * @param input data to be passed to the user defined transition */ applyTransition(input) { this.lock_callbacks(); try { _under_transition = true; this.usrDefined_transition(input); _under_transition = false; // loop over watchers callbacks of the StateTransition this._call_watchers(input); // loop over automatically added StateVariable callbacks to _transitions_callbackMap for (let upd_callback of _transitions_callbackMap.values()) { upd_callback(); } } catch (e) { _transitions_callbackMap.clear(); this.unlock_callbacks(); throw new Error(e.message); } _transitions_callbackMap.clear(); this.unlock_callbacks(); } } /** * A StateVariable hold the state of the App, its content can be a String, Object, Number and Boolean. Its **DEFAULT** * value is passed at creation time and defines the type of the variable, the type cannot be changed later. * A StateVariable is automatically stored in **localStorage**. * @param value - Returns a proxy to the content of the stateVariable, whenever it is set (directly or indirectly using Array.push * for example) will run the callback for all watchers.Proxy to the content of stateVariable * @param allowStandaloneAssign - Enable/Disable assignment outside of a stateTransition (default true) */ export class StateVariable extends BaseState { constructor(NAME, DEFAULT) { super(NAME); this.type = typeof (DEFAULT); this.default_val = DEFAULT; this._err_on_value = 'Wrong type assignment to state variable: ' + this.name; this._valueProxy = undefined; this._auto_valueProxy = undefined; this.allowStandaloneAssign = true; this.transitionMap = new Map(); // Sanity checks let white_list_types = ["string", "object", "number", "boolean"]; if (!white_list_types.includes(this.type)) throw TypeError(this._err_on_value); // set default variable if none this._val = this.GET() || this.CREATE(this.default_val); // proxy this._set_proxies(); } _set_proxies() { if (this.type === "object" && typeof (this._val) === "object") { this._valueProxy = onChangeProxy(this._val, this.updateWatcherIfAllowed.bind(this)); this._auto_valueProxy = onChangeProxy(this._val, this._markForWatchersUpdate.bind(this)); } } set value(val) { this._checkIsAllowed(); this._val = val; this._set_proxies(); if (_under_transition) this._markForWatchersUpdate(); else this.updateWatchers(); } get value() { if (_under_transition) return (this.type === "object") ? this._auto_valueProxy : this._val; else return (this.type === "object") ? this._valueProxy : this._val; } CREATE(me) { if (typeof (me) === this.type) { let push_var = (this.type !== 'string') ? JSON.stringify(me) : me; localStorage.setItem(this.name, push_var); } else throw TypeError(this._err_on_value); return me; } UPDATE_DATA() { if (typeof (this._val) === this.type) { let push_var = (this.type !== 'string') ? JSON.stringify(this._val) : this._val; localStorage.setItem(this.name, push_var); } else { if (_under_transition) _under_transition = false; if (_isCallback_locked) this.unlock_callbacks(); throw TypeError(this._err_on_value); } } RESET() { this.value = this.default_val; } GET() { let return_val = localStorage.getItem(this.name); if (return_val === null) return return_val; if (this.type !== 'string') { return_val = JSON.parse(return_val); if (typeof (return_val) !== this.type) throw TypeError("State variable: " + this.name + " is corrupted, returns type " + typeof (return_val) + " expecting " + this.type); } return return_val; } _markForWatchersUpdate() { this.UPDATE_DATA(); _transitions_callbackMap.set(this, this._call_watchers.bind(this)); } _checkIsAllowed() { if (!this.allowStandaloneAssign && !_under_transition) { if (_under_transition) _under_transition = false; throw "StateVariable " + this.name + " is not allowed assignment outside a state transition"; } } updateWatcherIfAllowed() { this._checkIsAllowed(); this.updateWatchers(); } updateWatchers() { this.lock_callbacks(); try { this.UPDATE_DATA(); // loop over watchers callbacks this._call_watchers(); } catch (e) { // make sure to unlock in case of error this.unlock_callbacks(); throw new Error(e.message); } this.unlock_callbacks(); } /** * Add a transition to this stateVariable, after that the variable can only be changed trough defined stateTransition. * @param name Used to identify the transition * @param func Definition of the variable update, **this** is bound to the variable. */ addTransition(name, func) { let t = new StateTransition(name); if (typeof (func) === "function") { t.usrDefined_transition = func.bind(this); this.transitionMap.set(name, t); this.allowStandaloneAssign = false; } } /** * Fires one of the user defined transition related to this stateVariable. * @param name Identifier of the transition. * @param input Payload to be passed to the transition, if any. */ applyTransition(name, input) { if (this.transitionMap.has(name)) this.transitionMap.get(name).applyTransition(input); else throw Error(`Transition ${name} not found`); } } /** * A Message does not change the state of the app and is not persisted in any way, it used to exchange payloads between custom-elements. * A custom-element can listen for a specific message, retrieve its payload and fire a callback when this happens. */ export class Message extends BaseState { sendMessage(input) { this._call_watchers(input); } } function baseMixin(listOfComponents, baseClass) { return class extends baseClass { constructor(...args) { super(...args); this._transitionMap = new Map(); this._messageMap = new Map(); this._extractTransitions(); this._addGetterSetters(); } _extractTransitions() { for (let itr = 0; itr < listOfComponents.length; itr++) { let comp = listOfComponents[itr]; if (comp instanceof StateVariable) { for (let t of comp.transitionMap.values()) { listOfComponents.push(t); } } } } applyTransition(name, input) { if (this._transitionMap.has(name)) this._transitionMap.get(name)(input); else throw Error(`Transition ${name} not found`); } sendMessageOnChannel(name, payload) { if (this._messageMap.has(name)) this._messageMap.get(name)(payload); else throw Error(`Message channel ${name} not found`); } _addGetterSetters() { for (let state_comp of listOfComponents) { if (state_comp instanceof StateVariable) { // adding proxy if (state_comp.type === "object") //@ts-ignore this[`_${state_comp.name}Proxy`] = onChangeProxy(state_comp._val, () => { throw `${state_comp.name} cannot be assigned from a custom element`; }); Object.defineProperty(this, state_comp.name, { set: (val) => { throw `${state_comp.name} cannot be assigned from a custom element`; }, //@ts-ignore get: () => { return (state_comp.type === "object") ? this[`_${state_comp.name}Proxy`] : state_comp._val; } }); } else if (state_comp instanceof Message) { this._messageMap.set(state_comp.name, state_comp.sendMessage.bind(state_comp)); } else if (state_comp instanceof StateTransition) { this._transitionMap.set(state_comp.name, state_comp.applyTransition.bind(state_comp)); } else { throw TypeError("Accept only StateVariable, StateTransition or Message."); } } } disconnectedCallback() { //@ts-ignore if (super['disconnectedCallback'] !== undefined) { //@ts-ignore super.disconnectedCallback(); } for (let state_comp of listOfComponents) { //@ts-ignore state_comp.detachWatcher(this); } } }; } /** * This is a mixin to be applied to a generic web-component. For any **stateVariables** in the list will add to the element a read-only property * named as the stateVariable. It will add an **applyTransition** method to dispatch the added * transition (either of a stateVariable or of a global stateTransition). Callbacks to react on stateVariable change needs to be overwritten by the user * and have a predefiend naming scheme: **on_"stateVarName"_update**. Callbacks to react to transitions are instead called **on_"stateTransitionName"**, * in the latter case also the transition input data are passed. For any **Message** in the list a **gotMessage_"messageName"** callback is added to react * to message exchange, this callback passes as input the message payload. * @param listOfComponents is a list of StateVariables and StateTransition to add to the web-component * @param baseClass The class on which the mixin is applied */ export function statesMixin(listOfComponents, baseClass) { return class extends baseMixin(listOfComponents, baseClass) { connectedCallback() { //@ts-ignore if (super['connectedCallback'] !== undefined) { //@ts-ignore super.connectedCallback(); } // watch default state variables for (let state_comp of listOfComponents) { if (state_comp instanceof Message) { //@ts-ignore if (this[`gotMessage_${state_comp.name}`]) //@ts-ignore state_comp.attachWatcher(this, this[`gotMessage_${state_comp.name}`].bind(this)); } else if (state_comp instanceof StateTransition) { //@ts-ignore if (this[`on_${state_comp.name}`]) //@ts-ignore state_comp.attachWatcher(this, this[`on_${state_comp.name}`].bind(this)); } //@ts-ignore else if (this[`on_${state_comp.name}_update`]) { //@ts-ignore state_comp.attachWatcher(this, this[`on_${state_comp.name}_update`].bind(this)); //@ts-ignore this[`on_${state_comp.name}_update`](); } } } }; } /** * This is a mixin to be applied to Lit-Element web-components. For any stateVariables in the list will add a read-only property * to the element named as the stateVariable. It will add an **applyTransition** method to dispatch the added * transition (either of a stateVariable or of a global stateTransition). For each change of a stateVariable or dispatch of * any of the stateTransition a render request is called. A hook function is added for each stateVariable with name **'on_VarName_update'**, * if this function is defined by the user then it will be run before the render. * For any **Message** in the list it will add a **gotMessage_"messageName"** method to react * to message exchange, this method passes as input the message payload. * @param listOfComponents is a list of StateVariables and StateTransition to add to the web-component * @param baseClass The class on which the mixin is applied */ export function litStatesMixin(listOfComponents, baseClass) { return class extends baseMixin(listOfComponents, baseClass) { connectedCallback() { if (super['connectedCallback'] !== undefined) { super.connectedCallback(); } for (let state_comp of listOfComponents) { if (state_comp instanceof Message) { //@ts-ignore if (this[`gotMessage_${state_comp.name}`]) //@ts-ignore state_comp.attachWatcher(this, this[`gotMessage_${state_comp.name}`].bind(this)); } else { //@ts-ignore state_comp.attachWatcher(this, this._stateRequestUpdate(state_comp.name).bind(this)); } } } _stateRequestUpdate(varName) { return function () { if (this[`on_${varName}_update`]) this[`on_${varName}_update`](); this.requestUpdate(); }; } }; }