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 React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { bindActionCreators } from 'redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import uniq from 'lodash/uniq';
|
import uniq from 'lodash/uniq';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import SuperBlock from './components/SuperBlock';
|
import SuperBlock from './components/SuperBlock';
|
||||||
import Spacer from '../helpers/Spacer';
|
import Spacer from '../helpers/Spacer';
|
||||||
|
|
||||||
import './map.css';
|
import './map.css';
|
||||||
import { ChallengeNode } from '../../redux/propTypes';
|
import { ChallengeNode } from '../../redux/propTypes';
|
||||||
|
import { toggleSuperBlock, toggleBlock, resetExpansion } from './redux';
|
||||||
|
import { currentChallengeIdSelector } from '../../redux';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
currentChallengeId: PropTypes.string,
|
||||||
introNodes: PropTypes.arrayOf(
|
introNodes: PropTypes.arrayOf(
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
fields: PropTypes.shape({ slug: PropTypes.string.isRequired }),
|
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) {
|
renderSuperBlocks(superBlocks) {
|
||||||
const { nodes, introNodes } = this.props;
|
const { nodes, introNodes } = this.props;
|
||||||
return superBlocks.map(superBlock => (
|
return superBlocks.map(superBlock => (
|
||||||
@ -48,7 +90,10 @@ class ShowMap extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ShowMap.displayName = 'Map';
|
Map.displayName = 'Map';
|
||||||
ShowMap.propTypes = propTypes;
|
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 React from 'react';
|
||||||
import ShallowRenderer from 'react-test-renderer/shallow';
|
import ShallowRenderer from 'react-test-renderer/shallow';
|
||||||
import Enzyme from 'enzyme';
|
import Enzyme, { shallow } from 'enzyme';
|
||||||
import Adapter from 'enzyme-adapter-react-16';
|
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 mockNodes from '../../__mocks__/map-nodes';
|
||||||
import mockIntroNodes from '../../__mocks__/intro-nodes';
|
import mockIntroNodes from '../../__mocks__/intro-nodes';
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
const renderer = new ShallowRenderer();
|
const renderer = new ShallowRenderer();
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
introNodes: mockIntroNodes,
|
||||||
|
nodes: mockNodes,
|
||||||
|
toggleBlock: () => {},
|
||||||
|
toggleSuperBlock: () => {},
|
||||||
|
resetExpansion: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
test('<Map /> snapshot', () => {
|
test('<Map /> snapshot', () => {
|
||||||
const component = renderer.render(
|
const componentToRender = (
|
||||||
<Map introNodes={mockIntroNodes} nodes={mockNodes} />
|
<Map
|
||||||
|
introNodes={mockIntroNodes}
|
||||||
|
nodes={mockNodes}
|
||||||
|
toggleBlock={() => {}}
|
||||||
|
toggleSuperBlock={() => {}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
const component = renderer.render(componentToRender);
|
||||||
expect(component).toMatchSnapshot('Map');
|
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 ga from '../../../analytics';
|
||||||
import { makeExpandedBlockSelector, toggleBlock } from '../redux';
|
import { makeExpandedBlockSelector, toggleBlock } from '../redux';
|
||||||
import { userSelector } from '../../../redux';
|
import { completedChallengesSelector } from '../../../redux';
|
||||||
import Caret from '../../../assets/icons/Caret';
|
import Caret from '../../../assets/icons/Caret';
|
||||||
import { blockNameify } from '../../../../utils/blockNameify';
|
import { blockNameify } from '../../../../utils/blockNameify';
|
||||||
/* eslint-disable max-len */
|
/* eslint-disable max-len */
|
||||||
@ -19,8 +19,8 @@ const mapStateToProps = (state, ownProps) => {
|
|||||||
|
|
||||||
return createSelector(
|
return createSelector(
|
||||||
expandedSelector,
|
expandedSelector,
|
||||||
userSelector,
|
completedChallengesSelector,
|
||||||
(isExpanded, { completedChallenges = [] }) => ({
|
(isExpanded, completedChallenges) => ({
|
||||||
isExpanded,
|
isExpanded,
|
||||||
completedChallenges: completedChallenges.map(({ id }) => id)
|
completedChallenges: completedChallenges.map(({ id }) => id)
|
||||||
})
|
})
|
||||||
@ -127,6 +127,7 @@ export class Block extends Component {
|
|||||||
}
|
}
|
||||||
return { ...challenge, isCompleted };
|
return { ...challenge, isCompleted };
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={`block ${isExpanded ? 'open' : ''}`}>
|
<li className={`block ${isExpanded ? 'open' : ''}`}>
|
||||||
<button
|
<button
|
||||||
|
@ -8,17 +8,17 @@ export const getNS = () => ns;
|
|||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
expandedState: {
|
expandedState: {
|
||||||
superBlock: {
|
superBlock: {},
|
||||||
'Responsive Web Design': true
|
block: {}
|
||||||
},
|
|
||||||
block: {
|
|
||||||
'basic-html-and-html5': true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 toggleBlock = createAction(types.toggleBlock);
|
||||||
export const toggleSuperBlock = createAction(types.toggleSuperBlock);
|
export const toggleSuperBlock = createAction(types.toggleSuperBlock);
|
||||||
|
|
||||||
@ -29,6 +29,13 @@ export const makeExpandedBlockSelector = block => state =>
|
|||||||
|
|
||||||
export const reducer = handleActions(
|
export const reducer = handleActions(
|
||||||
{
|
{
|
||||||
|
[types.resetExpansion]: state => ({
|
||||||
|
...state,
|
||||||
|
expandedState: {
|
||||||
|
superBlock: {},
|
||||||
|
block: {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
[types.toggleBlock]: (state, { payload }) => ({
|
[types.toggleBlock]: (state, { payload }) => ({
|
||||||
...state,
|
...state,
|
||||||
expandedState: {
|
expandedState: {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
/* global PAYPAL_SUPPORTERS */
|
/* global PAYPAL_SUPPORTERS */
|
||||||
import { createAction, handleActions } from 'redux-actions';
|
import { createAction, handleActions } from 'redux-actions';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
|
import store from 'store';
|
||||||
|
|
||||||
import { createTypes, createAsyncTypes } from '../utils/createTypes';
|
import { createTypes, createAsyncTypes } from '../utils/createTypes';
|
||||||
import { createFetchUserSaga } from './fetch-user-saga';
|
import { createFetchUserSaga } from './fetch-user-saga';
|
||||||
@ -15,6 +16,9 @@ import failedUpdatesEpic from './failed-updates-epic';
|
|||||||
import updateCompleteEpic from './update-complete-epic';
|
import updateCompleteEpic from './update-complete-epic';
|
||||||
|
|
||||||
import { types as settingsTypes } from './settings';
|
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';
|
export const ns = 'app';
|
||||||
|
|
||||||
@ -28,6 +32,7 @@ export const defaultFetchState = {
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
appUsername: '',
|
appUsername: '',
|
||||||
completionCount: 0,
|
completionCount: 0,
|
||||||
|
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
|
||||||
donationRequested: false,
|
donationRequested: false,
|
||||||
showCert: {},
|
showCert: {},
|
||||||
showCertFetchState: {
|
showCertFetchState: {
|
||||||
@ -56,6 +61,7 @@ export const types = createTypes(
|
|||||||
'resetUserData',
|
'resetUserData',
|
||||||
'submitComplete',
|
'submitComplete',
|
||||||
'updateComplete',
|
'updateComplete',
|
||||||
|
'updateCurrentChallengeId',
|
||||||
'updateFailed',
|
'updateFailed',
|
||||||
...createAsyncTypes('fetchUser'),
|
...createAsyncTypes('fetchUser'),
|
||||||
...createAsyncTypes('fetchProfileForUser'),
|
...createAsyncTypes('fetchProfileForUser'),
|
||||||
@ -120,11 +126,14 @@ export const showCert = createAction(types.showCert);
|
|||||||
export const showCertComplete = createAction(types.showCertComplete);
|
export const showCertComplete = createAction(types.showCertComplete);
|
||||||
export const showCertError = createAction(types.showCertError);
|
export const showCertError = createAction(types.showCertError);
|
||||||
|
|
||||||
|
export const updateCurrentChallengeId = createAction(
|
||||||
|
types.updateCurrentChallengeId
|
||||||
|
);
|
||||||
|
|
||||||
export const completedChallengesSelector = state =>
|
export const completedChallengesSelector = state =>
|
||||||
userSelector(state).completedChallenges || [];
|
userSelector(state).completedChallenges || [];
|
||||||
export const completionCountSelector = state => state[ns].completionCount;
|
export const completionCountSelector = state => state[ns].completionCount;
|
||||||
export const currentChallengeIdSelector = state =>
|
export const currentChallengeIdSelector = state => state[ns].currentChallengeId;
|
||||||
userSelector(state).currentChallengeId || '';
|
|
||||||
export const donationRequestedSelector = state => state[ns].donationRequested;
|
export const donationRequestedSelector = state => state[ns].donationRequested;
|
||||||
|
|
||||||
export const isOnlineSelector = state => state[ns].isOnline;
|
export const isOnlineSelector = state => state[ns].isOnline;
|
||||||
@ -207,6 +216,7 @@ export const reducer = handleActions(
|
|||||||
[username]: { ...user, sessionUser: true }
|
[username]: { ...user, sessionUser: true }
|
||||||
},
|
},
|
||||||
appUsername: username,
|
appUsername: username,
|
||||||
|
currentChallengeId: user.currentChallengeId,
|
||||||
userFetchState: {
|
userFetchState: {
|
||||||
pending: false,
|
pending: false,
|
||||||
complete: true,
|
complete: true,
|
||||||
@ -324,6 +334,10 @@ export const reducer = handleActions(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
[challengeTypes.challengeMounted]: (state, { payload }) => ({
|
||||||
|
...state,
|
||||||
|
currentChallengeId: payload
|
||||||
|
}),
|
||||||
[settingsTypes.updateLegacyCertComplete]: (state, { payload }) => {
|
[settingsTypes.updateLegacyCertComplete]: (state, { payload }) => {
|
||||||
const { appUsername } = state;
|
const { appUsername } = state;
|
||||||
return {
|
return {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { put, select, call, takeEvery } from 'redux-saga/effects';
|
import { put, select, call, takeEvery } from 'redux-saga/effects';
|
||||||
|
import store from 'store';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
currentChallengeIdSelector,
|
|
||||||
openDonationModal,
|
openDonationModal,
|
||||||
showDonationSelector,
|
showDonationSelector,
|
||||||
donationRequested,
|
donationRequested,
|
||||||
@ -16,14 +16,16 @@ import { post } from '../../../utils/ajax';
|
|||||||
import { randomCompliment } from '../utils/get-words';
|
import { randomCompliment } from '../utils/get-words';
|
||||||
import { updateSuccessMessage } from './';
|
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 isSignedIn = yield select(isSignedInSelector);
|
||||||
const currentChallengeId = yield select(currentChallengeIdSelector);
|
if (isSignedIn) {
|
||||||
if (isSignedIn && payload !== currentChallengeId) {
|
|
||||||
const update = {
|
const update = {
|
||||||
endpoint: '/update-my-current-challenge',
|
endpoint: '/update-my-current-challenge',
|
||||||
payload: {
|
payload: {
|
||||||
currentChallengeId: payload
|
currentChallengeId: id
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
Reference in New Issue
Block a user