adam-26/react-router-dispatcher

View on GitHub
src/dispatchRouteActions.js

Summary

Maintainability
A
2 hrs
Test Coverage
// @flow
import React from 'react';
import invariant from 'invariant';
import { matchRoutes } from 'react-router-config';

// TODO: Remove the need for any default parameters
const defaultParams = {
    httpResponse: {
        statusCode: 200
    }
};

function isRouteComponent(routeComponent) {
    return React.isValidElement(routeComponent) || typeof routeComponent === 'function';
}

function addRouteComponent(component, match, route, routeComponentKey, target) {
    target.push([component, match, { route, routeComponentKey }]);
}

export function getRouteComponents(route, routeComponentPropNames) {
    const routeComponents = [];
    routeComponentPropNames.forEach((propName) => {
        const routeComponent = route[propName];
        if (isRouteComponent(routeComponent)) {
            routeComponents.push({
                routeComponentKey: propName,
                routeComponent: routeComponent
            });
        }
        else if (routeComponent !== null && typeof routeComponent === 'object') {
            // support assigning component(s) using key/value pairs (object)
            Object.keys(routeComponent).forEach(componentName => {
                const component = routeComponent[componentName];
                if (isRouteComponent(component)) {
                    routeComponents.push({
                        routeComponentKey: `${propName}.${componentName}`,
                        routeComponent: routeComponent
                    });
                }
            });
        }
    });

    return routeComponents;
}

export function resolveRouteComponents(branch, routeComponentPropNames) {
    const routeComponents = [];
    branch.forEach(({ route, match }) => {
        // get the route component(s) for each route
        getRouteComponents(route, routeComponentPropNames).forEach(({ routeComponent, routeComponentKey }) => {
            addRouteComponent(routeComponent, match, route, routeComponentKey, routeComponents);
        });
    });

    return routeComponents
}

export function resolveActionSets(routeComponents, dispatchActions, initParamFuncName, isLifecycleMethod, actionFilter) {
    const actionSets = parseDispatchActions(dispatchActions);
    const resolvedActionSets = [];

    actionSets.forEach(actionSet => {
        actionSet.forEach(actionName => {
            const promises = [];
            let action = null;

            routeComponents.forEach(([component, match, routerContext]) => {
                if (typeof component.getDispatcherActions !== 'function') {
                    return;
                }

                const componentActions = component.getDispatcherActions([actionName], actionFilter);
                if (componentActions.length === 0) {
                    return;
                }

                // The dispatcher should invoke each individual action
                invariant(componentActions.length === 1, '[react-router-dispatcher]: .getDispatcherActions() returned more than 1 component action.');

                const componentAction = componentActions[0];
                if (action === null) {
                    action = componentAction;
                }

                // Determine the component mapper - lifecycle methods should NOT map prop values
                const componentParamsToProps = isLifecycleMethod ? (p => p) : component.getDispatchParamToProps();

                const { staticMethod, staticMethodName } = componentAction;
                const actionMethod = staticMethod || component[staticMethodName];
                promises.push([actionMethod, match, routerContext, componentParamsToProps]);
            });

            if (action === null) {
                return;
            }

            const { name, successHandler, errorHandler, stopServerActions, filterParamsToProps } = action;
            resolvedActionSets.push({
                name: name,
                routeActions: promises,
                actionSuccessHandler: typeof successHandler === 'function' ? successHandler : () => null,
                actionErrorHandler: typeof errorHandler === 'function' ? errorHandler : err => { throw err },
                stopServerActions: typeof stopServerActions === 'function' ? stopServerActions : false, // here or bundled with route actions?
                initParams:
                    (typeof initParamFuncName === 'string' && action[initParamFuncName]) || (params => params),
                filterParams: isLifecycleMethod ?
                    props => props :
                    filterParamsToProps
            });

        });
    });

    return resolvedActionSets;
}

function createActionSetPromise(resolvedActionSet, location, actionParams, props) {
    const {
        routeActions,
        actionSuccessHandler,
        actionErrorHandler,
        stopServerActions,
        initParams,
        filterParams
    } = resolvedActionSet;

    // This is a 2-step process - first init & assign the action parameters, this data is returned to the caller
    const filteredParams = filterParams(Object.assign(actionParams, initParams(actionParams)));
    // Then, append the route 'location' to the props that are passed to all action methods
    // - this prevents the 'location' data from being returned to the caller
    const filteredProps = Object.assign({ location }, filteredParams);

    // Invoke each route action
    return Promise.all(routeActions.map(([componentAction, match, routerContext, componentParamsToProps]) => {
        return Promise.resolve(componentAction(
            {
                ...componentParamsToProps(props, routerContext),
                ...filteredProps,
                match
            },
            routerContext));
        })
    )

    // Invoke any configured post-action handlers
    .then(() => Promise.resolve(actionSuccessHandler(filteredProps)))

    // Handle any action-specific error(s)
    .catch(err => actionErrorHandler(err, filteredProps))

    // determine if the next action set should be invoked
    .then(() => Promise.resolve(
        // eslint-disable-next-line no-unused-vars
        !routeActions.some(([ componentAction, match, routerContext ]) =>
            stopServerActions === false ?
                false :
                stopServerActions({ ...filteredProps, match }, routerContext))
    ));
}

export function reduceActionSets(resolvedActionSets, location, props) {
    const actionParams = Object.assign({}, defaultParams);
    let promiseActionSet = Promise.resolve(true); // always start w/true to invoke the first actionSet

    while (resolvedActionSets.length > 0) {
        const resolvedActionSet = resolvedActionSets.shift(); // IMPORTANT: don't refactor this inside the promise fn
        promiseActionSet = promiseActionSet
            .then((invokeActions) =>
                invokeActions ? createActionSetPromise(resolvedActionSet, location, actionParams, props) : Promise.resolve(invokeActions));
    }

    return promiseActionSet.then(() => Promise.resolve(actionParams));
}

export function matchRouteComponents(location, routes, routeComponentPropNames) {
    const branch = matchRoutes(routes, location.pathname);
    if (!branch.length) {
        return [];
    }

    return resolveRouteComponents(branch, routeComponentPropNames);
}

export function dispatchRouteActions(location, actions, routeConfig, props, initParamFuncName, isLifecycleMethod, actionFilter) {
    const { routes, routeComponentPropNames } = routeConfig;

    // Determine all RouteComponent(s) matched for the current route
    const routeComponents = matchRouteComponents(location, routes, routeComponentPropNames);
    if (routeComponents.length === 0) {
        return Promise.resolve();
    }

    const dispatchActions = typeof actions === 'function' ?
        parseDispatchActions(actions(location, props)) :
        actions;

    const actionSets = resolveActionSets(
        routeComponents,
        dispatchActions,
        initParamFuncName,
        isLifecycleMethod,
        actionFilter);

    return reduceActionSets(actionSets, location, props);
}

export function parseDispatchActions(dispatchActions) {
    if (typeof dispatchActions === 'string') {
        return [[dispatchActions]];
    }

    if (Array.isArray(dispatchActions)) {

        // Is a single action set defined?
        if (dispatchActions.every(action => typeof action === 'string')) {
            return [dispatchActions];
        }

        return dispatchActions.map(actionSet => {
            if (Array.isArray(actionSet)) {
                return actionSet;
            }

            if (typeof actionSet === 'string') {
                return [actionSet];
            }

            invariant(false, `Invalid dispatch action, '${actionSet}', expected string or array.`);
        });
    }

    invariant(false, 'Invalid dispatch actions, expected string or array.');
}

export function standardizeActionNames(dispatchActions) {
    if (typeof dispatchActions === 'function') {
        return dispatchActions;
    }

    return parseDispatchActions(dispatchActions);
}

function isClientAction(action) {
    return typeof action.initClientAction === 'function';
}

function isServerAction(action) {
    return typeof action.initServerAction === 'function';
}

/**
 * Dispatches asynchronous actions during a react components lifecycle
 *
 * @param location
 * @param actionNames
 * @param routeConfig
 * @param props
 */
export function dispatchComponentActions(location, actionNames, routeConfig, props) {
    return dispatchRouteActions(
        location,
        actionNames,
        routeConfig,
        props,
        'initComponentAction',
        true);
}

/**
 * Dispatches asynchronous actions on the server
 *
 * @param location
 * @param actionNames
 * @param routeConfig
 * @param props
 * @returns {*}
 */
export function dispatchServerActions(location, actionNames, routeConfig, props) {
    return dispatchRouteActions(
        location,
        standardizeActionNames(actionNames),
        routeConfig,
        props,
        'initServerAction',
        false,
        isServerAction);
}

/**
 * Dispatches synchronous actions on the client.
 *
 * @param location
 * @param actionNames
 * @param routeConfig
 * @param props
 * @returns {*}
 */
export function dispatchClientActions(location, actionNames, routeConfig, props) {
    const { routes, routeComponentPropNames } = routeConfig;

    const actionParams = Object.assign({}, defaultParams); // used for internal action parameters
    const clientActionSets = standardizeActionNames(actionNames);
    const routeComponents = matchRouteComponents(
        location,
        routes,
        routeComponentPropNames);

    clientActionSets.forEach(actionSet => {

        routeComponents.forEach(([component, match, routerCtx]) => {
            if (typeof component.getDispatcherActions !== 'function') {
                return;
            }

            const componentActions = component.getDispatcherActions(actionSet, isClientAction);
            const componentParamsToProps = component.getDispatchParamToProps();

            componentActions.forEach(({ staticMethod, staticMethodName, filterParamsToProps, initClientAction }) => {
                const componentActionMethod = staticMethod || component[staticMethodName];

                componentActionMethod(
                    {
                        ...componentParamsToProps(props, routerCtx),
                        ...filterParamsToProps(Object.assign(actionParams, initClientAction(actionParams))),
                        location,
                        match
                    },
                    routerCtx);
            });
        });
    });

    return actionParams;
}