feat: show last viewed challenge in curriculum map on reload (#35421)

* feat: Curriculum page loads with current challenge block open

* feat: Curriculum page expansion changes from review

* feat: Curriculum page expansion changes from second review

* fix: use a cookie to track current challenge

* fix: remove redundant server changes

* fix: add PropTypes to fix lint error

* fix: change cookies to store and add tests

* fix: use currentChallengeId

* fix: update tests

I couldn't figure out how to test the challenge saga, so that's gone for
now.  The Map tests have been updated to use currentChallengeId

* fix: remove unused PropTypes

* fix: separate currentChallengeId from user

* fix: update currentChallengeId directly on mount

* feat: reset map on every visit
This commit is contained in:
Oliver Eyton-Williams
2019-08-30 19:15:26 +02:00
committed by Valeriy
parent 469c3f05c2
commit 7d84783b40
6 changed files with 181 additions and 27 deletions

View File

@ -1,14 +1,20 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import PropTypes from 'prop-types';
import uniq from 'lodash/uniq';
import { createSelector } from 'reselect';
import SuperBlock from './components/SuperBlock';
import Spacer from '../helpers/Spacer';
import './map.css';
import { ChallengeNode } from '../../redux/propTypes';
import { toggleSuperBlock, toggleBlock, resetExpansion } from './redux';
import { currentChallengeIdSelector } from '../../redux';
const propTypes = {
currentChallengeId: PropTypes.string,
introNodes: PropTypes.arrayOf(
PropTypes.shape({
fields: PropTypes.shape({ slug: PropTypes.string.isRequired }),
@ -18,10 +24,46 @@ const propTypes = {
})
})
),
nodes: PropTypes.arrayOf(ChallengeNode)
nodes: PropTypes.arrayOf(ChallengeNode),
resetExpansion: PropTypes.func,
toggleBlock: PropTypes.func.isRequired,
toggleSuperBlock: PropTypes.func.isRequired
};
class ShowMap extends Component {
const mapStateToProps = state => {
return createSelector(
currentChallengeIdSelector,
currentChallengeId => ({
currentChallengeId
})
)(state);
};
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
resetExpansion,
toggleSuperBlock,
toggleBlock
},
dispatch
);
}
export class Map extends Component {
componentDidMount() {
this.initializeExpandedState(this.props.currentChallengeId);
}
initializeExpandedState(currentChallengeId) {
this.props.resetExpansion();
const { superBlock, block } = currentChallengeId
? this.props.nodes.find(node => node.id === currentChallengeId)
: this.props.nodes[0];
this.props.toggleBlock(block);
this.props.toggleSuperBlock(superBlock);
}
renderSuperBlocks(superBlocks) {
const { nodes, introNodes } = this.props;
return superBlocks.map(superBlock => (
@ -48,7 +90,10 @@ class ShowMap extends Component {
}
}
ShowMap.displayName = 'Map';
ShowMap.propTypes = propTypes;
Map.displayName = 'Map';
Map.propTypes = propTypes;
export default ShowMap;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Map);

View File

@ -1,20 +1,105 @@
/* global expect */
/* global expect jest */
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import Enzyme from 'enzyme';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import store from 'store';
import Map from './Map';
import { Map } from './Map';
import mockNodes from '../../__mocks__/map-nodes';
import mockIntroNodes from '../../__mocks__/intro-nodes';
Enzyme.configure({ adapter: new Adapter() });
const renderer = new ShallowRenderer();
const baseProps = {
introNodes: mockIntroNodes,
nodes: mockNodes,
toggleBlock: () => {},
toggleSuperBlock: () => {},
resetExpansion: () => {}
};
test('<Map /> snapshot', () => {
const component = renderer.render(
<Map introNodes={mockIntroNodes} nodes={mockNodes} />
const componentToRender = (
<Map
introNodes={mockIntroNodes}
nodes={mockNodes}
toggleBlock={() => {}}
toggleSuperBlock={() => {}}
/>
);
const component = renderer.render(componentToRender);
expect(component).toMatchSnapshot('Map');
});
describe('<Map/>', () => {
describe('after reload', () => {
let initializeSpy = null;
beforeEach(() => {
initializeSpy = jest.spyOn(Map.prototype, 'initializeExpandedState');
});
afterEach(() => {
initializeSpy.mockRestore();
store.clearAll();
});
// 7 was chosen because it has a different superblock from the first node.
const currentChallengeId = mockNodes[7].id;
it('should expand the block with the most recent challenge', () => {
const blockSpy = jest.fn();
const superSpy = jest.fn();
const props = {
...baseProps,
toggleBlock: blockSpy,
toggleSuperBlock: superSpy,
currentChallengeId: currentChallengeId
};
const mapToRender = <Map {...props} />;
shallow(mapToRender);
expect(blockSpy).toHaveBeenCalledTimes(1);
expect(blockSpy).toHaveBeenCalledWith(mockNodes[7].block);
expect(superSpy).toHaveBeenCalledTimes(1);
expect(superSpy).toHaveBeenCalledWith(mockNodes[7].superBlock);
});
it('should use the currentChallengeId prop if it exists', () => {
const props = { ...baseProps, currentChallengeId };
const mapToRender = <Map {...props} />;
shallow(mapToRender);
expect(initializeSpy).toHaveBeenCalledTimes(1);
expect(initializeSpy).toHaveBeenCalledWith(currentChallengeId);
});
it('should default to the first challenge otherwise', () => {
const blockSpy = jest.fn();
const superSpy = jest.fn();
const props = {
...baseProps,
toggleBlock: blockSpy,
toggleSuperBlock: superSpy
};
const mapToRender = <Map {...props} />;
shallow(mapToRender);
expect(blockSpy).toHaveBeenCalledTimes(1);
expect(blockSpy).toHaveBeenCalledWith(mockNodes[0].block);
expect(superSpy).toHaveBeenCalledTimes(1);
expect(superSpy).toHaveBeenCalledWith(mockNodes[0].superBlock);
});
it('calls resetExpansion when initialising', () => {
const expansionSpy = jest.fn();
const props = {
...baseProps,
resetExpansion: expansionSpy
};
const mapToRender = <Map {...props} />;
shallow(mapToRender);
expect(expansionSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -7,7 +7,7 @@ import { Link } from 'gatsby';
import ga from '../../../analytics';
import { makeExpandedBlockSelector, toggleBlock } from '../redux';
import { userSelector } from '../../../redux';
import { completedChallengesSelector } from '../../../redux';
import Caret from '../../../assets/icons/Caret';
import { blockNameify } from '../../../../utils/blockNameify';
/* eslint-disable max-len */
@ -19,8 +19,8 @@ const mapStateToProps = (state, ownProps) => {
return createSelector(
expandedSelector,
userSelector,
(isExpanded, { completedChallenges = [] }) => ({
completedChallengesSelector,
(isExpanded, completedChallenges) => ({
isExpanded,
completedChallenges: completedChallenges.map(({ id }) => id)
})
@ -127,6 +127,7 @@ export class Block extends Component {
}
return { ...challenge, isCompleted };
});
return (
<li className={`block ${isExpanded ? 'open' : ''}`}>
<button

View File

@ -8,17 +8,17 @@ export const getNS = () => ns;
const initialState = {
expandedState: {
superBlock: {
'Responsive Web Design': true
},
block: {
'basic-html-and-html5': true
}
superBlock: {},
block: {}
}
};
const types = createTypes(['toggleSuperBlock', 'toggleBlock'], ns);
const types = createTypes(
['resetExpansion', 'toggleSuperBlock', 'toggleBlock'],
ns
);
export const resetExpansion = createAction(types.resetExpansion);
export const toggleBlock = createAction(types.toggleBlock);
export const toggleSuperBlock = createAction(types.toggleSuperBlock);
@ -29,6 +29,13 @@ export const makeExpandedBlockSelector = block => state =>
export const reducer = handleActions(
{
[types.resetExpansion]: state => ({
...state,
expandedState: {
superBlock: {},
block: {}
}
}),
[types.toggleBlock]: (state, { payload }) => ({
...state,
expandedState: {

View File

@ -1,6 +1,7 @@
/* global PAYPAL_SUPPORTERS */
import { createAction, handleActions } from 'redux-actions';
import { uniqBy } from 'lodash';
import store from 'store';
import { createTypes, createAsyncTypes } from '../utils/createTypes';
import { createFetchUserSaga } from './fetch-user-saga';
@ -15,6 +16,9 @@ import failedUpdatesEpic from './failed-updates-epic';
import updateCompleteEpic from './update-complete-epic';
import { types as settingsTypes } from './settings';
import { types as challengeTypes } from '../templates/Challenges/redux/';
// eslint-disable-next-line max-len
import { CURRENT_CHALLENGE_KEY } from '../templates/Challenges/redux/current-challenge-saga';
export const ns = 'app';
@ -28,6 +32,7 @@ export const defaultFetchState = {
const initialState = {
appUsername: '',
completionCount: 0,
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
donationRequested: false,
showCert: {},
showCertFetchState: {
@ -56,6 +61,7 @@ export const types = createTypes(
'resetUserData',
'submitComplete',
'updateComplete',
'updateCurrentChallengeId',
'updateFailed',
...createAsyncTypes('fetchUser'),
...createAsyncTypes('fetchProfileForUser'),
@ -120,11 +126,14 @@ export const showCert = createAction(types.showCert);
export const showCertComplete = createAction(types.showCertComplete);
export const showCertError = createAction(types.showCertError);
export const updateCurrentChallengeId = createAction(
types.updateCurrentChallengeId
);
export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || [];
export const completionCountSelector = state => state[ns].completionCount;
export const currentChallengeIdSelector = state =>
userSelector(state).currentChallengeId || '';
export const currentChallengeIdSelector = state => state[ns].currentChallengeId;
export const donationRequestedSelector = state => state[ns].donationRequested;
export const isOnlineSelector = state => state[ns].isOnline;
@ -207,6 +216,7 @@ export const reducer = handleActions(
[username]: { ...user, sessionUser: true }
},
appUsername: username,
currentChallengeId: user.currentChallengeId,
userFetchState: {
pending: false,
complete: true,
@ -324,6 +334,10 @@ export const reducer = handleActions(
}
};
},
[challengeTypes.challengeMounted]: (state, { payload }) => ({
...state,
currentChallengeId: payload
}),
[settingsTypes.updateLegacyCertComplete]: (state, { payload }) => {
const { appUsername } = state;
return {

View File

@ -1,8 +1,8 @@
import { put, select, call, takeEvery } from 'redux-saga/effects';
import store from 'store';
import {
isSignedInSelector,
currentChallengeIdSelector,
openDonationModal,
showDonationSelector,
donationRequested,
@ -16,14 +16,16 @@ import { post } from '../../../utils/ajax';
import { randomCompliment } from '../utils/get-words';
import { updateSuccessMessage } from './';
function* currentChallengeSaga({ payload }) {
export const CURRENT_CHALLENGE_KEY = 'currentChallengeId';
export function* currentChallengeSaga({ payload: id }) {
store.set(CURRENT_CHALLENGE_KEY, id);
const isSignedIn = yield select(isSignedInSelector);
const currentChallengeId = yield select(currentChallengeIdSelector);
if (isSignedIn && payload !== currentChallengeId) {
if (isSignedIn) {
const update = {
endpoint: '/update-my-current-challenge',
payload: {
currentChallengeId: payload
currentChallengeId: id
}
};
try {