diff --git a/common/app/routes/challenges/components/step/Step.jsx b/common/app/routes/challenges/components/step/Step.jsx
index df8297cec5..b2689fedb1 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 47a6b61b2d..b2f8e65243 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 369d42f092..1ea0b8dbea 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 0000000000..e969bf7a68
--- /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 0000000000..a637f65d61
--- /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 d6189961a9..18772e0415 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 47e8386972..47983e3096 100644
--- a/package.json
+++ b/package.json
@@ -152,6 +152,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",