From b9c7532efd3adde2c00c5692e5c6d79ed050dd60 Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Sat, 22 Oct 2016 20:09:23 +0100 Subject: [PATCH] Next step unlocked persistence --- .../challenges/components/step/Step.jsx | 71 ++++++++++++------- common/app/routes/challenges/redux/actions.js | 6 +- common/app/routes/challenges/redux/reducer.js | 12 ++-- .../challenges/redux/step-challenge-epic.js | 30 ++++++-- .../redux/step-challenge-epic.test.js | 18 +++-- common/app/routes/challenges/redux/types.js | 1 + 6 files changed, 99 insertions(+), 39 deletions(-) diff --git a/common/app/routes/challenges/components/step/Step.jsx b/common/app/routes/challenges/components/step/Step.jsx index da9dd29c15..403d7dfc78 100644 --- a/common/app/routes/challenges/components/step/Step.jsx +++ b/common/app/routes/challenges/components/step/Step.jsx @@ -6,12 +6,13 @@ import PureComponent from 'react-pure-render/component'; import LightBox from 'react-images'; import { - stepForward, - stepBackward, + closeLightBoxImage, completeAction, - submitChallenge, openLightBoxImage, - closeLightBoxImage + stepBackward, + stepForward, + submitChallenge, + updateUnlockedSteps } from '../../redux/actions'; import { challengeSelector } from '../../redux/selectors'; import { Button, Col, Image, Row } from 'react-bootstrap'; @@ -42,12 +43,30 @@ const mapStateToProps = createSelector( ); const dispatchActions = { - stepForward, - stepBackward, + closeLightBoxImage, completeAction, - submitChallenge, openLightBoxImage, - closeLightBoxImage + stepBackward, + stepForward, + submitChallenge, + updateUnlockedSteps +}; + +const propTypes = { + closeLightBoxImage: PropTypes.func.isRequired, + completeAction: PropTypes.func.isRequired, + currentIndex: PropTypes.number, + isActionCompleted: PropTypes.bool, + isLastStep: PropTypes.bool, + isLightBoxOpen: PropTypes.bool, + numOfSteps: PropTypes.number, + openLightBoxImage: PropTypes.func.isRequired, + step: PropTypes.array, + steps: PropTypes.array, + stepBackward: PropTypes.func, + stepForward: PropTypes.func, + submitChallenge: PropTypes.func.isRequired, + updateUnlockedSteps: PropTypes.func.isRequired }; export class StepChallenge extends PureComponent { @@ -55,22 +74,6 @@ export class StepChallenge extends PureComponent { super(...args); this.handleLightBoxOpen = this.handleLightBoxOpen.bind(this); } - static displayName = 'StepChallenge'; - static propTypes = { - currentIndex: PropTypes.number, - step: PropTypes.array, - steps: PropTypes.array, - isActionCompleted: PropTypes.bool, - isLastStep: PropTypes.bool, - numOfSteps: PropTypes.number, - stepForward: PropTypes.func, - stepBackward: PropTypes.func, - completeAction: PropTypes.func.isRequired, - submitChallenge: PropTypes.func.isRequired, - isLightBoxOpen: PropTypes.bool, - openLightBoxImage: PropTypes.func.isRequired, - closeLightBoxImage: PropTypes.func.isRequired - }; handleLightBoxOpen(e) { if (!(e.ctrlKey || e.metaKey)) { @@ -79,6 +82,23 @@ export class StepChallenge extends PureComponent { } } + componentWillMount() { + const { updateUnlockedSteps } = this.props; + updateUnlockedSteps([]); + } + + componentWillUnmount() { + const { updateUnlockedSteps } = this.props; + updateUnlockedSteps([]); + } + + componentWillReceiveProps(nextProps) { + const { steps, updateUnlockedSteps } = this.props; + if (nextProps.steps !== steps) { + updateUnlockedSteps([]); + } + } + renderActionButton(action, completeAction) { const isApiAction = action === '#'; const buttonCopy = isApiAction ? @@ -260,4 +280,7 @@ export class StepChallenge extends PureComponent { } } +StepChallenge.displayName = 'StepChallenge'; +StepChallenge.propTypes = propTypes; + export default connect(mapStateToProps, dispatchActions)(StepChallenge); diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index f258f48fff..1b98cb6ba8 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -7,8 +7,12 @@ 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 goToStep = createAction( + types.goToStep, + (step, isUnlocked) => ({ step, isUnlocked }) +); export const completeAction = createAction(types.completeAction); +export const updateUnlockedSteps = createAction(types.updateUnlockedSteps); export const openLightBoxImage = createAction(types.openLightBoxImage); export const closeLightBoxImage = createAction(types.closeLightBoxImage); diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index 54ab917e4c..ca08888c20 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -44,7 +44,8 @@ const initialUiState = { shouldShakeQuestion: false, shouldShowQuestions: false, isChallengeModalOpen: false, - successMessage: 'Happy Coding!' + successMessage: 'Happy Coding!', + unlockedSteps: [] }; const initialState = { isCodeLocked: false, @@ -143,17 +144,20 @@ const mainReducer = handleActions( }), // step - [types.goToStep]: (state, { payload: step = 0 }) => ({ + [types.goToStep]: (state, { payload: { step = 0, isUnlocked }}) => ({ ...state, currentIndex: step, previousIndex: state.currentIndex, - isActionCompleted: false + isActionCompleted: isUnlocked }), - [types.completeAction]: state => ({ ...state, isActionCompleted: true }), + [types.updateUnlockedSteps]: (state, { payload }) => ({ + ...state, + unlockedSteps: payload + }), [types.openLightBoxImage]: state => ({ ...state, isLightBoxOpen: true diff --git a/common/app/routes/challenges/redux/step-challenge-epic.js b/common/app/routes/challenges/redux/step-challenge-epic.js index e969bf7a68..5d94b720d1 100644 --- a/common/app/routes/challenges/redux/step-challenge-epic.js +++ b/common/app/routes/challenges/redux/step-challenge-epic.js @@ -1,26 +1,44 @@ import types from './types'; -import { goToStep, submitChallenge } from './actions'; +import { goToStep, submitChallenge, updateUnlockedSteps } from './actions'; import { challengeSelector } from './selectors'; import getActionsOfType from '../../../../utils/get-actions-of-type'; +function unlockStep(step, unlockedSteps) { + if (!step) { + return null; + } + const updatedSteps = [ ...unlockedSteps ]; + updatedSteps[step] = true; + return updateUnlockedSteps(updatedSteps); +} + export default function stepChallengeEpic(actions, getState) { return getActionsOfType( actions, types.stepForward, - types.stepBackward + types.stepBackward, + types.completeAction ) .map(({ type }) => { const state = getState(); const { challenge: { description = [] } } = challengeSelector(state); - const { challengesApp: { currentIndex } } = state; + const { challengesApp: { currentIndex, unlockedSteps } } = state; const numOfSteps = description.length; - const isLastStep = currentIndex + 1 >= numOfSteps; + const stepFwd = currentIndex + 1; + const stepBwd = currentIndex - 1; + const isLastStep = stepFwd >= numOfSteps; + if (type === types.completeAction) { + return unlockStep(currentIndex, unlockedSteps); + } if (type === types.stepForward) { if (isLastStep) { return submitChallenge(); } - return goToStep(currentIndex + 1); + return goToStep(stepFwd, !!unlockedSteps[stepFwd]); } - return goToStep(currentIndex - 1); + if (type === types.stepBackward) { + return goToStep(stepBwd, !!unlockedSteps[stepBwd]); + } + return null; }); } diff --git a/common/app/routes/challenges/redux/step-challenge-epic.test.js b/common/app/routes/challenges/redux/step-challenge-epic.test.js index a637f65d61..cbc9dcc0d8 100644 --- a/common/app/routes/challenges/redux/step-challenge-epic.test.js +++ b/common/app/routes/challenges/redux/step-challenge-epic.test.js @@ -32,7 +32,12 @@ test(file, function(t) { }); t.test('steps back', t => { const actions = Observable.of({ type: types.stepBackward }); - const state = { challengesApp: { currentIndex: 1 } }; + const state = { + challengesApp: { + currentIndex: 1, + unlockedSteps: [ true, undefined ] // eslint-disable-line no-undefined + } + }; const onNextSpy = sinon.spy(); challengeSelectorStub.challengeSelector = sinon.spy(_state => { t.assert(_state === state, 'challenge selector not called with state'); @@ -56,7 +61,7 @@ test(file, function(t) { t.assert( onNextSpy.calledWithMatch({ type: types.goToStep, - payload: 0 + payload: { step: 0, isUnlocked: true } }), 'Epic did not return the expected action' ); @@ -67,7 +72,12 @@ test(file, function(t) { }); t.test('steps forward', t => { const actions = Observable.of({ type: types.stepForward }); - const state = { challengesApp: { currentIndex: 0 } }; + const state = { + challengesApp: { + currentIndex: 0, + unlockedSteps: [] + } + }; const onNextSpy = sinon.spy(); challengeSelectorStub.challengeSelector = sinon.spy(_state => { t.assert(_state === state, 'challenge selector not called with state'); @@ -91,7 +101,7 @@ test(file, function(t) { t.assert( onNextSpy.calledWithMatch({ type: types.goToStep, - payload: 1 + payload: { step: 1, isUnlocked: false } }), 'Epic did not return the expected action' ); diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index a4deb334a3..d04cd0bb38 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -8,6 +8,7 @@ export default createTypes([ 'completeAction', 'openLightBoxImage', 'closeLightBoxImage', + 'updateUnlockedSteps', // challenges 'fetchChallenge',