Created
May 9, 2018 01:58
-
-
Save wldcordeiro/5704f6a6545b5d6e01fe02536ae19e51 to your computer and use it in GitHub Desktop.
Conductor Epics
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const ORCHESTRATION_CLEAR = 'orchestration/CLEAR' | |
export function clearOrchestration(id) { | |
return { type: ORCHESTRATION_CLEAR, id } | |
} | |
export const ORCHESTRATION_PROGRESS = 'orchestration/PROGRESS' | |
export function orchestrationProgress(initAction, state) { | |
return { type: ORCHESTRATION_PROGRESS, initAction, state } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { combineReducers } from 'redux' | |
import { ORCHESTRATION_CLEAR, ORCHESTRATION_PROGRESS } from './actions' | |
export function orchestrationStates(state = {}, action) { | |
switch (action.type) { | |
case ORCHESTRATION_PROGRESS: | |
return { | |
...state, | |
[action.initAction]: { | |
...state[action.initAction], | |
...action.state, | |
}, | |
} | |
case ORCHESTRATION_CLEAR: | |
return { ...state, [action.id]: undefined } | |
default: | |
return state | |
} | |
} | |
export default combineReducers({ orchestrationStates }) | |
export { default as makeConductorEpic } from './make-conductor' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Observable } from 'rx' | |
import { orchestrationProgress } from './actions' | |
/* | |
* makeConductor - this function produces an epic for the purpose of orchestrating | |
* sequences of epics. It takes as arguments an `initAction`, an `actionMap`, | |
* an `initialStateObject` and an optional `customFilter` function and produces | |
* an epic that will go through your actionMap and call each handler for its | |
* action in the sequence, and emit a `progressAction` and `nextAction`. | |
* | |
* Arguments: | |
* initAction - the intializing action | |
* actionMap - the actionMap is object map keyed by action types where each member | |
* is a handler function called with the following signature. | |
* | |
* (lastState, action, store) => { state, nextAction } | |
* | |
* The lastState argument is the state of the sequence (the shape of which is | |
* provided by the initStateObj argument documented below.), action is the action | |
* the handler is tied to, and store is the redux store. The return object is | |
* the new sequence state, and an optional nextAction which can be a single action, | |
* an array of actions, null or left off completely. In the case of a single | |
* or many actions those are fired after the progress action while if it is null | |
* or undefined we do not fire a next action. | |
* | |
* Example actionMap | |
* | |
* const TEST_ACTION = 'TEST_ACTION' | |
* const testAction = jest.fn(() => ({ type: TEST_ACTION })) | |
* const TEST_ACTION2 = 'TEST_ACTION2' | |
* const testActionMap = { | |
* [TEST_ACTION]: () => ({ | |
* state: { ...testState, initiated: true }, | |
* nextAction: testAction2(), | |
* }), | |
* [TEST_ACTION2]: lastState => ({ | |
* state: { ...lastState, thing1: true }, | |
* }), | |
* } | |
* | |
* initStateObject - an object of boolean flags for completion of steps in the | |
* sequence. The only constraint is that there must be an `initiated` key, the | |
* others can be named whatever you chose. | |
* | |
* Example initStateObj | |
* | |
* const testState = { initiated: false, thing1: false, thing2: false } | |
* | |
* customFilter - an optional function that can be used to filter the actions in | |
* your sequence further than `.ofType`, it is called with each action and the store. | |
* | |
* Example customFilter | |
* | |
* const customFilter = action => | |
* action.type === TEST_ACTION4 ? action.foo > 5 : true | |
* | |
* Return value: conductorEpic - this is your conductor. You must now connect it | |
* to the store by passing it to combineEpics | |
* | |
* const testConducterEpic = makeConductorEpic( | |
* TEST_ACTION, | |
* testActionMap, | |
* testState, | |
* customFilter | |
* ) | |
* | |
*/ | |
export default function makeConductorEpic( | |
initAction, | |
actionMap, | |
initStateObj, | |
customFilter | |
) { | |
const scanSeed = { | |
state: { ...initStateObj }, | |
nextAction: null, | |
progressAction: null, | |
} | |
return function conductorEpic(action$, store) { | |
// Take the last state and the object returned from the actionMap handler and | |
// return an object prepared for the scan and mergeMap steps. | |
function mapToTick(lastState, action) { | |
const { state: nextState, nextAction } = actionMap[action.type]( | |
lastState, | |
action, | |
store | |
) | |
// Check if our orchestratration has begun, ended or if the step is | |
// repeating and don't produce a new progress tick or next action | |
const isNotInit = action.type !== initAction | |
const notInitiated = !lastState.initiated && !nextState.initiated | |
const seqCompleted = Object.values(lastState).every(v => v === true) | |
const seqUnchanged = | |
JSON.stringify(nextState) === JSON.stringify(lastState) | |
if (isNotInit && (notInitiated || seqCompleted || seqUnchanged)) { | |
return { state: lastState, progressAction: null, nextAction: null } | |
} | |
return { | |
state: nextState, | |
progressAction: orchestrationProgress(initAction, nextState), | |
nextAction: [].concat(nextAction).filter(i => i), | |
} | |
} | |
const seqAction$ = action$.ofType(...Object.keys(actionMap)) | |
return Observable.if( | |
() => customFilter == null, | |
seqAction$, | |
seqAction$.filter(action => customFilter(action, store)) | |
) | |
.scan( | |
({ state: lastState }, action) => mapToTick(lastState, action), | |
scanSeed | |
) | |
.mergeMap(({ progressAction, nextAction }) => { | |
if (progressAction == null) { | |
return Observable.empty() | |
} | |
const progress$ = Observable.of(progressAction) | |
if (nextAction == null || nextAction.length === 0) { | |
return progress$ | |
} | |
const nextAction$ = nextAction.map(action => Observable.of(action)) | |
return Observable.concat(progress$, ...nextAction$) | |
}) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-env jest */ | |
import { ActionsObservable } from 'redux-observable' | |
import makeConductorEpic from './make-conductor' | |
import { snapshotOrchestration } from './test-orchestration' | |
const TEST_ACTION = 'TEST_ACTION' | |
const testAction = jest.fn(() => ({ type: TEST_ACTION })) | |
const TEST_ACTION2 = 'TEST_ACTION2' | |
const testAction2 = jest.fn(() => ({ type: TEST_ACTION2 })) | |
const TEST_ACTION3 = 'TEST_ACTION3' | |
const testAction3 = jest.fn(() => ({ type: TEST_ACTION3 })) | |
export const testState = { | |
initiated: false, | |
thing1: false, | |
thing2: false, | |
} | |
const testActionMap = { | |
[TEST_ACTION]: jest.fn(() => ({ | |
state: { ...testState, initiated: true }, | |
nextAction: testAction2(), | |
})), | |
[TEST_ACTION2]: jest.fn(lastState => ({ | |
state: { ...lastState, thing1: true }, | |
nextAction: testAction3(), | |
})), | |
[TEST_ACTION3]: jest.fn(lastState => ({ | |
state: { ...lastState, thing2: true }, | |
})), | |
} | |
const testConducterEpic = makeConductorEpic( | |
TEST_ACTION, | |
testActionMap, | |
testState | |
) | |
const action$ = ActionsObservable.of(testAction(), testAction2(), testAction3()) | |
const outOfSeq$ = ActionsObservable.of(testAction2(), testAction3()) | |
const TEST_ACTION4 = 'TEST_ACTION4' | |
const testAction4 = foo => ({ type: TEST_ACTION4, foo }) | |
const customFilter$ = ActionsObservable.of( | |
testAction(), | |
testAction2(), | |
testAction3(), | |
testAction4(1), | |
testAction4(10) | |
) | |
const extendedTestState = { ...testState, thing3: false } | |
const extendedTestActionMap = { | |
...testActionMap, | |
[TEST_ACTION]: jest.fn(() => ({ | |
state: { ...extendedTestState, initiated: true }, | |
nextAction: testAction2(), | |
})), | |
[TEST_ACTION3]: jest.fn(state => ({ | |
state: { ...state, thing2: true }, | |
nextAction: [testAction4(1), testAction4(10)], | |
})), | |
[TEST_ACTION4]: jest.fn(state => ({ | |
state: { ...state, thing3: true }, | |
})), | |
} | |
const customFilter = action => | |
action.type === TEST_ACTION4 ? action.foo > 5 : true | |
const customFilterTestConducterEpic = makeConductorEpic( | |
TEST_ACTION, | |
extendedTestActionMap, | |
extendedTestState, | |
customFilter | |
) | |
describe('makeConductorEpic', () => { | |
test('normal conductor epic', () => { | |
snapshotOrchestration(testConducterEpic(action$)) | |
}) | |
test('ignore out of sequence actions when not initiated', () => { | |
snapshotOrchestration(testConducterEpic(outOfSeq$)) | |
}) | |
test('customFilter', () => { | |
snapshotOrchestration(customFilterTestConducterEpic(customFilter$)) | |
}) | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Jest Snapshot v1, https://goo.gl/fbAQLP | |
exports[`makeConductorEpic customFilter 1`] = ` | |
β³ ORCHESTRATION PROGRESS: | |
β : initiated | |
π: thing1 | |
π: thing2 | |
π: thing3 | |
π₯ ORCHESTRATION ACTION: | |
type: TEST_ACTION2 | |
β³ ORCHESTRATION PROGRESS: | |
β : initiated | |
β : thing1 | |
π: thing2 | |
π: thing3 | |
π₯ ORCHESTRATION ACTION: | |
type: TEST_ACTION3 | |
β³ ORCHESTRATION PROGRESS: | |
β : initiated | |
β : thing1 | |
β : thing2 | |
π: thing3 | |
π₯ ORCHESTRATION ACTION: | |
type: TEST_ACTION4 | |
foo: 1 | |
π₯ ORCHESTRATION ACTION: | |
type: TEST_ACTION4 | |
foo: 10 | |
β ORCHESTRATION PROGRESS: | |
β : initiated | |
β : thing1 | |
β : thing2 | |
β : thing3 | |
`; | |
exports[`makeConductorEpic ignore out of sequence actions when not initiated 1`] = ``; | |
exports[`makeConductorEpic normal conductor epic 1`] = ` | |
β³ ORCHESTRATION PROGRESS: | |
β : initiated | |
π: thing1 | |
π: thing2 | |
π₯ ORCHESTRATION ACTION: | |
type: TEST_ACTION2 | |
β³ ORCHESTRATION PROGRESS: | |
β : initiated | |
β : thing1 | |
π: thing2 | |
π₯ ORCHESTRATION ACTION: | |
type: TEST_ACTION3 | |
β ORCHESTRATION PROGRESS: | |
β : initiated | |
β : thing1 | |
β : thing2 | |
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-env jest */ | |
import { ORCHESTRATION_PROGRESS } from './actions' | |
export function formatAction(action, serialize, title = 'ACTION') { | |
const { type, ...restOfAction } = action | |
const firstLine = `π₯ ${title}:\n\ntype: ${type}` | |
const actionHasKeys = | |
Object.keys(restOfAction).filter(a => a !== 'type').length > 0 | |
if (actionHasKeys) { | |
const formattedRest = Object.entries(restOfAction) | |
.map(([key, value]) => `${key}: ${serialize(value)}`) | |
.join('\n') | |
return `${firstLine}\n${formattedRest}\n` | |
} | |
return `${firstLine}\n` | |
} | |
function formatProgress(action) { | |
const formattedState = Object.entries(action.state) | |
.map(([stepName, value]) => `${value ? 'β ' : 'π'}: ${stepName}`) | |
.join('\n') | |
const progressIcon = Object.values(action.state).every(v => v === true) | |
? 'β' | |
: 'β³' | |
return `${progressIcon} ORCHESTRATION PROGRESS:\n\n${formattedState}\n` | |
} | |
export function snapshotOrchestration(epic) { | |
expect.addSnapshotSerializer({ | |
test(value) { | |
return Array.isArray(value) && value.every(i => i.type != null &&) | |
}, | |
print(actions, serialize) { | |
return actions | |
.map(action => { | |
switch (action.type) { | |
case ORCHESTRATION_PROGRESS: | |
return formatProgress(action) | |
default: | |
return formatAction(action, serialize, 'ORCHESTRATION ACTION') | |
} | |
}) | |
.join('\n') | |
}, | |
}) | |
epic.toArray().subscribe(actions => expect(actions).toMatchSnapshot()) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment