action.ts

/**
 * Redux action tools.
 *
 * @module redux
 * @license Apache-2.0
 * @author drmats
 */

/* eslint-disable @typescript-eslint/no-explicit-any */




import type {
    AllowSubset,
    Arr,
    Fun,
    NonConstEnum,
    Override,
    SafeKey,
} from "@xcmats/js-toolbox/type";
import { objectMap } from "@xcmats/js-toolbox/struct";




/**
 * redux-compatible Action interface.
 */
export interface ReduxCompatAction<ActionType = any> {
    type: ActionType;
}




/**
 * redux-compatible AnyAction interface.
 */
export interface ReduxCompatAnyAction<
    ActionType = any,
> extends ReduxCompatAction<ActionType> {
    [key: string]: any;
}




/**
 * Unique, private identifier distinguishing
 * between EmptyAction and PayloadAction (statically and in runtime).
 */
const payload = Symbol("payload");




/**
 * Empty action consists just of { type: ActionType } field.
 */
export interface EmptyAction<
    ActionType extends SafeKey = SafeKey,
> extends ReduxCompatAction<ActionType> {
    [payload]: false;
}




/**
 * Action with payload: { type: ActionType, payload: PayloadType }
 */
export interface PayloadAction<
    PayloadType = any,
    ActionType extends SafeKey = SafeKey,
> extends ReduxCompatAction<ActionType> {
    [payload]: true;
    payload: PayloadType;
}




/**
 * Empty action or action carrying payload.
 */
export type Action<
    PayloadType = any,
    ActionType extends SafeKey = SafeKey,
> =
    | EmptyAction<ActionType>
    | PayloadAction<PayloadType, ActionType>;




/**
 * Type predicate - does a given action carry payload?
 */
export function isWithPayload<PayloadType, ActionType extends SafeKey> (
    a: Action<PayloadType, ActionType>,
): a is PayloadAction<PayloadType, ActionType> {
    return a[payload];
}




/**
 * Type predicate - is a given action of string type?
 */
export function isStringActionType<PayloadType> (
    a: Action<PayloadType>,
): a is Action<PayloadType, string> {
    return typeof a.type === "string";
}




/**
 * Type predicate - is a given action of number type?
 */
export function isNumberActionType<PayloadType> (
    a: Action<PayloadType>,
): a is Action<PayloadType, number> {
    return typeof a.type === "number";
}




/**
 * Action creator not carrying anything else than just `type` field.
 */
export interface EmptyActionCreator<
    ActionType extends SafeKey,
> extends EmptyAction<ActionType> {
    (): EmptyAction<ActionType>;
}




/**
 * Action creator carrying payload (more than just `type`).
 */
export interface PayloadActionCreator<
    PayloadType = any,
    ActionType extends SafeKey = SafeKey,
    Args extends Arr = Arr,
> extends EmptyAction<ActionType> {
    (...args: Args): PayloadAction<PayloadType, ActionType>;
}




/**
 * Any action creator (carrying just `type` or `type` and `payload`).
 */
export interface ActionCreator<
    PayloadType = any,
    ActionType extends SafeKey = SafeKey,
    Args extends Arr = Arr,
> extends EmptyAction<ActionType> {
    (...args: Args): Action<PayloadType, ActionType>;
}




/**
 * Redux action creator definer.
 *
 * @function defineActionCreator
 * @param actionType Action type
 * @param creator Optional custom function returning payload.
 * @returns Action creator function.
 */
export function defineActionCreator<
    ActionType extends SafeKey,
> (actionType: ActionType):
    EmptyActionCreator<ActionType>;
export function defineActionCreator<
    ActionType extends SafeKey,
    PayloadType,
    Args extends Arr,
> (actionType: ActionType, creator?: Fun<Args, PayloadType>):
    PayloadActionCreator<PayloadType, ActionType, Args>;
export function defineActionCreator<
    ActionType extends SafeKey,
    PayloadType,
    Args extends Arr,
> (actionType: ActionType, creator?: Fun<Args, PayloadType>):
    ActionCreator<PayloadType, ActionType, Args> {
    let actionCreator: any = !creator ?
        () => ({
            type: actionType,
            [payload]: false,
        }) :
        (...args: Args) => ({
            type: actionType,
            [payload]: true,
            payload: creator(...args),
        });
    actionCreator.type = actionType;
    return actionCreator;
}




/**
 * Construct interface based on `ActionEnum`. Consist of empty action
 * creators (action creators without payload - just `type` field).
 */
export type EmptyActionCreators<ActionEnum extends NonConstEnum> = {
    [K in keyof ActionEnum]: EmptyActionCreator<ActionEnum[K]>;
};




/**
 * Construct object whose keys correspond to `actionEnum` keys and
 * values consists of empty action creators for each type. Conforms to
 * `EmptyActionCreators<ActionEnum>` interface.
 *
 * @function emptyActionCreators
 * @param actionEnum Enum upon which an EmptyActionCreators object is built.
 * @returns EmptyActionCreators object.
 */
export function emptyActionCreators<ActionEnum extends NonConstEnum> (
    actionEnum: ActionEnum,
): EmptyActionCreators<ActionEnum> {
    let actions = {} as EmptyActionCreators<ActionEnum>;
    for (const actionType in actionEnum) {
        actions[actionType] = defineActionCreator(actionEnum[actionType]);
    }
    return actions;
}




/**
 * Take `ActionEnum` type with `PayloadCreators` object type and construct
 * `PayloadActionCreators` on its basis.
 *
 * Constructed `PayloadActionCreators` object type consists only of keys
 * that are also present in `ActionEnum` type (all other keys are dropped).
 */
export type PayloadActionCreators<
    ActionEnum extends NonConstEnum,
    PayloadCreators,
> = {
    [K in Extract<keyof PayloadCreators, keyof ActionEnum>]:
        PayloadCreators[K] extends Fun<infer Args, infer PayloadType> ?
            PayloadActionCreator<PayloadType, ActionEnum[K], Args> : never;
};




/**
 * Take empty action creators object based on `ActionEnum` type
 * (an object with all action creators not carrying anything besides
 * `type` property) and `PayloadCreators` object consisting of plain
 * javascript functions taking arguments and returning values.
 *
 * `PayloadCreators` object type is constrained to be a subset of `ActionEnum`
 * type (in the sense of `AllowSubset` type defined in `type/utils.ts`).
 *
 * Create fully typed action creators object with all action creators
 * defined as `EmptyActionCreator` or `PayloadActionCreator`.
 *
 * @function payloadActionCreators
 * @param emptyActionCreators EmptyActionCreators object
 * @param payloadCreators Object with payload creators.
 * @returns ActionCreators object.
 */
export function payloadActionCreators<
    ActionEnum extends NonConstEnum,
    PayloadCreators extends
        & AllowSubset<ActionEnum, PayloadCreators>
        & Partial<Record<keyof ActionEnum, Fun>>,
> (
    emptyActionCreators: EmptyActionCreators<ActionEnum>,
    payloadCreators: PayloadCreators,
):
    Override<
        typeof emptyActionCreators,
        PayloadActionCreators<ActionEnum, PayloadCreators>
    >
{
    return Object.assign(
        emptyActionCreators,
        objectMap(payloadCreators) <keyof ActionEnum>(([key, creator]) => [
            key, defineActionCreator(emptyActionCreators[key].type, creator),
        ]),
    );
}




/**
 * Construct action slice for provided action enum. Optionally
 * define action creators with payload. Statically typed.
 *
 * @function actionCreators
 * @param actionEnum Enum upon which an ActionCreators object is built.
 * @param payloadCreators Optional object with payload creators.
 * @returns ActionCreators object.
 */
