From 98673fc316d361d76ed5dc868743ef3bc635bcbd Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 31 Aug 2016 14:06:03 -0700 Subject: [PATCH] Refactor(challenges): Move step movement logic into epic --- .../challenges/components/step/Step.jsx | 49 +++---- common/app/routes/challenges/redux/actions.js | 2 + common/app/routes/challenges/redux/index.js | 4 +- .../challenges/redux/step-challenge-epic.js | 26 ++++ .../redux/step-challenge-epic.test.js | 137 ++++++++++++++++++ common/app/routes/challenges/redux/types.js | 2 + package.json | 1 + 7 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 common/app/routes/challenges/redux/step-challenge-epic.js create mode 100644 common/app/routes/challenges/redux/step-challenge-epic.test.js diff --git a/common/app/routes/challenges/components/step/Step.jsx b/common/app/routes/challenges/components/step/Step.jsx index df8297cec56..b2689fedb16 100644 --- a/common/app/routes/challenges/components/step/Step.jsx +++ b/common/app/routes/challenges/components/step/Step.jsx @@ -7,7 +7,8 @@ import ReactTransitionReplace from 'react-css-transition-replace'; import LightBox from 'react-images'; import { - goToStep, + stepForward, + stepBackward, completeAction, submitChallenge, openLightBoxImage, @@ -38,12 +39,14 @@ const mapStateToProps = createSelector( step: description[currentIndex], steps: description, numOfSteps: description.length, + isLastStep: currentIndex + 1 >= description.length, isGoingForward: currentIndex > previousIndex }) ); const dispatchActions = { - goToStep, + stepForward, + stepBackward, completeAction, submitChallenge, openLightBoxImage, @@ -53,8 +56,6 @@ const dispatchActions = { export class StepChallenge extends PureComponent { constructor(...args) { super(...args); - this.handleNextClick = this.handleNextClick.bind(this); - this.handleBackClick = this.handleBackClick.bind(this); this.handleLightBoxOpen = this.handleLightBoxOpen.bind(this); } static displayName = 'StepChallenge'; @@ -64,8 +65,10 @@ export class StepChallenge extends PureComponent { steps: PropTypes.array, isActionCompleted: PropTypes.bool, isGoingForward: PropTypes.bool, + isLastStep: PropTypes.bool, numOfSteps: PropTypes.number, - goToStep: PropTypes.func, + stepForward: PropTypes.func, + stepBackward: PropTypes.func, completeAction: PropTypes.func, submitChallenge: PropTypes.func, isLightBoxOpen: PropTypes.bool, @@ -73,20 +76,6 @@ export class StepChallenge extends PureComponent { closeLightBoxImage: PropTypes.func }; - handleNextClick() { - const { numOfSteps, currentIndex, submitChallenge, goToStep } = this.props; - const isLastStep = currentIndex + 1 >= numOfSteps; - if (isLastStep) { - return submitChallenge(); - } - return goToStep(currentIndex + 1); - } - - handleBackClick() { - const { currentIndex, goToStep } = this.props; - goToStep(currentIndex - 1); - } - handleLightBoxOpen(e) { if (!(e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -119,7 +108,7 @@ export class StepChallenge extends PureComponent { ); } - renderBackButton(index) { + renderBackButton(index, stepBackward) { if (index === 0) { return ( Go to my previous step ); } - renderNextButton(hasAction, index, numOfSteps, isCompleted) { - const isLastStep = index + 1 >= numOfSteps; + renderNextButton(hasAction, isLastStep, isCompleted, stepForward) { const btnClass = classnames({ 'col-sm-4 col-xs-12': true, disabled: hasAction && !isCompleted @@ -154,7 +142,7 @@ export class StepChallenge extends PureComponent { bsStyle='primary' className={ btnClass } disabled={ hasAction && !isCompleted } - onClick={ this.handleNextClick } + onClick={ stepForward } > { isLastStep ? 'Finish challenge' : 'Go to my next step'} @@ -166,7 +154,10 @@ export class StepChallenge extends PureComponent { currentIndex, numOfSteps, isActionCompleted, - completeAction + completeAction, + isLastStep, + stepForward, + stepBackward }) { if (!Array.isArray(step)) { return null; @@ -206,7 +197,7 @@ export class StepChallenge extends PureComponent {
{ this.renderActionButton(action, completeAction) } - { this.renderBackButton(currentIndex) } + { this.renderBackButton(currentIndex, stepBackward) } diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index 05467fa9992..69c21668c17 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -5,6 +5,8 @@ import { getMouse, loggerToStr } from '../utils'; import types from './types'; // step +export const stepForward = createAction(types.stepForward); +export const stepBackward = createAction(types.stepBackward); export const goToStep = createAction(types.goToStep); export const completeAction = createAction(types.completeAction); export const openLightBoxImage = createAction(types.openLightBoxImage); diff --git a/common/app/routes/challenges/redux/index.js b/common/app/routes/challenges/redux/index.js index 369d42f0921..1ea0b8dbeaa 100644 --- a/common/app/routes/challenges/redux/index.js +++ b/common/app/routes/challenges/redux/index.js @@ -5,6 +5,7 @@ import answerSaga from './answer-saga'; import resetChallengeSaga from './reset-challenge-saga'; import bugSaga from './bug-saga'; import mapUiSaga from './map-ui-saga'; +import stepChallengeEpic from './step-challenge-epic'; export * as actions from './actions'; export reducer from './reducer'; @@ -19,5 +20,6 @@ export const sagas = [ answerSaga, resetChallengeSaga, bugSaga, - mapUiSaga + mapUiSaga, + stepChallengeEpic ]; diff --git a/common/app/routes/challenges/redux/step-challenge-epic.js b/common/app/routes/challenges/redux/step-challenge-epic.js new file mode 100644 index 00000000000..e969bf7a68f --- /dev/null +++ b/common/app/routes/challenges/redux/step-challenge-epic.js @@ -0,0 +1,26 @@ +import types from './types'; +import { goToStep, submitChallenge } from './actions'; +import { challengeSelector } from './selectors'; +import getActionsOfType from '../../../../utils/get-actions-of-type'; + +export default function stepChallengeEpic(actions, getState) { + return getActionsOfType( + actions, + types.stepForward, + types.stepBackward + ) + .map(({ type }) => { + const state = getState(); + const { challenge: { description = [] } } = challengeSelector(state); + const { challengesApp: { currentIndex } } = state; + const numOfSteps = description.length; + const isLastStep = currentIndex + 1 >= numOfSteps; + if (type === types.stepForward) { + if (isLastStep) { + return submitChallenge(); + } + return goToStep(currentIndex + 1); + } + return goToStep(currentIndex - 1); + }); +} diff --git a/common/app/routes/challenges/redux/step-challenge-epic.test.js b/common/app/routes/challenges/redux/step-challenge-epic.test.js new file mode 100644 index 00000000000..a637f65d615 --- /dev/null +++ b/common/app/routes/challenges/redux/step-challenge-epic.test.js @@ -0,0 +1,137 @@ +import { Observable, config } from 'rx'; +import test from 'tape'; +import proxy from 'proxyquire'; +import sinon from 'sinon'; +import types from './types'; + +config.longStackSupport = true; +const challengeSelectorStub = {}; +const stepChallengeEpic = proxy( + './step-challenge-epic', + { './selectors': challengeSelectorStub } +); + +const file = 'common/app/routes/challenges/redux/step-challenge-epic'; +test(file, function(t) { + t.test('does not respond to random actions', t => { + const actions = Observable.of({ type: 'NotTheMomma' }); + let called = false; + stepChallengeEpic(actions, () => {}) + .subscribe( + () => { called = true; }, + e => t.fail(e), + () => { + if (!called) { + t.pass(); + } else { + t.fail(new Error('epic should not respond')); + } + t.end(); + } + ); + }); + t.test('steps back', t => { + const actions = Observable.of({ type: types.stepBackward }); + const state = { challengesApp: { currentIndex: 1 } }; + const onNextSpy = sinon.spy(); + challengeSelectorStub.challengeSelector = sinon.spy(_state => { + t.assert(_state === state, 'challenge selector not called with state'); + return { + challenge: { + description: new Array(2) + } + }; + }); + stepChallengeEpic(actions, () => state) + .subscribe( + onNextSpy, + e => { + throw e; + }, + () => { + t.assert( + onNextSpy.calledOnce, + 'epic not called exactly once' + ); + t.assert( + onNextSpy.calledWithMatch({ + type: types.goToStep, + payload: 0 + }), + 'Epic did not return the expected action' + ); + delete challengeSelectorStub.challengeSelector; + t.end(); + } + ); + }); + t.test('steps forward', t => { + const actions = Observable.of({ type: types.stepForward }); + const state = { challengesApp: { currentIndex: 0 } }; + const onNextSpy = sinon.spy(); + challengeSelectorStub.challengeSelector = sinon.spy(_state => { + t.assert(_state === state, 'challenge selector not called with state'); + return { + challenge: { + description: new Array(2) + } + }; + }); + stepChallengeEpic(actions, () => state) + .subscribe( + onNextSpy, + e => { + throw e; + }, + () => { + t.assert( + onNextSpy.calledOnce, + 'epic not called exactly once' + ); + t.assert( + onNextSpy.calledWithMatch({ + type: types.goToStep, + payload: 1 + }), + 'Epic did not return the expected action' + ); + delete challengeSelectorStub.challengeSelector; + t.end(); + } + ); + }); + t.test('submits on last step forward', t => { + const actions = Observable.of({ type: types.stepForward }); + const state = { challengesApp: { currentIndex: 1 } }; + const onNextSpy = sinon.spy(); + challengeSelectorStub.challengeSelector = sinon.spy(_state => { + t.assert(_state === state, 'challenge selector not called with state'); + return { + challenge: { + description: new Array(2) + } + }; + }); + stepChallengeEpic(actions, () => state) + .subscribe( + onNextSpy, + e => { + throw e; + }, + () => { + t.assert( + onNextSpy.calledOnce, + 'epic not called exactly once' + ); + t.assert( + onNextSpy.calledWithMatch({ + type: types.submitChallenge + }), + 'Epic did not return the expected action' + ); + delete challengeSelectorStub.challengeSelector; + t.end(); + } + ); + }); +}); diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index 5fdb6797248..3ee885419b1 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -2,6 +2,8 @@ import createTypes from '../../../utils/create-types'; export default createTypes([ // step + 'stepForward', + 'stepBackward', 'goToStep', 'completeAction', 'openLightBoxImage', diff --git a/package.json b/package.json index 88de9be1231..b8a5ace4c0c 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "less": "^2.5.1", "loopback-component-explorer": "^2.1.1", "merge-stream": "^1.0.0", + "proxyquire": "^1.7.10", "rev-del": "^1.0.5", "sinon": "^1.17.3", "sort-keys": "^1.1.1",