Skip to content

Instantly share code, notes, and snippets.

@wldcordeiro
Created May 9, 2018 01:58
Show Gist options
  • Save wldcordeiro/5704f6a6545b5d6e01fe02536ae19e51 to your computer and use it in GitHub Desktop.
Save wldcordeiro/5704f6a6545b5d6e01fe02536ae19e51 to your computer and use it in GitHub Desktop.
Conductor Epics
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 }
}
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'
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$)
})
}
}
/* 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$))
})
})
// 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
`;
/* 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