export function actionCreators<
    ActionEnum extends NonConstEnum,
> (actionEnum: ActionEnum): EmptyActionCreators<ActionEnum>;
export function actionCreators<
    ActionEnum extends NonConstEnum,
    PayloadCreators extends
        & AllowSubset<ActionEnum, PayloadCreators>
        & Partial<Record<keyof ActionEnum, Fun>>,
> (
    actionEnum: ActionEnum,
    payloadCreators?: PayloadCreators,
):
    Override<
        EmptyActionCreators<ActionEnum>,
        PayloadActionCreators<ActionEnum, PayloadCreators>
    >;
export function actionCreators<
    ActionEnum extends NonConstEnum,
    PayloadCreators extends
        & AllowSubset<ActionEnum, PayloadCreators>
        & Partial<Record<keyof ActionEnum, Fun>>,
> (
    actionEnum: ActionEnum,
    payloadCreators?: PayloadCreators,
):
    | EmptyActionCreators<ActionEnum>
    | Override<
        EmptyActionCreators<ActionEnum>,
        PayloadActionCreators<ActionEnum, PayloadCreators>
    >
{
    const eac = emptyActionCreators(actionEnum);
    if (payloadCreators) {
        return payloadActionCreators(eac, payloadCreators);
    } else {
        return eac;
    }
}




/**
 * Binds given action creator with chosen store's dispatch.
 *
 * @function bindActionCreator
 * @param actionCreator any action creator
 * @param dispatch redux store's `dispatch` function
 * @returns bound action creator
 */
export function bindActionCreator<
    ActionCreatorType extends Fun,
    ReduxDispatch extends Fun<[Action]>,
> (
    actionCreator: ActionCreatorType | ActionCreator,
    dispatch: ReduxDispatch,
): typeof actionCreator {
    let boundActionCreator = (
        ...args: Parameters<ActionCreatorType>
    ) => dispatch(actionCreator(...args));
    if ((actionCreator as ActionCreator).type) {
        (boundActionCreator as ActionCreator).type =
            (actionCreator as ActionCreator).type;
    }
    return boundActionCreator as ActionCreatorType;
}




/**
 * Redux's original `bindActionCreators` clone with extended `Action`
 * support (original function assumes dispatch parametrized with redux's
 * `AnyAction` which is not compatible with `Action`).
 *
 * Turns an object with action creators into an object with every
 * action creator wrapped into a `dispatch` call.
 *
 * @function bindActionCreators
 * @param actionCreators Object with action creator functions
 * @param dispatch redux store's `dispatch` function
 * @returns Object with wrapped action creators
 */
export function bindActionCreators<
    ActionCreatorType extends Fun,
    ReduxDispatch extends Fun<[Action]>,
    ActionCreators extends Record<SafeKey, ActionCreatorType | ActionCreator>,
> (
    actionCreators: ActionCreators,
    dispatch: ReduxDispatch,
): typeof actionCreators {
    return (
        objectMap(actionCreators) (
            ([k, a]) => [k, bindActionCreator(a, dispatch)],
        )
    ) as unknown as ActionCreators;
}




/**
 * Bind whole tree of action creators to the redux's dispatch function.
 *
 * @function bindActionCreatorsTree
 * @param acTree Object with `actionCreators` objects
 * @param dispatch redux store's `dispatch` function
 * @returns Object with wrapped action creators
 */
export function bindActionCreatorsTree<
    ActionCreatorType extends Fun,
    ReduxDispatch extends Fun<[Action]>,
    ActionCreators extends Record<SafeKey, ActionCreatorType | ActionCreator>,
    ACTree extends Record<keyof ACTree, ActionCreators>,
> (
    acTree: ACTree,
    dispatch: ReduxDispatch,
): ACTree {
    return objectMap(acTree) (
        ([k, a]) => [k, bindActionCreators(a, dispatch)],
    ) as ACTree;
}