diff --git a/packages/learn/package.json b/packages/learn/package.json
index 1e7ca13a31..47b84a3c38 100644
--- a/packages/learn/package.json
+++ b/packages/learn/package.json
@@ -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",
diff --git a/packages/learn/src/components/OfflineWarning/OfflineWarning.js b/packages/learn/src/components/OfflineWarning/OfflineWarning.js
new file mode 100644
index 0000000000..69fa384096
--- /dev/null
+++ b/packages/learn/src/components/OfflineWarning/OfflineWarning.js
@@ -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 : (
+
+ We cannot reach the server to update your progress.
+
+ );
+}
+
+OfflineWarning.displayName = 'OfflineWarning';
+OfflineWarning.propTypes = propTypes;
+
+export default OfflineWarning;
diff --git a/packages/learn/src/components/OfflineWarning/index.js b/packages/learn/src/components/OfflineWarning/index.js
new file mode 100644
index 0000000000..f22e29909f
--- /dev/null
+++ b/packages/learn/src/components/OfflineWarning/index.js
@@ -0,0 +1 @@
+export { default } from './OfflineWarning';
diff --git a/packages/learn/src/components/OfflineWarning/offline-warning.css b/packages/learn/src/components/OfflineWarning/offline-warning.css
new file mode 100644
index 0000000000..f8adedeeb0
--- /dev/null
+++ b/packages/learn/src/components/OfflineWarning/offline-warning.css
@@ -0,0 +1,7 @@
+.offline-warning {
+ height: 38px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: lightblue;
+}
diff --git a/packages/learn/src/layouts/index.js b/packages/learn/src/layouts/index.js
index 5324c5df84..76eb9195bc 100644
--- a/packages/learn/src/layouts/index.js
+++ b/packages/learn/src/layouts/index.js
@@ -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 (
+
{children}
diff --git a/packages/learn/src/redux/app/failed-updates-epic.js b/packages/learn/src/redux/app/failed-updates-epic.js
new file mode 100644
index 0000000000..dc4ce96566
--- /dev/null
+++ b/packages/learn/src/redux/app/failed-updates-epic.js
@@ -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;
diff --git a/packages/learn/src/redux/app/index.js b/packages/learn/src/redux/app/index.js
index 60e8f84e2a..2e3f9a543c 100644
--- a/packages/learn/src/redux/app/index.js
+++ b/packages/learn/src/redux/app/index.js
@@ -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,
diff --git a/packages/learn/src/redux/app/update-complete-epic.js b/packages/learn/src/redux/app/update-complete-epic.js
new file mode 100644
index 0000000000..054ef0d120
--- /dev/null
+++ b/packages/learn/src/redux/app/update-complete-epic.js
@@ -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))
+ );
+}
diff --git a/packages/learn/src/templates/Challenges/redux/completion-epic.js b/packages/learn/src/templates/Challenges/redux/completion-epic.js
index f56ba5a697..d7eda54b50 100644
--- a/packages/learn/src/templates/Challenges/redux/completion-epic.js
+++ b/packages/learn/src/templates/Challenges/redux/completion-epic.js
@@ -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))
);
}
}
diff --git a/packages/learn/src/templates/Challenges/redux/current-challenge-epic.js b/packages/learn/src/templates/Challenges/redux/current-challenge-epic.js
index a49041c8ca..24e15c1a6e 100644
--- a/packages/learn/src/templates/Challenges/redux/current-challenge-epic.js
+++ b/packages/learn/src/templates/Challenges/redux/current-challenge-epic.js
@@ -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 }));
+ })
);
}
diff --git a/packages/learn/src/templates/Challenges/utils/ajax-stream.js b/packages/learn/src/templates/Challenges/utils/ajax-stream.js
index 2063e53913..df08952913 100644
--- a/packages/learn/src/templates/Challenges/utils/ajax-stream.js
+++ b/packages/learn/src/templates/Challenges/utils/ajax-stream.js
@@ -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() {
diff --git a/packages/learn/src/templates/Challenges/utils/build.js b/packages/learn/src/templates/Challenges/utils/build.js
index a64de105b0..503726f9f3 100644
--- a/packages/learn/src/templates/Challenges/utils/build.js
+++ b/packages/learn/src/templates/Challenges/utils/build.js
@@ -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
diff --git a/packages/learn/src/templates/Challenges/utils/postUpdate$.js b/packages/learn/src/templates/Challenges/utils/postUpdate$.js
new file mode 100644
index 0000000000..14d0d1283f
--- /dev/null
+++ b/packages/learn/src/templates/Challenges/utils/postUpdate$.js
@@ -0,0 +1,5 @@
+import { postJSON$ } from './ajax-stream';
+
+export default function postUpdate$({ endpoint, payload }) {
+ return postJSON$(endpoint, payload);
+}
diff --git a/packages/learn/utils/index.js b/packages/learn/utils/index.js
index ae35ea0bc8..df2959da4c 100644
--- a/packages/learn/utils/index.js
+++ b/packages/learn/utils/index.js
@@ -23,3 +23,7 @@ exports.unDasherize = function unDasherize(name) {
};
exports.descriptionRegex = /\