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:
committed by
Valeriy
parent
469c3f05c2
commit
7d84783b40
@ -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);
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
Reference in New Issue
Block a user