Refactor(challenges): Move step movement logic into epic

pull/10417/head
Berkeley Martinez 2016-08-31 14:06:03 -07:00
parent f7cffb2f16
commit 98673fc316
7 changed files with 191 additions and 30 deletions

View File

@ -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 (
<Col
@ -135,15 +124,14 @@ export class StepChallenge extends PureComponent {
bsSize='large'
bsStyle='primary'
className='col-sm-4 col-xs-12'
onClick={ this.handleBackClick }
onClick={ stepBackward }
>
Go to my previous step
</Button>
);
}
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'}
</Button>
@ -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 {
<div className='spacer' />
<div className='challenge-button-block'>
{ this.renderActionButton(action, completeAction) }
{ this.renderBackButton(currentIndex) }
{ this.renderBackButton(currentIndex, stepBackward) }
<Col
className='challenge-step-counter large-p text-center'
sm={ 4 }
@ -217,9 +208,9 @@ export class StepChallenge extends PureComponent {
{
this.renderNextButton(
!!action,
currentIndex,
numOfSteps,
isActionCompleted
isLastStep,
isActionCompleted,
stepForward
)
}
</div>

View File

@ -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);

View File

@ -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
];

View File

@ -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);
});
}

View File

@ -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();
}
);
});
});

View File

@ -2,6 +2,8 @@ import createTypes from '../../../utils/create-types';
export default createTypes([
// step
'stepForward',
'stepBackward',
'goToStep',
'completeAction',
'openLightBoxImage',

View File

@ -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",