RolandJansen/intermix.js

View on GitHub
src/registry/AbstractRegistry.ts

Summary

Maintainability
A
1 hr
Test Coverage
A
91%
import { ActionCreatorsMapObject, Reducer, ReducersMapObject, Store } from "redux";
import { store } from "../store/store";
import { IActionHandlerMap, IState, Tuple } from "../interfaces/interfaces";
import {
    InternalAction,
    IOscAction,
    IOscActionDef,
    OscArgSequence,
    procedure,
    OscPayload,
} from "../interfaces/IActions";
import { IRegistryItem } from "../interfaces/IRegistryItems";
import { getRandomString, deepCopy } from "../helper";
import SeqPart from "../seqpart/SeqPart";

/**
 * Base class for item registries.
 * Registries create objects (plugins, sequencer parts, etc)
 * and prepare them to be used in intermix.
 */
export default abstract class AbstractRegistry {
    private static keyLength = 5;

    public abstract itemList: Map<string, IRegistryItem>;
    public abstract add(optionalParameter?: any): IRegistryItem;
    public abstract remove(itemId: string): void;

    public getUidList(): string[] {
        return Array.from(this.itemList.keys());
    }

    public getAllSubReducers(): ReducersMapObject {
        const subReducers: ReducersMapObject = {};
        const allUids = this.getUidList();

        allUids.forEach((uid: string) => {
            const item = this.itemList.get(uid);
            if (item) {
                const initState = this.getInitialState(uid, item.actionDefs, item.initState);
                subReducers[uid] = this.getSubReducer(item.actionDefs, initState);
            }
        });

        return subReducers;
    }

    private isSeqPart = (item: IRegistryItem): item is SeqPart => {
        return (item as SeqPart).initState !== undefined;
    };

    /**
     * Subscribes an item to the store. The function that will
     * be called by the store invokes the onChange handler of the registered item.
     * This should be the only intermix function that subscribes to the store.
     * For details see:
     * https://github.com/reduxjs/redux/issues/303#issuecomment-125184409
     * @param st Instance of the store that keeps the state
     * @param newObserver The item to be registered
     */
    public observeStore<T extends IRegistryItem>(st: Store, newObserver: T): () => void {
        const selectSubState = this.selectSubState;
        const getChanged = this.getChanged;

        const uid = newObserver.uid;
        const onChange = newObserver.onChange.bind(newObserver);
        let currentState: IState = {};

        function handleChange(): void {
            const nextState: IState = selectSubState(st.getState(), uid);

            // check by reference that
            // both objects are different
            if (nextState !== currentState) {
                const changed = getChanged(currentState, nextState);
                currentState = nextState;
                if (changed.length === 1) {
                    onChange(changed[0]);
                } else if (changed.length > 1) {
                    changed.forEach((tuple: Tuple) => {
                        onChange(tuple);
                    });
                }
            }
        }

        const unsubscribe = store.subscribe(handleChange);
        handleChange(); // invoke the function once to set currentState
        return unsubscribe;
    }

    /**
     * Returns the sub state of a plugin from the global state object
     * @param globalState The global state object from redux store
     * @param uid Unique id of the item
     */
    protected selectSubState(globalState: IState, uid: string): IState {
        if (globalState.hasOwnProperty(uid)) {
            // type assertion is not good but IState has
            // no fixed members to build a type guard
            return globalState[uid] as IState;
        }
        return {};
    }

    /**
     * Determines which value has been changed between
     * two successive states. Only works with flat states.
     * @param currentState Original item state
     * @param nextState New item state
     */
    protected getChanged(currentState: IState, nextState: IState): Tuple[] {
        let prop: string;
        const change: Tuple[] = [];
        for (prop in currentState) {
            if (currentState[prop] !== nextState[prop]) {
                change.push([prop, nextState[prop]]);
            }
        }
        return change;
    }

    /**
     * Creates action creator functions from an object with
     * action definitions.
     * @param actionDefs An array of action definitions (see IActionDef)
     * @param uid The unique id of the item that gets registered
     */
    protected getActionCreators(actionDefs: IOscActionDef[], uid: string): ActionCreatorsMapObject {
        const actionCreators: ActionCreatorsMapObject = {};

        actionDefs.forEach((actionDef) => {
            // make a physical copy of actionDef. If not, we would
            // replace <UID> in the original and all following instances
            // would have the same UID in its address.
            const actionDefCopy = Object.assign({}, actionDef);
            actionDefCopy.address = actionDefCopy.address.replace("<UID>", uid);
            const addressParts = actionDefCopy.address.split("/");
            const method = addressParts[addressParts.length - 1];

            let type: string;
            if (actionDefCopy.type) {
                type = actionDefCopy.type;
            } else {
                type = method;
            }

            if (actionDefCopy.typeTag === ",T" || actionDefCopy.typeTag === ",F" || actionDefCopy.typeTag === ",N") {
                const payload = actionDefCopy.typeTag === ",T" ? 1 : 0;
                actionCreators[method] = (): IOscAction => {
                    return {
                        address: actionDefCopy.address,
                        typeTag: actionDefCopy.typeTag,
                        type,
                        payload,
                    };
                };
            } else {
                actionCreators[method] = (payload: OscPayload): IOscAction => {
                    return {
                        address: actionDefCopy.address,
                        typeTag: actionDefCopy.typeTag,
                        type,
                        payload,
                    };
                };
            }
        });
        return actionCreators;
    }

    /**
     * A generic way to build a reducer with pre-defined handlers.
     * These handlers get called according to a lookup table and
     * they do the real work.
     * This approach is explained in detail in the redux doc section
     * "Reducing Boilerplate".
     * @param actionDefs An array of action definitions (see IActionDef)
     * @param initialState Initial state of the sub-state-tree for this reducer
     */
    protected getSubReducer(actionDefs: IOscActionDef[], initialState: IState): Reducer {
        const actionHandlers: IActionHandlerMap = this.getActionHandlers(actionDefs);

        return (state: IState = initialState, action: InternalAction): IState => {
            if (state.uid === action.listener && actionHandlers.hasOwnProperty(action.type)) {
                const handler = actionHandlers[action.type];
                const newState = handler(state, action);
                return newState;
            }
            return state;
        };
    }

    /**
     * Generates an object mapping from action-types to the handlers
     * that will be used to auto-generate reducers. See:
     * https://redux.js.org/recipes/reducingboilerplate#generating-reducers
     * @param actionDefs An array of action definitions (see IOscActionDef)
     */
    protected getActionHandlers(actionDefs: IOscActionDef[]): IActionHandlerMap {
        const handlers: IActionHandlerMap = {};

        actionDefs.forEach((actionDef) => {
            const addressParts = actionDef.address.split("/");
            const method = addressParts[addressParts.length - 1];
            let type: string;

            if (actionDef.type) {
                type = actionDef.type;
            } else {
                type = method;
            }

            if (actionDef.process) {
                const process = actionDef.process;
                handlers[type] = (state: IState, action: InternalAction): IState => {
                    return Object.assign({}, state, process(state, action));
                };
            } else {
                handlers[type] = (state: IState, action: InternalAction): IState => {
                    return Object.assign({}, state, {
                        [type]: action.payload,
                    });
                };
            }
        });
        return handlers;
    }

    /**
     * Generates the initial state for a registry item from
     * its ActionDef object.
     * @param actionDefs An array of action definitions (see IActionDef)
     */
    protected getInitialState(uid: string, actionDefs: IOscActionDef[], initState: IState = {}): IState {
        const iState: IState = {};

        iState.uid = uid; // readonly field
        iState.actionDefs = deepCopy(actionDefs);
        actionDefs.forEach((actionDef) => {
            const addressParts = actionDef.address.split("/");
            const method = addressParts[addressParts.length - 1];
            let type: string;
            let value: number | string | OscArgSequence | ArrayBuffer | procedure;

            if (actionDef.type) {
                type = actionDef.type;
            } else {
                type = method;
            }

            if (actionDef.value) {
                value = actionDef.value;
            } else {
                if (actionDef.typeTag === ",s") {
                    value = "";
                } else {
                    value = 0;
                }
            }

            iState[type] = value;
        });

        return { ...iState, ...initState };
    }

    protected getUniqueItemKey(): string {
        let itemKey = getRandomString(AbstractRegistry.keyLength);
        while (!this.isKeyUnique(itemKey)) {
            itemKey = getRandomString(AbstractRegistry.keyLength);
        }

        return itemKey;
    }

    private isKeyUnique(itemKey: string): boolean {
        const allocated = this.getAllocatedKeys();
        allocated.forEach((key) => {
            if (key === itemKey) {
                return false;
            }
        });
        return true;
    }

    private getAllocatedKeys(): string[] {
        const state = store.getState();
        return [...state.plugins, ...state.seqparts];
    }
}