impera-js
Version:
Tiny, Proxy based App State Managment for custom-elements
459 lines (400 loc) • 16.9 kB
text/typescript
import onChangeProxy from "./onChange.js"
import {LitElement} from "lit-element"
var _isCallback_locked = false;
var _under_transition = false;
const _transitions_callbackMap : Map<StateVariable, Function> = new Map();
class BaseState{
callbackMap : Map<EventTarget,Function>
name : string
constructor(NAME:string){
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?:any){
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:HTMLElement, callback:Function ) :void {
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:HTMLElement) :void {
if(target === null || target === undefined )
throw Error("Target is undefined.")
// remove element from watcher list
this.callbackMap.delete(target);
}
}
type usrCallback = (input?: any) => void;
/**
* 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:string,func?:usrCallback){
super(NAME);
if(typeof func === "function") this.usrDefined_transition = func;
}
/**
* User defined transition to be overwritten.
* @param input Any meaningfull data.
*/
usrDefined_transition(input?:any){}
/**
* 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?:any ) :void {
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{
type : string;
default_val : any ;
_err_on_value :string;
_val : any;
_valueProxy: ProxyConstructor;
_auto_valueProxy: ProxyConstructor;
allowStandaloneAssign:boolean;
transitionMap : Map<string,StateTransition>
constructor(NAME:string, DEFAULT:any){ // FIXME DEFAULT HAS A TYPE OF TYPE
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:any){
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:any):any{
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():void{
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():void{
this.value = this.default_val ;
}
GET():any{
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() :void {
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:string, func:Function){
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:string,input?:any){
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:any) :void {
this._call_watchers(input);
}
}
type Constructor<T = {}> = new (...args: any[]) => T;
interface htmlEL {
new():HTMLElement
}
interface litEl{
new():LitElement
}
function baseMixin<TBase extends Constructor>(listOfComponents:Array<StateVariable|StateTransition|Message>, baseClass:TBase) {
return class extends baseClass {
_transitionMap : Map<String,any>
_messageMap :Map<String,any>
constructor(...args: any[]){
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:string,input?:any){
if(this._transitionMap.has(name))
this._transitionMap.get(name)(input);
else throw Error(`Transition ${name} not found`);
}
sendMessageOnChannel(name:string, payload:any){
if(this._messageMap.has(name))
this._messageMap.get(name)(payload);
else throw Error(`Message channel ${name} not found`);
}
_addGetterSetters():void{
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: any) => {
throw `${state_comp.name} cannot be assigned from a custom element`;
},
//@ts-ignore
get: () => { return ((<StateVariable>state_comp).type === "object") ? this[`_${state_comp.name}Proxy`] : (<StateVariable>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:Array<StateVariable|StateTransition|Message>, baseClass:htmlEL) {
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:Array<StateVariable|StateTransition|Message>, baseClass:litEl) {
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:string)
{
return function (){
if(this[`on_${varName}_update`]) this[`on_${varName}_update`]();
this.requestUpdate();
}
}
}
}