Merge pull request #16819 from Bouncey/fix/slowNetwork

fix(challenge): Handle slow network connections gracefully
This commit is contained in:
Berkeley Martinez
2018-03-12 14:28:55 -07:00
committed by GitHub
19 changed files with 280 additions and 154 deletions

View File

@@ -49,7 +49,11 @@ const primaryLang = getLangFromPath(location.pathname);
defaultState.app.csrfToken = csrfToken;
const serviceOptions = { xhrPath: '/services', context: { _csrf: csrfToken } };
const serviceOptions = {
context: { _csrf: csrfToken },
xhrPath: '/services',
xhrTimeout: 15000
};
const history = useLangRoutes(createHistory, primaryLang)();
sendPageAnalytics(history, ga);

View File

@@ -337,6 +337,10 @@ p {
border-radius: 6px;
}
.btn-big.btn-primary[disabled] {
color: rgb(141, 139, 132)
}
.btn-bigger {
font-size: 30px;
}

View File

@@ -179,9 +179,7 @@ export default composeReducers(
}
};
}
return {
...merge(state, action.meta.entities)
};
return merge({}, state, action.meta.entities);
}
return state;
},
@@ -205,16 +203,15 @@ export default composeReducers(
() => ({
[
combineActions(
app.fetchChallenges.complete,
app.fetchNewBlock.complete,
map.fetchMapUi.complete
)
]: (state, { payload }) => {
const {entities: { block } } = payload;
return {
...merge(state, payload.entities),
fullBlocks: union(state.fullBlocks, [ Object.keys(block)[0] ])
};
},
]: (state, { payload: { entities } }) => merge({}, state, entities),
[app.fetchNewBlock.complete]:
(state, { payload: { entities: { block }}}) => ({
...state,
fullBlocks: union(state.fullBlocks, [ Object.keys(block)[0] ])
}),
[
challenges.submitChallenge.complete
]: (state, { payload: { username, points, challengeInfo } }) => ({

View File

@@ -0,0 +1,26 @@
import React from 'react';
import styles from './skeletonStyles';
function SkeletonSprite() {
return (
<div className='sprite-container'>
<style dangerouslySetInnerHTML={ { __html: styles } } />
<svg className='sprite-svg'>
<rect
className='sprite'
fill='#ccc'
height='100%'
stroke='#ccc'
width='2px'
x='0'
y='0'
/>
</svg>
</div>
);
}
SkeletonSprite.displayName = 'SkeletonSprite';
export default SkeletonSprite;

View File

@@ -1,4 +1,5 @@
export { default as ButtonSpacer } from './ButtonSpacer.jsx';
export { default as FullWidthRow } from './FullWidthRow.jsx';
export { default as Loader } from './Loader.jsx';
export { default as SkeletonSprite } from './SkeletonSprite.jsx';
export { default as Spacer } from './Spacer.jsx';
export { default as ButtonSpacer } from './ButtonSpacer.jsx';

View File

@@ -0,0 +1,60 @@
export default `
.sprite-container {
height: 100%;
width: 100%;
}
.sprite-svg {
height: 100%;
width: 100%;
background: #aaa;
}
@-webkit-keyframes shimmer{
0% {
-webkit-transform: translateX(0%);
transform: translateX(0%);
stroke-width: 2px;
}
35% {
stroke-width: 30px;
}
100% {
-webkit-transform: translateX(100%);
transform: translateX(100%);
stroke-width: 2px;
}
}
@keyframes shimmer{
0% {
-webkit-transform: translateX(0%);
transform: translateX(0%);
stroke-width: 2px;
}
35% {
stroke-width: 30px;
}
100% {
-webkit-transform: translateX(100%);
transform: translateX(100%);
stroke-width: 2px;
}
}
.sprite {
-webkit-animation-name: shimmer;
animation-name: shimmer;
width: 2px;
-webkit-animation-duration: 2s;
animation-duration: 2s;
-webkit-animation-timing-function: ease-in-out;
animation-timing-function: ease-in-out;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
-webkit-animation-direction: normal;
animation-direction: normal;
}
`;

View File

@@ -1,5 +1,6 @@
import { Observable } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import _ from 'lodash';
import debug from 'debug';
import {
@@ -9,8 +10,7 @@ import {
delayedRedirect,
fetchChallengeCompleted,
fetchChallengesCompleted,
fetchNewBlock,
fetchNewBlockComplete,
challengeSelector,
nextChallengeSelector
} from './';
@@ -21,7 +21,7 @@ import {
import { shapeChallenges } from './utils';
import { types as challenge } from '../routes/Challenges/redux';
import { langSelector } from '../Router/redux';
import { langSelector, paramsSelector } from '../Router/redux';
const isDev = debug.enabled('fcc:*');
@@ -60,62 +60,59 @@ export function fetchChallengesForBlockEpic(
{ getState },
{ services }
) {
return actions::ofType(
types.appMounted,
types.updateChallenges,
types.fetchNewBlock.start
)
.flatMapLatest(({ type, payload }) => {
const fetchAnotherBlock = type === types.fetchNewBlock.start;
const state = getState();
let {
block: blockName = 'basic-html-and-html5'
} = challengeSelector(state);
const lang = langSelector(state);
if (fetchAnotherBlock) {
const fullBlocks = fullBlocksSelector(state);
if (fullBlocks.includes(payload)) {
return Observable.of(null);
}
blockName = payload;
}
const onAppMount = actions::ofType(types.appMounted)
.map(() => {
const {
block = 'basic-html-and-html5'
} = challengeSelector(getState());
return block;
});
const onNewChallenge = actions::ofType(challenge.moveToNextChallenge)
.map(() => {
const {
isNewBlock,
isNewSuperBlock,
nextChallenge
} = nextChallengeSelector(getState());
const isNewBlockRequired = isNewBlock || isNewSuperBlock && nextChallenge;
return isNewBlockRequired ? nextChallenge.block : null;
});
const onBlockSelect = actions::ofType(types.fetchNewBlock.start)
.map(({ payload }) => payload);
return Observable.merge(onAppMount, onNewChallenge, onBlockSelect)
.filter(block => {
const fullBlocks = fullBlocksSelector(getState());
return block && !fullBlocks.includes(block);
})
.flatMapLatest(blockName => {
const lang = langSelector(getState());
const options = {
params: { lang, blockName },
service: 'challenge'
};
return services.readService$(options)
.retry(3)
.map(fetchChallengesCompleted)
.startWith({ type: types.fetchChallenges.start })
.map(newBlockData => {
const { dashedName } = paramsSelector(getState());
const { entities: { challenge } } = newBlockData;
const currentChallengeInNewBlock = _.pickBy(
challenge,
newChallenge => newChallenge.dashedName === dashedName
);
return fetchNewBlockComplete({
...newBlockData,
meta: {
challenge: currentChallengeInNewBlock
}
});
})
.catch(createErrorObservable);
})
.filter(Boolean);
}
});
}
function fetchChallengesForNextBlockEpic(action$, { getState }) {
return action$::ofType(challenge.checkForNextBlock)
.map(() => {
const {
nextChallenge,
isNewBlock,
isNewSuperBlock
} = nextChallengeSelector(getState());
const isNewBlockRequired = (
(isNewBlock || isNewSuperBlock) &&
nextChallenge &&
!nextChallenge.description
);
return isNewBlockRequired ?
fetchNewBlock(nextChallenge.block) :
null;
})
.filter(Boolean);
}
export default combineEpics(
fetchChallengeEpic,
fetchChallengesForBlockEpic,
fetchChallengesForNextBlockEpic
fetchChallengesForBlockEpic
);

View File

@@ -15,14 +15,18 @@ import updateMyCurrentChallengeEpic from './update-my-challenge-epic.js';
import fetchChallengesEpic from './fetch-challenges-epic.js';
import nightModeEpic from './night-mode-epic.js';
import { createFilesMetaCreator } from '../files';
import { updateThemeMetacreator, entitiesSelector } from '../entities';
import {
updateThemeMetacreator,
entitiesSelector,
fullBlocksSelector
} from '../entities';
import { utils } from '../Flash/redux';
import { paramsSelector } from '../Router/redux';
import { types as challenges } from '../routes/Challenges/redux';
import { types as map } from '../Map/redux';
import {
challengeToFiles,
createCurrentChallengeMeta,
challengeToFilesMetaCreator,
getFirstChallengeOfNextBlock,
getFirstChallengeOfNextSuperBlock,
getNextChallenge
@@ -113,7 +117,7 @@ export const fetchChallengeCompleted = createAction(
null,
meta => ({
...meta,
...flow(challengeToFiles, createFilesMetaCreator)(meta.challenge)
...challengeToFilesMetaCreator(meta.challenge)
})
);
export const fetchChallenges = createAction('' + types.fetchChallenges);
@@ -124,7 +128,8 @@ export const fetchChallengesCompleted = createAction(
export const fetchNewBlock = createAction(types.fetchNewBlock.start);
export const fetchNewBlockComplete = createAction(
types.fetchNewBlock.complete,
({ entities }) => entities
({ entities }) => ({ entities }),
({ meta: { challenge } }) => ({ ...createCurrentChallengeMeta(challenge) })
);
export const updateChallenges = createAction(types.updateChallenges);
@@ -239,6 +244,12 @@ export const challengeSelector = state => {
return challengeMap[challengeName] || {};
};
export const isCurrentBlockCompleteSelector = state => {
const { block } = paramsSelector(state);
const fullBlocks = fullBlocksSelector(state);
return fullBlocks.includes(block);
};
export const previousSolutionSelector = state => {
const { id } = challengeSelector(state);
const { challengeMap = {} } = userSelector(state);

View File

@@ -1,21 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Col, Row } from 'react-bootstrap';
import ns from './ns.json';
import { isCurrentBlockCompleteSelector } from '../../redux';
import { SkeletonSprite } from '../../helperComponents';
const mapStateToProps = createSelector(
isCurrentBlockCompleteSelector,
blockComplete => ({
showLoading: !blockComplete
})
);
const propTypes = {
children: PropTypes.array
children: PropTypes.array,
showLoading: PropTypes.bool
};
export default function ChallengeDescription({ children }) {
function ChallengeDescription({ children, showLoading }) {
return (
<Row>
<Col
className={ `${ns}-instructions` }
xs={ 12 }
>
{ children }
{
showLoading ?
children
.map((_, i) => (
<div
key={ '' + i + 'description' }
style={{ height: '36px', margin: '9px 0px' }}
>
<SkeletonSprite />
</div>
)) :
children
}
</Col>
</Row>
);
@@ -23,3 +47,5 @@ export default function ChallengeDescription({ children }) {
ChallengeDescription.displayName = 'ChallengeDescription';
ChallengeDescription.propTypes = propTypes;
export default connect(mapStateToProps)(ChallengeDescription);

View File

@@ -1,15 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ns from './ns.json';
import { isCurrentBlockCompleteSelector } from '../../redux';
import { SkeletonSprite } from '../../helperComponents';
const mapStateToProps = createSelector(
isCurrentBlockCompleteSelector,
blockComplete => ({
showLoading: !blockComplete
})
);
const propTypes = {
children: PropTypes.string,
isCompleted: PropTypes.bool
isCompleted: PropTypes.bool,
showLoading: PropTypes.bool
};
export default function ChallengeTitle({ children, isCompleted }) {
function ChallengeTitle({ children, isCompleted, showLoading }) {
let icon = null;
if (showLoading) {
return (
<h4 style={{ height: '35px', marginBottom: '9px' }}>
<SkeletonSprite />
<hr />
</h4>
);
}
if (isCompleted) {
icon = (
<i
@@ -29,3 +48,5 @@ export default function ChallengeTitle({ children, isCompleted }) {
ChallengeTitle.displayName = 'ChallengeTitle';
ChallengeTitle.propTypes = propTypes;
export default connect(mapStateToProps)(ChallengeTitle);

View File

@@ -8,9 +8,10 @@ const propTypes = {
children: PropTypes.node
};
export default function ChildContainer({ children, ...props }) {
function ChildContainer(props) {
const { children, ...restProps } = props;
return (
<AppChildContainer { ...props }>
<AppChildContainer { ...restProps }>
{ children }
<CompletionModal />
</AppChildContainer>
@@ -18,3 +19,5 @@ export default function ChildContainer({ children, ...props }) {
}
ChildContainer.propTypes = propTypes;
export default ChildContainer;

View File

@@ -1,8 +1,6 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Grid, Col, Row } from 'react-bootstrap';
import ns from './ns.json';
import { SkeletonSprite } from '../../helperComponents';
const propTypes = {
content: PropTypes.string
@@ -10,19 +8,6 @@ const propTypes = {
export default class CodeMirrorSkeleton extends PureComponent {
renderLine(line, i) {
return (
<div className={ `${ns}-shimmer` } key={ i }>
<Row>
<Col xs={ 12 }>
<div className='sprite-wrapper'>
<div className='sprite' />
</div>
</Col>
</Row>
</div>
);
}
render() {
const {
@@ -39,18 +24,12 @@ export default class CodeMirrorSkeleton extends PureComponent {
className='CodeMirror-sizer'
style={
{
minHeight: (editorLines.length * 18) + 'px',
height: (editorLines.length * 18) + 'px',
overflow: 'hidden'
}
}
>
<div className='CodeMirror-lines'>
<div className='CodeMirror-code'>
<Grid>
{ editorLines.map(this.renderLine) }
</Grid>
</div>
</div>
<SkeletonSprite />
</div>
</div>
</div>

View File

@@ -73,6 +73,7 @@ const propTypes = {
dashedName: PropTypes.string,
lang: PropTypes.string.isRequired
}),
showLoading: PropTypes.bool,
title: PropTypes.string,
updateSuccessMessage: PropTypes.func.isRequired,
updateTitle: PropTypes.func.isRequired,

View File

@@ -1,6 +1,16 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Button, Tooltip, OverlayTrigger } from 'react-bootstrap';
import { isCurrentBlockCompleteSelector } from '../../redux';
const mapStateToProps = createSelector(
isCurrentBlockCompleteSelector,
blockComplete => ({
isDisabled: !blockComplete
})
);
const unlockWarning = (
<Tooltip id='tooltip'>
@@ -15,13 +25,14 @@ const propTypes = {
guideUrl: PropTypes.string,
hint: PropTypes.string,
isCodeLocked: PropTypes.bool,
isDisabled: PropTypes.bool,
makeToast: PropTypes.func.isRequired,
openHelpModal: PropTypes.func.isRequired,
unlockUntrustedCode: PropTypes.func.isRequired,
updateHint: PropTypes.func.isRequired
};
export default class ToolPanel extends PureComponent {
class ToolPanel extends PureComponent {
constructor(...props) {
super(...props);
this.makeHint = this.makeHint.bind(this);
@@ -44,7 +55,7 @@ export default class ToolPanel extends PureComponent {
});
}
renderHint(hint, makeHint) {
renderHint(hint, makeHint, isDisabled) {
if (!hint) {
return null;
}
@@ -53,6 +64,7 @@ export default class ToolPanel extends PureComponent {
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ makeHint }
>
Hint
@@ -60,7 +72,12 @@ export default class ToolPanel extends PureComponent {
);
}
renderExecute(isCodeLocked, executeChallenge, unlockUntrustedCode) {
renderExecute(
isCodeLocked,
executeChallenge,
unlockUntrustedCode,
isDisabled
) {
if (isCodeLocked) {
return (
<OverlayTrigger
@@ -71,6 +88,7 @@ export default class ToolPanel extends PureComponent {
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ unlockUntrustedCode }
>
I trust this code. Unlock it.
@@ -83,6 +101,7 @@ export default class ToolPanel extends PureComponent {
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ executeChallenge }
>
Run tests (ctrl + enter)
@@ -96,18 +115,20 @@ export default class ToolPanel extends PureComponent {
guideUrl,
hint,
isCodeLocked,
isDisabled,
openHelpModal,
unlockUntrustedCode
} = this.props;
console.log(isDisabled);
return (
<div>
{ this.renderHint(hint, this.makeHint) }
{ this.renderHint(hint, this.makeHintm, isDisabled) }
{
this.renderExecute(
isCodeLocked,
executeChallenge,
unlockUntrustedCode
unlockUntrustedCode,
isDisabled
)
}
<div className='button-spacer' />
@@ -115,6 +136,7 @@ export default class ToolPanel extends PureComponent {
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ this.makeReset }
>
Reset your code
@@ -127,6 +149,7 @@ export default class ToolPanel extends PureComponent {
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
href={ guideUrl }
target='_blank'
>
@@ -139,6 +162,7 @@ export default class ToolPanel extends PureComponent {
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ openHelpModal }
>
Ask for help on the forum
@@ -151,3 +175,5 @@ export default class ToolPanel extends PureComponent {
ToolPanel.displayName = 'ToolPanel';
ToolPanel.propTypes = propTypes;
export default connect(mapStateToProps)(ToolPanel);

View File

@@ -142,48 +142,6 @@
word-wrap: break-word;
}
@keyframes skeletonShimmer{
0% {
transform: translateX(-48px);
}
100% {
transform: translateX(1000px);
}
}
.@{ns}-shimmer {
position: relative;
min-height: 18px;
.row {
height: 18px;
.col-xs-12 {
padding-right: 12px;
height: 17px;
}
}
.sprite-wrapper {
background-color: #333;
height: 17px;
width: 75%;
}
.sprite {
animation-name: skeletonShimmer;
animation-duration: 2.5s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: normal;
background: white;
box-shadow: 0 0 3px 2px;
height: 17px;
width: 2px;
z-index: 5;
}
}
.@{ns}-success-modal {
display: flex;
flex-direction: column;

View File

@@ -28,7 +28,8 @@ import {
submitTypes,
viewTypes,
getFileKey,
challengeToFiles
challengeToFilesMetaCreator
} from '../utils';
import {
types as app,
@@ -36,14 +37,11 @@ import {
} from '../../../redux';
import { html } from '../../../utils/challengeTypes.js';
import blockNameify from '../../../utils/blockNameify.js';
import { updateFileMetaCreator, createFilesMetaCreator } from '../../../files';
import { updateFileMetaCreator } from '../../../files';
// this is not great but is ok until we move to a different form type
export projectNormalizer from '../views/project/redux';
const challengeToFilesMetaCreator =
_.flow(challengeToFiles, createFilesMetaCreator);
export const epics = [
modalEpic,
challengeEpic,

View File

@@ -3,6 +3,7 @@ import _ from 'lodash';
import * as challengeTypes from '../../../utils/challengeTypes.js';
import { createPoly, updateFileFromSpec } from '../../../../utils/polyvinyl.js';
import { decodeScriptTags } from '../../../../utils/encode-decode.js';
import { createFilesMetaCreator } from '../../../files';
// turn challengeType to file ext
const pathsMap = {
@@ -113,6 +114,17 @@ export function challengeToFiles(challenge, files) {
};
}
export const challengeToFilesMetaCreator =
_.flow(challengeToFiles, createFilesMetaCreator);
// ({ dashedName: { Challenge } }) => ({ meta: Files }) || {}
export function createCurrentChallengeMeta(challenge) {
if (_.isEmpty(challenge)) {
return {};
}
return challengeToFilesMetaCreator(_.values(challenge)[0]);
}
export function createTests({ tests = [] }) {
return tests
.map(test => {

View File

@@ -31,7 +31,7 @@ const mapStateToProps = createSelector(
actionCompletedSelector,
lightBoxSelector,
(
{ description = [] },
{ description = [['', '', 'Happy Coding!', '']] },
currentIndex,
previousIndex,
isActionCompleted,

View File

@@ -58,7 +58,8 @@ export default function mapUiService(app) {
block,
isLocked,
isComingSoon,
isBeta
isBeta,
challengeType
} = challenge;
map[dashedName] = {
dashedName,
@@ -68,7 +69,8 @@ export default function mapUiService(app) {
block,
isLocked,
isComingSoon,
isBeta
isBeta,
challengeType
};
return map;
}, {});