From e4e41e6fe3405541083fbdfd47b344b7806cbead Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Thu, 26 Jul 2018 14:37:10 +0100 Subject: [PATCH] feat(updates): Store updates if the server is not responding --- packages/learn/package.json | 5 +- .../OfflineWarning/OfflineWarning.js | 22 +++++ .../src/components/OfflineWarning/index.js | 1 + .../OfflineWarning/offline-warning.css | 7 ++ packages/learn/src/layouts/index.js | 59 +++++++++--- .../src/redux/app/failed-updates-epic.js | 74 +++++++++++++++ packages/learn/src/redux/app/index.js | 33 ++++++- .../src/redux/app/update-complete-epic.js | 12 +++ .../Challenges/redux/completion-epic.js | 92 +++++++++++-------- .../redux/current-challenge-epic.js | 39 +++++--- .../templates/Challenges/utils/ajax-stream.js | 35 ++++--- .../src/templates/Challenges/utils/build.js | 2 +- .../templates/Challenges/utils/postUpdate$.js | 5 + packages/learn/utils/index.js | 4 + 14 files changed, 312 insertions(+), 78 deletions(-) create mode 100644 packages/learn/src/components/OfflineWarning/OfflineWarning.js create mode 100644 packages/learn/src/components/OfflineWarning/index.js create mode 100644 packages/learn/src/components/OfflineWarning/offline-warning.css create mode 100644 packages/learn/src/redux/app/failed-updates-epic.js create mode 100644 packages/learn/src/redux/app/update-complete-epic.js create mode 100644 packages/learn/src/templates/Challenges/utils/postUpdate$.js 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 = /\