import { isEqual, stubTrue, negate } from 'lodash'; import { AnyAction } from 'redux'; import {concat, of, Observable, from, Observer, OperatorFunction} from 'rxjs'; import {catchError, filter, mergeMap, takeWhile, switchMap, concatMap, tap} from 'rxjs/operators'; import { StateObservable, ActionsObservable } from 'redux-observable'; import { notifyUnhandledError } from 'wdk-client/Actions/UnhandledErrorActions'; import { EpicDependencies, ModuleEpic } from 'wdk-client/Core/Store'; export interface Action { readonly type: Type; readonly payload: Payload; } export interface ActionTypeGuard> { (action: { type: string }): action is T; } export interface ActionTypeGuardContainer> { isOfType: ActionTypeGuard; } export type ActionCreator = T extends Action ? Args extends any[] ? { readonly type: Type; (...args: Args): T } & ActionTypeGuardContainer : { readonly type: Type; (): T } & ActionTypeGuardContainer : never // Utility type to infer the Action type from the ActionCreator export type InferAction = T extends ActionCreator ? A : T extends ActionCreator ? A : never; // This is the main utility function export function makeActionCreator( type: Type ) : ActionCreator, []>; export function makeActionCreator( type: Type, createPayload: (...args: Args) => Payload ) : ActionCreator, Args> export function makeActionCreator( type: Type, createPayload?: (...args: Args) => Payload ) { function createAction(...args: Args) { return { type, payload: createPayload && createPayload(...args) }; } function isOfType(otherAction: { type: string }): otherAction is Action { return otherAction.type === type; } return Object.assign(createAction, { type, isOfType }); } export type GenericActionCreator = ActionCreator, any>; export interface Request2Fulfill { ( requestActions: T, state: StateObservable, dependencies: EpicDependencies ): Promise } export interface MapRequestActionsToEpicOptions { areActionsNew?: (actions: T, prevActions: T) => boolean; areActionsCoherent?: (actions: T, state: State) => boolean; } interface Pred { (value: T): boolean; } /** * A factory for `OperatorFunction`. Examples of `OperatorFactory` * are `mergeMap`, `concatMap`, and `switchMap` from `rxjs/operators`. */ type MapOperatorFactory = typeof mergeMap; /** * Creates an Epic that calls `request2Fulfill` when actions matching the * supplied set of ActionCreators have been dispatched. `request2Fulfill` is * called when all of the actions have been dispatched, and when any of them * are dispatched thereafter. */ interface MapRequestActionsToEpic { // The following overloads are so that we can capture type parameters for // ActionCreators being passed. Each overload specifies the length of the // array of action creators accepted, which should also match the length of the // array of actions accepted by `request2Fulfill`. Note that the elements in // these arrays should correspond to one another by position. // // Add more overloads when needed. // 1 request action < A1 extends Action, State >( actionCreators: [ ActionCreator ], request2Fulfill: Request2Fulfill<[ A1 ], State>, options?: MapRequestActionsToEpicOptions<[ A1 ], State> ): ModuleEpic; // 2 request action < A1 extends Action, A2 extends Action, State >( actionCreators: [ ActionCreator, ActionCreator ], request2Fulfill: Request2Fulfill<[ A1, A2 ], State>, options?: MapRequestActionsToEpicOptions<[ A1, A2 ], State> ): ModuleEpic; // 3 request action < A1 extends Action, A2 extends Action, A3 extends Action, State >( actionCreators: [ ActionCreator, ActionCreator, ActionCreator ], request2Fulfill: Request2Fulfill<[ A1, A2, A3 ], State>, options?: MapRequestActionsToEpicOptions<[ A1, A2, A3 ], State> ): ModuleEpic; // 4 request action < A1 extends Action, A2 extends Action, A3 extends Action, A4 extends Action, State >( actionCreators: [ ActionCreator, ActionCreator, ActionCreator, ActionCreator ], request2Fulfill: Request2Fulfill<[ A1, A2, A3, A4 ], State>, options?: MapRequestActionsToEpicOptions<[ A1, A2, A3, A4 ], State> ): ModuleEpic; // 5 request action < A1 extends Action, A2 extends Action, A3 extends Action, A4 extends Action, A5 extends Action, State >( actionCreators: [ ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator ], request2Fulfill: Request2Fulfill<[ A1, A2, A3, A4, A5 ], State>, options?: MapRequestActionsToEpicOptions<[ A1, A2, A3, A4, A5 ], State> ): ModuleEpic; // 6 request action < A1 extends Action, A2 extends Action, A3 extends Action, A4 extends Action, A5 extends Action, A6 extends Action, State >( actionCreators: [ ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator ], request2Fulfill: Request2Fulfill<[ A1, A2, A3, A4, A5, A6 ], State>, options?: MapRequestActionsToEpicOptions<[ A1, A2, A3, A4, A5, A6 ], State> ): ModuleEpic; // 7 request action < A1 extends Action, A2 extends Action, A3 extends Action, A4 extends Action, A5 extends Action, A6 extends Action, A7 extends Action, State >( actionCreators: [ ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator ], request2Fulfill: Request2Fulfill<[ A1, A2, A3, A4, A5, A6, A7 ], State>, options?: MapRequestActionsToEpicOptions<[ A1, A2, A3, A4, A5, A6, A7 ], State> ): ModuleEpic; // 8 request action < A1 extends Action, A2 extends Action, A3 extends Action, A4 extends Action, A5 extends Action, A6 extends Action, A7 extends Action, A8 extends Action, State >( actionCreators: [ ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator, ActionCreator ], request2Fulfill: Request2Fulfill<[ A1, A2, A3, A4, A5, A6, A7, A8 ], State>, options?: MapRequestActionsToEpicOptions<[ A1, A2, A3, A4, A5, A6, A7, A8 ], State> ): ModuleEpic; } /** * Factory that creates a `MapRequestActionsToEpic` function with the supplied * `MapOperatorFactory`. */ export const mapRequestActionsToEpicWith = (mapOperatorFactory: MapOperatorFactory): MapRequestActionsToEpic => ( actionCreators: GenericActionCreator[], request2Fulfill: Request2Fulfill, options: MapRequestActionsToEpicOptions = {} ): ModuleEpic => { return function mapRequestActionsEpic( action$: Observable, state$: StateObservable, dependencies: EpicDependencies, ) { const actionStreams = actionCreators.map(ac => action$.pipe(filter(ac.isOfType))); const filterActions = makeFilterActions(options); // track previous filtered actions to be passed to `filterActions` let prevFilteredActions: AnyAction[] | undefined = undefined; const combined$ = combineLatestIf(actionStreams, actions => { const ret = filterActions(actions, prevFilteredActions, state$.value); // only update `prevFilteredActions` if `filterActions` returns `true` if (ret) prevFilteredActions = actions; return ret; }); return combined$.pipe( mapOperatorFactory(actions => from(request2Fulfill(actions, state$, dependencies))), ); }; } function makeFilterActions(options: MapRequestActionsToEpicOptions) { const { areActionsCoherent = stubTrue, areActionsNew = negate(isEqual) } = options; return function filterActions(actions: T, prevActions: T | undefined, state: S): boolean { return ( areActionsCoherent(actions, state) && ( prevActions == null || areActionsNew(actions, prevActions) ) ); } } /** * `MapRequestActionsToEpic` that uses `mergeMap` to rate limit output actions. */ export const mergeMapRequestActionsToEpic: MapRequestActionsToEpic = mapRequestActionsToEpicWith(mergeMap); /** * `MapRequestActionsToEpic` that uses `concatMap` to rate limit output actions. */ export const concatMapRequestActionsToEpic: MapRequestActionsToEpic = mapRequestActionsToEpicWith(concatMap); /** * `MapRequestActionsToEpic` that uses `switchMap` to rate limit output actions. */ export const switchMapRequestActionsToEpic: MapRequestActionsToEpic = mapRequestActionsToEpicWith(switchMap); interface TakeEpicInWindowOptions { startActionCreator: ActionCreator; endActionCreator: ActionCreator; compareStartAndEndActions?: (startAction: StartAction, endAction: EndAction) => boolean; } /** * Starts the target epic when `startAction` is emitted, until `endAction` is * emitted. */ export function takeEpicInWindow( options: TakeEpicInWindowOptions, epic: ModuleEpic, ): ModuleEpic { const { startActionCreator, endActionCreator, compareStartAndEndActions = stubTrue } = options; return function takeUntilEpic(action$, state$, deps) { // TODO Add logging diagnostics return action$.pipe( filter(startActionCreator.isOfType), mergeMap((startAction: StartAction) => { // FIXME New epics are starting before previous are ending const logTag = `[${startActionCreator.type} - ${endActionCreator.type} -- ${JSON.stringify(startAction)}]`; const log = (...args: any[]) => console.log(logTag, '--', ...args); const window$ = concat( of(startAction), action$.pipe( // Filter out other start actions. We might want to do some sort of comparison with startAction...? filter(action => !startActionCreator.isOfType(action)), takeWhile(action => { return ( !endActionCreator.isOfType(action) || !compareStartAndEndActions(startAction, action as EndAction) ); }) ) ); log('starting epic', startAction); return epic(ActionsObservable.from(window$), state$, deps).pipe( tap( action => { log('action produced by epic in window', action.type, 'payload' in action ? action.payload : undefined); }, error => { log('error produced by epic in window', error); }, () => { log('ending epic', startAction); } ) ); }), ); }; } export function combineLatestIf(s1: Observable, pred: Pred<[T]>): Observable<[T]>; export function combineLatestIf(s1: Observable, s2: Observable, pred: Pred<[T, T2]>): Observable<[T, T2]>; export function combineLatestIf(s1: Observable, s2: Observable, s3: Observable, pred: Pred<[T, T2, T3]>): Observable<[T, T2, T3]>; export function combineLatestIf(s1: Observable, s2: Observable, s3: Observable, s4: Observable, pred: Pred<[T, T2, T3, T4]>): Observable<[T, T2, T3, T4]>; export function combineLatestIf(s1: Observable, s2: Observable, s3: Observable, s4: Observable, s5: Observable, pred: Pred<[T, T2, T3, T4, T5]>): Observable<[T, T2, T3, T4, T5]>; export function combineLatestIf(s1: Observable, s2: Observable, s3: Observable, s4: Observable, s5: Observable, s6: Observable, pred: Pred<[T, T2, T3, T4, T5, T6]>): Observable<[T, T2, T3, T4, T5, T6]>; export function combineLatestIf(s1: Observable, s2: Observable, s3: Observable, s4: Observable, s5: Observable, s6: Observable, s7: Observable, pred: Pred<[T, T2, T3, T4, T5, T6, T7]>): Observable<[T, T2, T3, T4, T5, T6, T7]>; export function combineLatestIf(s1: Observable, s2: Observable, s3: Observable, s4: Observable, s5: Observable, s6: Observable, s7: Observable, s8: Observable, pred: Pred<[T, T2, T3, T4, T5, T6, T7, T8]>): Observable<[T, T2, T3, T4, T5, T6, T7, T8]>; export function combineLatestIf(sources: Array>, pred: (values: T[]) => boolean): Observable export function combineLatestIf(...args: Array | Array> | ((values: any) => boolean)>): Observable { const pred = args[args.length - 1] as (values: T[]) => boolean; const rest = args.slice(0, -1); const sources = ( rest.length === 1 && Array.isArray(rest[0]) ? rest[0] : rest ) as Array>; return Observable.create(function subscribe(observer: Observer) { // latest value of each source const currentValue: T[] = []; // set of indexes of sources that have emitted let emitted = new Set(); // incremented when each source completes let completedCount = 0; // create array of source subscriptions const subscriptions = sources.map((source$, index) => { return source$.subscribe({ next: value => { emitted.add(index); const oldValue = currentValue[index]; currentValue[index] = value; if (emitted.size === sources.length) { const valueToEmit = currentValue.slice(); if (pred(valueToEmit)) { observer.next(valueToEmit); } else { currentValue[index] = oldValue; } } }, error: error => observer.error(error), complete: () => { completedCount++; if (completedCount === sources.length) { observer.complete(); } } }); }); // clean up logic to unsubscribe to source subscriptions return function unsubscribe() { subscriptions.forEach(s => s.unsubscribe()); }; }); } // Utility to partially apply a function. This type definiton works with how we // define ActionCreator. Eventually, we want to curry ActionCreator. export type Partial1 = T extends (soleArg: infer sole) => infer Return ? () => Return : T extends (first: infer first, ...rest: infer Rest) => infer Return ? (...rest: Rest) => Return : never; export function partial(fn: F, t: T): Partial1; export function partial(fn: Function, t: any) { return function (...args: any[]) { return fn(t, ...args); } }