initial commit
This commit is contained in:
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { actions } from './reducer';
|
||||
import type { ActionType, ActionCallbackType } from './types';
|
||||
|
||||
export const emitterCallback = (
|
||||
type: string,
|
||||
observerDispatch: React.Dispatch< ActionType >
|
||||
) => ( callback: ActionCallbackType, priority = 10 ): ( () => void ) => {
|
||||
const action = actions.addEventCallback( type, callback, priority );
|
||||
observerDispatch( action );
|
||||
return () => {
|
||||
observerDispatch( actions.removeEventCallback( type, action.id ) );
|
||||
};
|
||||
};
|
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getObserversByPriority } from './utils';
|
||||
import type { EventObserversType } from './types';
|
||||
import { isErrorResponse, isFailResponse } from '../hooks/use-emit-response';
|
||||
|
||||
/**
|
||||
* Emits events on registered observers for the provided type and passes along
|
||||
* the provided data.
|
||||
*
|
||||
* This event emitter will silently catch promise errors, but doesn't care
|
||||
* otherwise if any errors are caused by observers. So events that do care
|
||||
* should use `emitEventWithAbort` instead.
|
||||
*
|
||||
* @param {Object} observers The registered observers to omit to.
|
||||
* @param {string} eventType The event type being emitted.
|
||||
* @param {*} data Data passed along to the observer when it is invoked.
|
||||
*
|
||||
* @return {Promise} A promise that resolves to true after all observers have executed.
|
||||
*/
|
||||
export const emitEvent = async (
|
||||
observers: EventObserversType,
|
||||
eventType: string,
|
||||
data: unknown
|
||||
): Promise< unknown > => {
|
||||
const observersByType = getObserversByPriority( observers, eventType );
|
||||
const observerResponses = [];
|
||||
for ( const observer of observersByType ) {
|
||||
try {
|
||||
const observerResponse = await Promise.resolve(
|
||||
observer.callback( data )
|
||||
);
|
||||
if ( typeof observerResponse === 'object' ) {
|
||||
observerResponses.push( observerResponse );
|
||||
}
|
||||
} catch ( e ) {
|
||||
// we don't care about errors blocking execution, but will console.error for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
}
|
||||
return observerResponses.length ? observerResponses : true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emits events on registered observers for the provided type and passes along
|
||||
* the provided data. This event emitter will abort if an observer throws an
|
||||
* error or if the response includes an object with an error type property.
|
||||
*
|
||||
* Any successful observer responses before abort will be included in the returned package.
|
||||
*
|
||||
* @param {Object} observers The registered observers to omit to.
|
||||
* @param {string} eventType The event type being emitted.
|
||||
* @param {*} data Data passed along to the observer when it is invoked.
|
||||
*
|
||||
* @return {Promise} Returns a promise that resolves to either boolean, or an array of responses
|
||||
* from registered observers that were invoked up to the point of an error.
|
||||
*/
|
||||
export const emitEventWithAbort = async (
|
||||
observers: EventObserversType,
|
||||
eventType: string,
|
||||
data: unknown
|
||||
): Promise< Array< unknown > > => {
|
||||
const observerResponses = [];
|
||||
const observersByType = getObserversByPriority( observers, eventType );
|
||||
for ( const observer of observersByType ) {
|
||||
try {
|
||||
const response = await Promise.resolve( observer.callback( data ) );
|
||||
if ( typeof response !== 'object' || response === null ) {
|
||||
continue;
|
||||
}
|
||||
if ( ! response.hasOwnProperty( 'type' ) ) {
|
||||
throw new Error(
|
||||
'Returned objects from event emitter observers must return an object with a type property'
|
||||
);
|
||||
}
|
||||
if ( isErrorResponse( response ) || isFailResponse( response ) ) {
|
||||
observerResponses.push( response );
|
||||
// early abort.
|
||||
return observerResponses;
|
||||
}
|
||||
// all potential abort conditions have been considered push the
|
||||
// response to the array.
|
||||
observerResponses.push( response );
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
observerResponses.push( { type: 'error' } );
|
||||
return observerResponses;
|
||||
}
|
||||
}
|
||||
return observerResponses;
|
||||
};
|
@ -0,0 +1,4 @@
|
||||
export * from './reducer';
|
||||
export * from './emitters';
|
||||
export * from './emitter-callback';
|
||||
export * from './types';
|
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
ACTION,
|
||||
ActionType,
|
||||
ActionCallbackType,
|
||||
EventObserversType,
|
||||
} from './types';
|
||||
|
||||
export const actions = {
|
||||
addEventCallback: (
|
||||
eventType: string,
|
||||
callback: ActionCallbackType,
|
||||
priority = 10
|
||||
): ActionType => {
|
||||
return {
|
||||
id: uniqueId(),
|
||||
type: ACTION.ADD_EVENT_CALLBACK,
|
||||
eventType,
|
||||
callback,
|
||||
priority,
|
||||
};
|
||||
},
|
||||
removeEventCallback: ( eventType: string, id: string ): ActionType => {
|
||||
return {
|
||||
id,
|
||||
type: ACTION.REMOVE_EVENT_CALLBACK,
|
||||
eventType,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const initialState = {} as EventObserversType;
|
||||
|
||||
/**
|
||||
* Handles actions for emitters
|
||||
*/
|
||||
export const reducer = (
|
||||
state = initialState,
|
||||
{ type, eventType, id, callback, priority }: ActionType
|
||||
): typeof initialState => {
|
||||
const newEvents = state.hasOwnProperty( eventType )
|
||||
? new Map( state[ eventType ] )
|
||||
: new Map();
|
||||
switch ( type ) {
|
||||
case ACTION.ADD_EVENT_CALLBACK:
|
||||
newEvents.set( id, { priority, callback } );
|
||||
return {
|
||||
...state,
|
||||
[ eventType ]: newEvents,
|
||||
};
|
||||
case ACTION.REMOVE_EVENT_CALLBACK:
|
||||
newEvents.delete( id );
|
||||
return {
|
||||
...state,
|
||||
[ eventType ]: newEvents,
|
||||
};
|
||||
}
|
||||
};
|
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { emitEvent, emitEventWithAbort } from '../emitters';
|
||||
|
||||
describe( 'Testing emitters', () => {
|
||||
let observerMocks = {};
|
||||
let observerA;
|
||||
let observerB;
|
||||
let observerPromiseWithResolvedValue;
|
||||
beforeEach( () => {
|
||||
observerA = jest.fn().mockReturnValue( true );
|
||||
observerB = jest.fn().mockReturnValue( true );
|
||||
observerPromiseWithResolvedValue = jest.fn().mockResolvedValue( 10 );
|
||||
observerMocks = new Map( [
|
||||
[ 'observerA', { priority: 10, callback: observerA } ],
|
||||
[ 'observerB', { priority: 10, callback: observerB } ],
|
||||
[
|
||||
'observerReturnValue',
|
||||
{ priority: 10, callback: jest.fn().mockReturnValue( 10 ) },
|
||||
],
|
||||
[
|
||||
'observerPromiseWithReject',
|
||||
{
|
||||
priority: 10,
|
||||
callback: jest.fn().mockRejectedValue( 'an error' ),
|
||||
},
|
||||
],
|
||||
[
|
||||
'observerPromiseWithResolvedValue',
|
||||
{ priority: 10, callback: observerPromiseWithResolvedValue },
|
||||
],
|
||||
[
|
||||
'observerSuccessType',
|
||||
{
|
||||
priority: 10,
|
||||
callback: jest.fn().mockReturnValue( { type: 'success' } ),
|
||||
},
|
||||
],
|
||||
] );
|
||||
} );
|
||||
describe( 'Testing emitEvent()', () => {
|
||||
it( 'invokes all observers', async () => {
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEvent( observers, 'test', 'foo' );
|
||||
expect( console ).toHaveErroredWith( 'an error' );
|
||||
expect( observerA ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerB ).toHaveBeenCalledWith( 'foo' );
|
||||
expect( response ).toEqual( [ { type: 'success' } ] );
|
||||
} );
|
||||
} );
|
||||
describe( 'Testing emitEventWithAbort()', () => {
|
||||
it( 'does not abort on any return value other than an object with an error or fail type property', async () => {
|
||||
observerMocks.delete( 'observerPromiseWithReject' );
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEventWithAbort(
|
||||
observers,
|
||||
'test',
|
||||
'foo'
|
||||
);
|
||||
expect( console ).not.toHaveErrored();
|
||||
expect( observerB ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerPromiseWithResolvedValue ).toHaveBeenCalled();
|
||||
expect( response ).toEqual( [ { type: 'success' } ] );
|
||||
} );
|
||||
it( 'Aborts on a return value with an object that has a a fail type property', async () => {
|
||||
const validObjectResponse = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'failure' } );
|
||||
observerMocks.set( 'observerValidObject', {
|
||||
priority: 5,
|
||||
callback: validObjectResponse,
|
||||
} );
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEventWithAbort(
|
||||
observers,
|
||||
'test',
|
||||
'foo'
|
||||
);
|
||||
expect( console ).not.toHaveErrored();
|
||||
expect( validObjectResponse ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerPromiseWithResolvedValue ).not.toHaveBeenCalled();
|
||||
expect( response ).toEqual( [ { type: 'failure' } ] );
|
||||
} );
|
||||
it( 'throws an error on an object returned from observer without a type property', async () => {
|
||||
const failingObjectResponse = jest.fn().mockReturnValue( {} );
|
||||
observerMocks.set( 'observerInvalidObject', {
|
||||
priority: 5,
|
||||
callback: failingObjectResponse,
|
||||
} );
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEventWithAbort(
|
||||
observers,
|
||||
'test',
|
||||
'foo'
|
||||
);
|
||||
expect( console ).toHaveErrored();
|
||||
expect( failingObjectResponse ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerPromiseWithResolvedValue ).not.toHaveBeenCalled();
|
||||
expect( response ).toEqual( [ { type: 'error' } ] );
|
||||
} );
|
||||
} );
|
||||
describe( 'Test Priority', () => {
|
||||
it( 'executes observers in expected order by priority', async () => {
|
||||
const a = jest.fn();
|
||||
const b = jest.fn().mockReturnValue( { type: 'error' } );
|
||||
const observers = {
|
||||
test: new Map( [
|
||||
[ 'observerA', { priority: 200, callback: a } ],
|
||||
[ 'observerB', { priority: 10, callback: b } ],
|
||||
] ),
|
||||
};
|
||||
await emitEventWithAbort( observers, 'test', 'foo' );
|
||||
expect( console ).not.toHaveErrored();
|
||||
expect( b ).toHaveBeenCalledTimes( 1 );
|
||||
expect( a ).not.toHaveBeenCalled();
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,18 @@
|
||||
export enum ACTION {
|
||||
ADD_EVENT_CALLBACK = 'add_event_callback',
|
||||
REMOVE_EVENT_CALLBACK = 'remove_event_callback',
|
||||
}
|
||||
|
||||
export type ActionCallbackType = ( ...args: unknown[] ) => unknown;
|
||||
|
||||
export type ActionType = {
|
||||
type: ACTION;
|
||||
eventType: string;
|
||||
id: string;
|
||||
callback?: ActionCallbackType;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type ObserverType = { priority: number; callback: ActionCallbackType };
|
||||
export type ObserversType = Map< string, ObserverType >;
|
||||
export type EventObserversType = Record< string, ObserversType >;
|
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { EventObserversType, ObserverType } from './types';
|
||||
|
||||
export const getObserversByPriority = (
|
||||
observers: EventObserversType,
|
||||
eventType: string
|
||||
): ObserverType[] => {
|
||||
return observers[ eventType ]
|
||||
? Array.from( observers[ eventType ].values() ).sort( ( a, b ) => {
|
||||
return a.priority - b.priority;
|
||||
} )
|
||||
: [];
|
||||
};
|
Reference in New Issue
Block a user