Merge pull request #10417 from BerkeleyTrue/refactor/move-step-logic
Refactor(challenges): Move step movement logic into epic
This commit is contained in:
@ -7,7 +7,8 @@ import ReactTransitionReplace from 'react-css-transition-replace';
|
|||||||
import LightBox from 'react-images';
|
import LightBox from 'react-images';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
goToStep,
|
stepForward,
|
||||||
|
stepBackward,
|
||||||
completeAction,
|
completeAction,
|
||||||
submitChallenge,
|
submitChallenge,
|
||||||
openLightBoxImage,
|
openLightBoxImage,
|
||||||
@ -38,12 +39,14 @@ const mapStateToProps = createSelector(
|
|||||||
step: description[currentIndex],
|
step: description[currentIndex],
|
||||||
steps: description,
|
steps: description,
|
||||||
numOfSteps: description.length,
|
numOfSteps: description.length,
|
||||||
|
isLastStep: currentIndex + 1 >= description.length,
|
||||||
isGoingForward: currentIndex > previousIndex
|
isGoingForward: currentIndex > previousIndex
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const dispatchActions = {
|
const dispatchActions = {
|
||||||
goToStep,
|
stepForward,
|
||||||
|
stepBackward,
|
||||||
completeAction,
|
completeAction,
|
||||||
submitChallenge,
|
submitChallenge,
|
||||||
openLightBoxImage,
|
openLightBoxImage,
|
||||||
@ -53,8 +56,6 @@ const dispatchActions = {
|
|||||||
export class StepChallenge extends PureComponent {
|
export class StepChallenge extends PureComponent {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
this.handleNextClick = this.handleNextClick.bind(this);
|
|
||||||
this.handleBackClick = this.handleBackClick.bind(this);
|
|
||||||
this.handleLightBoxOpen = this.handleLightBoxOpen.bind(this);
|
this.handleLightBoxOpen = this.handleLightBoxOpen.bind(this);
|
||||||
}
|
}
|
||||||
static displayName = 'StepChallenge';
|
static displayName = 'StepChallenge';
|
||||||
@ -64,8 +65,10 @@ export class StepChallenge extends PureComponent {
|
|||||||
steps: PropTypes.array,
|
steps: PropTypes.array,
|
||||||
isActionCompleted: PropTypes.bool,
|
isActionCompleted: PropTypes.bool,
|
||||||
isGoingForward: PropTypes.bool,
|
isGoingForward: PropTypes.bool,
|
||||||
|
isLastStep: PropTypes.bool,
|
||||||
numOfSteps: PropTypes.number,
|
numOfSteps: PropTypes.number,
|
||||||
goToStep: PropTypes.func,
|
stepForward: PropTypes.func,
|
||||||
|
stepBackward: PropTypes.func,
|
||||||
completeAction: PropTypes.func,
|
completeAction: PropTypes.func,
|
||||||
submitChallenge: PropTypes.func,
|
submitChallenge: PropTypes.func,
|
||||||
isLightBoxOpen: PropTypes.bool,
|
isLightBoxOpen: PropTypes.bool,
|
||||||
@ -73,20 +76,6 @@ export class StepChallenge extends PureComponent {
|
|||||||
closeLightBoxImage: PropTypes.func
|
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) {
|
handleLightBoxOpen(e) {
|
||||||
if (!(e.ctrlKey || e.metaKey)) {
|
if (!(e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -119,7 +108,7 @@ export class StepChallenge extends PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderBackButton(index) {
|
renderBackButton(index, stepBackward) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col
|
||||||
@ -135,15 +124,14 @@ export class StepChallenge extends PureComponent {
|
|||||||
bsSize='large'
|
bsSize='large'
|
||||||
bsStyle='primary'
|
bsStyle='primary'
|
||||||
className='col-sm-4 col-xs-12'
|
className='col-sm-4 col-xs-12'
|
||||||
onClick={ this.handleBackClick }
|
onClick={ stepBackward }
|
||||||
>
|
>
|
||||||
Go to my previous step
|
Go to my previous step
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderNextButton(hasAction, index, numOfSteps, isCompleted) {
|
renderNextButton(hasAction, isLastStep, isCompleted, stepForward) {
|
||||||
const isLastStep = index + 1 >= numOfSteps;
|
|
||||||
const btnClass = classnames({
|
const btnClass = classnames({
|
||||||
'col-sm-4 col-xs-12': true,
|
'col-sm-4 col-xs-12': true,
|
||||||
disabled: hasAction && !isCompleted
|
disabled: hasAction && !isCompleted
|
||||||
@ -154,7 +142,7 @@ export class StepChallenge extends PureComponent {
|
|||||||
bsStyle='primary'
|
bsStyle='primary'
|
||||||
className={ btnClass }
|
className={ btnClass }
|
||||||
disabled={ hasAction && !isCompleted }
|
disabled={ hasAction && !isCompleted }
|
||||||
onClick={ this.handleNextClick }
|
onClick={ stepForward }
|
||||||
>
|
>
|
||||||
{ isLastStep ? 'Finish challenge' : 'Go to my next step'}
|
{ isLastStep ? 'Finish challenge' : 'Go to my next step'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -166,7 +154,10 @@ export class StepChallenge extends PureComponent {
|
|||||||
currentIndex,
|
currentIndex,
|
||||||
numOfSteps,
|
numOfSteps,
|
||||||
isActionCompleted,
|
isActionCompleted,
|
||||||
completeAction
|
completeAction,
|
||||||
|
isLastStep,
|
||||||
|
stepForward,
|
||||||
|
stepBackward
|
||||||
}) {
|
}) {
|
||||||
if (!Array.isArray(step)) {
|
if (!Array.isArray(step)) {
|
||||||
return null;
|
return null;
|
||||||
@ -206,7 +197,7 @@ export class StepChallenge extends PureComponent {
|
|||||||
<div className='spacer' />
|
<div className='spacer' />
|
||||||
<div className='challenge-button-block'>
|
<div className='challenge-button-block'>
|
||||||
{ this.renderActionButton(action, completeAction) }
|
{ this.renderActionButton(action, completeAction) }
|
||||||
{ this.renderBackButton(currentIndex) }
|
{ this.renderBackButton(currentIndex, stepBackward) }
|
||||||
<Col
|
<Col
|
||||||
className='challenge-step-counter large-p text-center'
|
className='challenge-step-counter large-p text-center'
|
||||||
sm={ 4 }
|
sm={ 4 }
|
||||||
@ -217,9 +208,9 @@ export class StepChallenge extends PureComponent {
|
|||||||
{
|
{
|
||||||
this.renderNextButton(
|
this.renderNextButton(
|
||||||
!!action,
|
!!action,
|
||||||
currentIndex,
|
isLastStep,
|
||||||
numOfSteps,
|
isActionCompleted,
|
||||||
isActionCompleted
|
stepForward
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,8 @@ import { getMouse, loggerToStr } from '../utils';
|
|||||||
import types from './types';
|
import types from './types';
|
||||||
|
|
||||||
// step
|
// step
|
||||||
|
export const stepForward = createAction(types.stepForward);
|
||||||
|
export const stepBackward = createAction(types.stepBackward);
|
||||||
export const goToStep = createAction(types.goToStep);
|
export const goToStep = createAction(types.goToStep);
|
||||||
export const completeAction = createAction(types.completeAction);
|
export const completeAction = createAction(types.completeAction);
|
||||||
export const openLightBoxImage = createAction(types.openLightBoxImage);
|
export const openLightBoxImage = createAction(types.openLightBoxImage);
|
||||||
|
@ -5,6 +5,7 @@ import answerSaga from './answer-saga';
|
|||||||
import resetChallengeSaga from './reset-challenge-saga';
|
import resetChallengeSaga from './reset-challenge-saga';
|
||||||
import bugSaga from './bug-saga';
|
import bugSaga from './bug-saga';
|
||||||
import mapUiSaga from './map-ui-saga';
|
import mapUiSaga from './map-ui-saga';
|
||||||
|
import stepChallengeEpic from './step-challenge-epic';
|
||||||
|
|
||||||
export * as actions from './actions';
|
export * as actions from './actions';
|
||||||
export reducer from './reducer';
|
export reducer from './reducer';
|
||||||
@ -19,5 +20,6 @@ export const sagas = [
|
|||||||
answerSaga,
|
answerSaga,
|
||||||
resetChallengeSaga,
|
resetChallengeSaga,
|
||||||
bugSaga,
|
bugSaga,
|
||||||
mapUiSaga
|
mapUiSaga,
|
||||||
|
stepChallengeEpic
|
||||||
];
|
];
|
||||||
|
26
common/app/routes/challenges/redux/step-challenge-epic.js
Normal file
26
common/app/routes/challenges/redux/step-challenge-epic.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
137
common/app/routes/challenges/redux/step-challenge-epic.test.js
Normal file
137
common/app/routes/challenges/redux/step-challenge-epic.test.js
Normal 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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -2,6 +2,8 @@ import createTypes from '../../../utils/create-types';
|
|||||||
|
|
||||||
export default createTypes([
|
export default createTypes([
|
||||||
// step
|
// step
|
||||||
|
'stepForward',
|
||||||
|
'stepBackward',
|
||||||
'goToStep',
|
'goToStep',
|
||||||
'completeAction',
|
'completeAction',
|
||||||
'openLightBoxImage',
|
'openLightBoxImage',
|
||||||
|
@ -152,6 +152,7 @@
|
|||||||
"less": "^2.5.1",
|
"less": "^2.5.1",
|
||||||
"loopback-component-explorer": "^2.1.1",
|
"loopback-component-explorer": "^2.1.1",
|
||||||
"merge-stream": "^1.0.0",
|
"merge-stream": "^1.0.0",
|
||||||
|
"proxyquire": "^1.7.10",
|
||||||
"rev-del": "^1.0.5",
|
"rev-del": "^1.0.5",
|
||||||
"sinon": "^1.17.3",
|
"sinon": "^1.17.3",
|
||||||
"sort-keys": "^1.1.1",
|
"sort-keys": "^1.1.1",
|
||||||
|
Reference in New Issue
Block a user