feat(updates): Store updates if the server is not responding

This commit is contained in:
Stuart Taylor
2018-07-26 14:37:10 +01:00
committed by Mrugesh Mohapatra
parent 54e14b9433
commit e4e41e6fe3
14 changed files with 312 additions and 78 deletions

View File

@ -73,9 +73,10 @@
"lint:src": "eslint ./src . --fix",
"lint:utils": "eslint ./utils . --fix",
"pretty": "yarn format && yarn lint",
"test": "jest src",
"test": "jest src && jest utils",
"test:ci": "yarn format && jest src",
"test:watch": "jest --watch src"
"test:src": "jest src",
"test:watch-src": "jest --watch src"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import './offline-warning.css';
const propTypes = {
isOnline: PropTypes.bool.isRequired,
isSignedIn: PropTypes.bool.isRequired
};
function OfflineWarning({ isOnline, isSignedIn }) {
return !isSignedIn || isOnline ? null : (
<div className='offline-warning'>
We cannot reach the server to update your progress.
</div>
);
}
OfflineWarning.displayName = 'OfflineWarning';
OfflineWarning.propTypes = propTypes;
export default OfflineWarning;

View File

@ -0,0 +1 @@
export { default } from './OfflineWarning';

View File

@ -0,0 +1,7 @@
.offline-warning {
height: 38px;
display: flex;
justify-content: center;
align-items: center;
background: lightblue;
}

View File

@ -1,4 +1,4 @@
import React, { Fragment, PureComponent } from 'react';
import React, { Fragment, Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
@ -8,13 +8,21 @@ import ga from '../analytics';
import Header from '../components/Header';
import DonationModal from '../components/Donation';
import { fetchUser, userSelector } from '../redux/app';
import OfflineWarning from '../components/OfflineWarning';
import {
fetchUser,
userSelector,
onlineStatusChange,
isOnlineSelector,
isSignedInSelector
} from '../redux/app';
import 'prismjs/themes/prism.css';
import 'react-reflex/styles.css';
import './global.css';
import './layout.css';
import { createSelector } from 'reselect';
import { isBrowser } from '../../utils';
const metaKeywords = [
'javascript',
@ -42,46 +50,72 @@ const metaKeywords = [
const mapStateToProps = createSelector(
userSelector,
({ theme = 'default' }) => ({ theme })
isSignedInSelector,
isOnlineSelector,
({ theme = 'default' }, isSignedIn, isOnline) => ({
theme,
isSignedIn,
isOnline
})
);
const mapDispatchToProps = dispatch =>
bindActionCreators({ fetchUser }, dispatch);
bindActionCreators({ fetchUser, onlineStatusChange }, dispatch);
const propTypes = {
children: PropTypes.object,
fetchUser: PropTypes.func.isRequired,
isOnline: PropTypes.bool.isRequired,
isSignedIn: PropTypes.bool.isRequired,
onlineStatusChange: PropTypes.func.isRequired,
theme: PropTypes.string
};
class Layout extends PureComponent {
class Layout extends Component {
state = {
location: ''
};
componentDidMount() {
this.props.fetchUser();
const url = window.location.pathname + window.location.search;
ga.pageview(url);
window.addEventListener('online', this.updateOnlineStatus);
window.addEventListener('offline', this.updateOnlineStatus);
/* eslint-disable react/no-did-mount-set-state */
// this is for local location tracking only, no re-rendering required
this.setState(state => ({
...state,
this.setState({
location: url
}));
});
}
componentDidUpdate() {
const url = window.location.pathname + window.location.search;
if (url !== this.state.location) {
ga.pageview(url);
/* eslint-disable react/no-did-update-set-state */
// this is for local location tracking only, no re-rendering required
this.setState(state => ({
...state,
this.setState({
location: url
}));
});
}
}
componentWillUnmount() {
window.removeEventListener('online', this.updateOnlineStatus);
window.removeEventListener('offline', this.updateOnlineStatus);
}
updateOnlineStatus = () => {
const { onlineStatusChange } = this.props;
const isOnline =
isBrowser() && 'navigator' in window ? window.navigator.onLine : null;
return typeof isOnline === 'boolean' ? onlineStatusChange(isOnline) : null;
};
render() {
const { children, theme } = this.props;
const { children, theme, isOnline, isSignedIn } = this.props;
return (
<Fragment>
<Helmet
@ -97,6 +131,7 @@ class Layout extends PureComponent {
/>
<Header />
<div className={'app-wrapper ' + theme}>
<OfflineWarning isOnline={isOnline} isSignedIn={isSignedIn} />
<main>{children}</main>
</div>
<DonationModal />

View File

@ -0,0 +1,74 @@
import { merge } from 'rxjs/observable/merge';
import { empty } from 'rxjs/observable/empty';
import {
tap,
filter,
map,
ignoreElements,
switchMap,
catchError
} from 'rxjs/operators';
import { ofType } from 'redux-observable';
import store from 'store';
import uuid from 'uuid/v4';
import { types, onlineStatusChange, isOnlineSelector } from './';
import postUpdate$ from '../../templates/Challenges/utils/postUpdate$';
const key = 'fcc-failed-updates';
function delay(time = 0, fn) {
return setTimeout(fn, time);
}
function failedUpdateEpic(action$, { getState }) {
const storeUpdates = action$.pipe(
ofType(types.updateFailed),
tap(({ payload }) => {
const failures = store.get(key) || [];
payload.id = uuid();
store.set(key, [...failures, payload]);
}),
map(() => onlineStatusChange(false))
);
const flushUpdates = action$.pipe(
ofType(types.fetchUserComplete, types.updateComplete),
filter(() => store.get(key)),
filter(() => isOnlineSelector(getState())),
tap(() => {
const failures = store.get(key) || [];
let delayTime = 0;
const batch = failures.map(update => {
delayTime += 300;
// we stagger the updates here so we don't hammer the server
return delay(delayTime, () =>
postUpdate$(update)
.pipe(
switchMap(response => {
if (response && response.message) {
// the request completed successfully
const failures = store.get(key) || [];
const newFailures = failures.filter(x => x.id !== update.id);
store.set(key, newFailures);
}
return empty();
}),
catchError(() => empty())
)
.toPromise()
);
});
Promise.all(batch)
.then(() => console.info('progress updates processed where possible'))
.catch(err =>
console.warn('unable to process progress updates', err.message)
);
}),
ignoreElements()
);
return merge(storeUpdates, flushUpdates);
}
export default failedUpdateEpic;

View File

@ -5,10 +5,17 @@ import { createTypes } from '../../../utils/stateManagement';
import { types as challenge } from '../../templates/Challenges/redux';
import fetchUserEpic from './fetch-user-epic';
import hardGoToEpic from './hard-go-to-epic';
import failedUpdatesEpic from './failed-updates-epic';
import updateCompleteEpic from './update-complete-epic';
const ns = 'app';
export const epics = [fetchUserEpic, hardGoToEpic];
export const epics = [
fetchUserEpic,
hardGoToEpic,
failedUpdatesEpic,
updateCompleteEpic
];
export const types = createTypes(
[
@ -19,7 +26,12 @@ export const types = createTypes(
'hardGoTo',
'updateUserSignedIn',
'openDonationModal',
'closeDonationModal'
'closeDonationModal',
'onlineStatusChange',
'updateComplete',
'updateFailed'
],
ns
);
@ -30,7 +42,8 @@ const initialState = {
showLoading: true,
isSignedIn: false,
user: {},
showDonationModal: false
showDonationModal: false,
isOnline: true
};
export const fetchUser = createAction(types.fetchUser);
@ -45,9 +58,17 @@ export const closeDonationModal = createAction(types.closeDonationModal);
export const updateUserSignedIn = createAction(types.updateUserSignedIn);
export const onlineStatusChange = createAction(types.onlineStatusChange);
export const updateComplete = createAction(types.updateComplete);
export const updateFailed = createAction(types.updateFailed);
export const completionCountSelector = state => state[ns].completionCount;
export const isDonationModalOpenSelector = state => state[ns].showDonationModal;
export const isSignedInSelector = state => state[ns].isSignedIn;
export const isOnlineSelector = state => state[ns].isOnline;
export const userSelector = state => state[ns].user || {};
export const userStateLoadingSelector = state => state[ns].showLoading;
export const completedChallengesSelector = state =>
@ -100,6 +121,12 @@ export const reducer = handleActions(
...state,
isSignedIn: payload
}),
[types.onlineStatusChange]: (state, { payload: isOnline }) => ({
...state,
isOnline
}),
[challenge.submitComplete]: (state, { payload: { id } }) => ({
...state,
completionCount: state.completionCount + 1,

View File

@ -0,0 +1,12 @@
import { ofType } from 'redux-observable';
import { mapTo, filter } from 'rxjs/operators';
import { types, onlineStatusChange, isOnlineSelector } from './';
export default function updateCompleteEpic(action$, { getState }) {
return action$.pipe(
ofType(types.updateComplete),
filter(() => !isOnlineSelector(getState())),
mapTo(onlineStatusChange(true))
);
}

View File

@ -1,9 +1,9 @@
import { of } from 'rxjs/observable/of';
import { _if } from 'rxjs/observable/if';
import { empty } from 'rxjs/observable/empty';
import {
switchMap,
retry,
map,
catchError,
concat,
filter,
@ -12,8 +12,6 @@ import {
import { ofType } from 'redux-observable';
import { navigate } from 'gatsby';
import { _csrf as csrfToken } from '../../../redux/cookieValues';
import {
backendFormValuesSelector,
projectFormValuesSelector,
@ -29,26 +27,34 @@ import {
userSelector,
isSignedInSelector,
openDonationModal,
shouldShowDonationSelector
shouldShowDonationSelector,
updateComplete,
updateFailed,
isOnlineSelector
} from '../../../redux/app';
import { postJSON$ } from '../utils/ajax-stream';
import postUpdate$ from '../utils/postUpdate$';
import { challengeTypes, submitTypes } from '../../../../utils/challengeTypes';
function postChallenge(url, username, _csrf, challengeInfo) {
const body = { ...challengeInfo, _csrf };
const saveChallenge = postJSON$(url, body).pipe(
function postChallenge(update, username) {
const saveChallenge = postUpdate$(update).pipe(
retry(3),
map(({ points }) =>
submitComplete({
username,
points,
...challengeInfo
})
switchMap(({ points }) =>
of(
submitComplete({
username,
points,
...update.payload
}),
updateComplete()
)
),
catchError(err => {
console.error(err);
return of({ type: 'here is an error' });
catchError(({ _body, _endpoint }) => {
let payload = _body;
if (typeof _body === 'string') {
payload = JSON.parse(_body);
}
return of(updateFailed({ endpoint: _endpoint, payload }));
})
);
return saveChallenge;
@ -65,14 +71,18 @@ function submitModern(type, state) {
const { id } = challengeMetaSelector(state);
const files = challengeFilesSelector(state);
const { username } = userSelector(state);
return postChallenge(
'/external/modern-challenge-completed',
username,
csrfToken,
{
id,
files
}
const challengeInfo = {
id,
files
};
const update = {
endpoint: '/external/modern-challenge-completed',
payload: challengeInfo
};
return _if(
() => isOnlineSelector(state),
postChallenge(update, username),
of(updateFailed(update))
);
}
}
@ -91,13 +101,17 @@ function submitProject(type, state) {
if (challengeType === challengeTypes.backEndProject) {
challengeInfo.githubLink = githubLink;
}
return postChallenge(
'/external/project-completed',
username,
csrfToken,
challengeInfo
).pipe(
concat(of(updateProjectFormValues({})))
const update = {
endpoint: '/external/project-completed',
payload: challengeInfo
};
return _if(
() => isOnlineSelector(state),
postChallenge(update, username).pipe(
concat(of(updateProjectFormValues({})))
),
of(updateFailed(update))
);
}
@ -111,11 +125,15 @@ function submitBackendChallenge(type, state) {
state
);
const challengeInfo = { id, solution };
return postChallenge(
'/external/backend-challenge-completed',
username,
csrfToken,
challengeInfo
const update = {
endpoint: '/external/backend-challenge-completed',
payload: challengeInfo
};
return _if(
() => isOnlineSelector(state),
postChallenge(update, username),
of(updateFailed(update))
);
}
}

View File

@ -1,28 +1,43 @@
import { _if } from 'rxjs/observable/if';
import { of } from 'rxjs/observable/of';
import { ofType } from 'redux-observable';
import { types } from './';
import { filter, switchMap, catchError, mapTo } from 'rxjs/operators';
import {
isSignedInSelector,
currentChallengeIdSelector
currentChallengeIdSelector,
updateComplete,
updateFailed,
isOnlineSelector
} from '../../../redux/app';
import { postJSON$ } from '../utils/ajax-stream';
import { _csrf } from '../../../redux/cookieValues';
import { of } from 'rxjs/observable/of';
import postUpdate$ from '../utils/postUpdate$';
function currentChallengeEpic(action$, { getState }) {
return action$.pipe(
ofType(types.challengeMounted),
filter(() => isSignedInSelector(getState())),
filter(({ payload }) => payload !== currentChallengeIdSelector(getState())),
switchMap(({ payload }) =>
postJSON$('/external/update-my-current-challenge', {
currentChallengeId: payload,
_csrf
})
),
mapTo({ type: 'currentChallengeUpdateComplete' }),
catchError(() => of({ type: 'current-challenge-update-error' }))
switchMap(({ payload }) => {
const update = {
endpoint: '/external/update-my-current-challenge',
payload: {
currentChallengeId: payload
}
};
return _if(
() => isOnlineSelector(getState()),
postUpdate$(update).pipe(mapTo(updateComplete())),
of(updateFailed(update))
);
}),
catchError(({ _body, _endpoint }) => {
let payload = _body;
if (typeof _body === 'string') {
payload = JSON.parse(_body);
}
return of(updateFailed({ endpoint: _endpoint, payload }));
})
);
}

View File

@ -93,15 +93,24 @@ function normalizeAjaxSuccessEvent(e, xhr, settings) {
};
}
function normalizeAjaxErrorEvent(e, xhr, type) {
return {
function createNormalizeAjaxErrorEvent(options) {
let _body = {};
let _endpoint = '';
if (typeof options === 'string') {
_endpoint = options;
} else {
_body = options.body;
_endpoint = options.url;
}
return (e, xhr, type) => ({
_body,
_endpoint,
type: type,
status: xhr.status,
xhr: xhr,
originalEvent: e
};
});
}
/*
* Creates an observable for an Ajax request with either a settings object
* with url, headers, etc or a string for a URL.
@ -131,7 +140,7 @@ export function ajax$(options) {
createXHR: function() {
return this.crossDomain ? getCORSRequest() : getXMLHttpRequest();
},
normalizeError: normalizeAjaxErrorEvent,
normalizeError: createNormalizeAjaxErrorEvent(options),
normalizeSuccess: normalizeAjaxSuccessEvent
};
@ -144,9 +153,7 @@ export function ajax$(options) {
}
}
}
var normalizeError = settings.normalizeError;
var normalizeSuccess = settings.normalizeSuccess;
const { normalizeError, normalizeSuccess } = settings;
if (!settings.crossDomain && !settings.headers['X-Requested-With']) {
settings.headers['X-Requested-With'] = 'XMLHttpRequest';
@ -157,6 +164,12 @@ export function ajax$(options) {
var isDone = false;
var xhr;
const throwWithMeta = err => {
err._body = options.body || {};
err._endpoint = options.url;
observer.error(err);
};
var processResponse = function(xhr, e) {
var status = xhr.status === 1223 ? 204 : xhr.status;
if ((status >= 200 && status <= 300) || status === 0 || status === '') {
@ -164,7 +177,7 @@ export function ajax$(options) {
observer.next(normalizeSuccess(e, xhr, settings));
observer.complete();
} catch (err) {
observer.error(err);
throwWithMeta(err);
}
} else {
observer.error(normalizeError(e, xhr, 'error'));
@ -175,7 +188,7 @@ export function ajax$(options) {
try {
xhr = settings.createXHR();
} catch (err) {
observer.error(err);
throwWithMeta(err);
}
try {
@ -239,7 +252,7 @@ export function ajax$(options) {
debug('ajax$ sending content', settings.hasContent && settings.body);
xhr.send((settings.hasContent && settings.body) || null);
} catch (err) {
observer.error(err);
throwWithMeta(err);
}
return function() {

View File

@ -19,7 +19,7 @@ import { createFileStream, pipe } from './polyvinyl';
const jQuery = {
src: 'https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js'
};
const frameRunner = {
export const frameRunner = {
src: '/js/frame-runner.js',
crossDomain: false,
cacheBreaker: true

View File

@ -0,0 +1,5 @@
import { postJSON$ } from './ajax-stream';
export default function postUpdate$({ endpoint, payload }) {
return postJSON$(endpoint, payload);
}

View File

@ -23,3 +23,7 @@ exports.unDasherize = function unDasherize(name) {
};
exports.descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
exports.isBrowser = function isBrowser() {
return typeof window !== 'undefined';
};