chore(learn): Merge learn in to the client app

This commit is contained in:
Bouncey
2018-09-30 11:37:19 +01:00
committed by Stuart Taylor
parent 9e869a46fc
commit 5b254f3ad6
320 changed files with 9820 additions and 27605 deletions

View File

@@ -0,0 +1,5 @@
import cookies from 'browser-cookies';
export const _csrf = typeof window !== 'undefined' && cookies.get('_csrf');
export const jwt =
typeof window !== 'undefined' && cookies.get('jwt_access_token');

View File

@@ -0,0 +1,41 @@
import { Observable } from 'rxjs';
import Fetchr from 'fetchr';
function callbackObserver(observer) {
return (err, res) => {
if (err) {
return observer.error(err);
}
observer.next(res);
return observer.complete();
};
}
export default function servicesCreator(options) {
const services = new Fetchr(options);
return {
readService$({ service: resource, params = {} }) {
return Observable.create(observer =>
services
.read(resource)
.params(params)
.end(callbackObserver(observer))
);
}
};
}
// createService$({ service: resource, params, body, config }) {
// return Observable.create(observer => {
// services.create(
// resource,
// params,
// body,
// config,
// callbackObserver(observer)
// );
// return Subscription.create(() => observer.dispose());
// });
// }

View File

@@ -1,14 +1,43 @@
/* eslint-disable-next-line max-len */
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createStore as reduxCreateStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { createEpicMiddleware } from 'redux-observable';
import servicesCreator from './createServices';
import { _csrf } from './cookieValues';
import rootEpic from './rootEpic';
import rootReducer from './rootReducer';
import rootSaga from './rootSaga';
const serviceOptions = {
context: _csrf ? { _csrf } : {},
xhrPath: '/external/services',
xhrTimeout: 15000
};
const sagaMiddleware = createSagaMiddleware();
const epicMiddleware = createEpicMiddleware({
dependencies: {
window: typeof window !== 'undefined' ? window : {},
location: typeof window !== 'undefined' ? window.location : {},
document: typeof window !== 'undefined' ? document : {},
services: servicesCreator(serviceOptions)
}
});
const composeEnhancers = composeWithDevTools({
// options like actionSanitizer, stateSanitizer
});
export const createStore = () => {
const store = reduxCreateStore(rootReducer, applyMiddleware(sagaMiddleware));
const store = reduxCreateStore(
rootReducer,
composeEnhancers(applyMiddleware(sagaMiddleware, epicMiddleware))
);
sagaMiddleware.run(rootSaga);
epicMiddleware.run(rootEpic);
if (module.hot) {
// Enable Webpack hot module replacement for reducers
module.hot.accept('./rootReducer', () => {

View File

@@ -0,0 +1,89 @@
import { merge, empty } from 'rxjs';
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$';
import { isGoodXHRStatus } from '../templates/Challenges/utils';
const key = 'fcc-failed-updates';
function delay(time = 0, fn) {
return setTimeout(fn, time);
}
function failedUpdateEpic(action$, state$) {
const storeUpdates = action$.pipe(
ofType(types.updateFailed),
tap(({ payload = {} }) => {
if ('endpoint' in payload && 'payload' in 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(state$.value)),
tap(() => {
const failures = store.get(key) || [];
let delayTime = 100;
const batch = failures.map((update, i) => {
// we stagger the updates here so we don't hammer the server
// *********************************************************
// progressivly increase additional delay by the amount of updates
// 1st: 100ms delay
// 2nd: 200ms delay
// 3rd: 400ms delay
// 4th: 700ms delay
// 5th: 1100ms delay
// 6th: 1600ms delay
// and so-on
delayTime += 100 * i;
return delay(delayTime, () =>
postUpdate$(update)
.pipe(
switchMap(response => {
if (
response &&
(response.message || isGoodXHRStatus(response.status))
) {
console.info(`${update.id} succeeded`);
// 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

@@ -0,0 +1,15 @@
/* global HOME_PATH */
import { ofType } from 'redux-observable';
import { tap, ignoreElements } from 'rxjs/operators';
import { types } from './';
export default function hardGoToEpic(action$, _, { location }) {
return action$.pipe(
ofType(types.hardGoTo),
tap(({ payload = HOME_PATH }) => {
location.href = payload;
}),
ignoreElements()
);
}

View File

@@ -1,4 +1,5 @@
import { createAction, handleActions } from 'redux-actions';
import { uniqBy } from 'lodash';
import { createTypes, createAsyncTypes } from '../utils/createTypes';
import { createFetchUserSaga } from './fetch-user-saga';
@@ -8,8 +9,16 @@ import { createReportUserSaga } from './report-user-saga';
import { createShowCertSaga } from './show-cert-saga';
import { createNightModeSaga } from './night-mode-saga';
import hardGoToEpic from './hard-go-to-epic';
import failedUpdatesEpic from './failed-updates-epic';
import updateCompleteEpic from './update-complete-epic';
import { types as settingsTypes } from './settings';
/** ***********************************/
const challengeReduxTypes = {};
/** ***********************************/
const ns = 'app';
const defaultFetchState = {
@@ -21,6 +30,7 @@ const defaultFetchState = {
const initialState = {
appUsername: '',
completionCount: 0,
showCert: {},
showCertFetchState: {
...defaultFetchState
@@ -28,12 +38,19 @@ const initialState = {
user: {},
userFetchState: {
...defaultFetchState
}
},
showDonationModal: false,
isOnline: true
};
const types = createTypes(
export const types = createTypes(
[
'appMount',
'closeDonationModal',
'openDonationModal',
'onlineStatusChange',
'updateComplete',
'updateFailed',
...createAsyncTypes('fetchUser'),
...createAsyncTypes('acceptTerms'),
...createAsyncTypes('showCert'),
@@ -42,6 +59,12 @@ const types = createTypes(
ns
);
export const epics = [
hardGoToEpic,
failedUpdatesEpic,
updateCompleteEpic
];
export const sagas = [
...createAcceptTermsSaga(types),
...createAppMountSaga(types),
@@ -53,6 +76,14 @@ export const sagas = [
export const appMount = createAction(types.appMount);
export const closeDonationModal = createAction(types.closeDonationModal);
export const openDonationModal = createAction(types.openDonationModal);
export const onlineStatusChange = createAction(types.onlineStatusChange);
export const updateComplete = createAction(types.updateComplete);
export const updateFailed = createAction(types.updateFailed);
export const acceptTerms = createAction(types.acceptTerms);
export const acceptTermsComplete = createAction(types.acceptTermsComplete);
export const acceptTermsError = createAction(types.acceptTermsError);
@@ -69,14 +100,39 @@ export const showCert = createAction(types.showCert);
export const showCertComplete = createAction(types.showCertComplete);
export const showCertError = createAction(types.showCertError);
export const isSignedInSelector = state => !!Object.keys(state[ns].user).length;
export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || [];
export const completionCountSelector = state => state[ns].completionCount;
export const currentChallengeIdSelector = state =>
userSelector(state).currentChallengeId || '';
export const isOnlineSelector = state => state[ns].isOnline;
export const isSignedInSelector = state => !!state[ns].appUsername;
export const isDonationModalOpenSelector = state => state[ns].showDonationModal;
export const signInLoadingSelector = state =>
userFetchStateSelector(state).pending;
export const showCertSelector = state => state[ns].showCert;
export const showCertFetchStateSelector = state => state[ns].showCertFetchState;
export const showDonationSelector = state => {
const completedChallenges = completedChallengesSelector(state);
const completionCount = completionCountSelector(state);
const currentCompletedLength = completedChallenges.length;
// the user has not completed 9 challenges in total yet
if (currentCompletedLength < 9) {
return false;
}
// this will mean we are on the 10th submission in total for the user
if (completedChallenges.length === 9) {
return true;
}
// this will mean we are on the 3rd submission for this browser session
if (completionCount === 2) {
return true;
}
return false;
};
export const userByNameSelector = username => state => {
const { user } = state[ns];
return username in user ? user[username] : {};
@@ -131,6 +187,14 @@ export const reducer = handleActions(
error: payload
}
}),
[types.onlineStatusChange]: (state, { payload: isOnline }) => ({
...state,
isOnline
}),
[types.openDonationModal]: state => ({
...state,
showDonationModal: true
}),
[types.showCert]: state => ({
...state,
showCert: {},
@@ -156,6 +220,23 @@ export const reducer = handleActions(
error: payload
}
}),
[challengeReduxTypes.submitComplete]: (state, { payload: { id } }) => {
const { appUsername } = state;
return {
...state,
completionCount: state.completionCount + 1,
user: {
...state.user,
[appUsername]: {
...state.user[appUsername],
completedChallenges: uniqBy(
[...state.user[appUsername].completedChallenges, { id }],
'id'
)
}
}
};
},
[settingsTypes.submitNewUsernameComplete]: (state, { payload }) =>
payload
? {

View File

@@ -16,14 +16,14 @@ function setTheme(currentTheme = defaultTheme, theme) {
html.classList.add(theme);
}
function* updateLocalThemeSaga({ payload }) {
const currentTheme = store.get(themeKey);
if ('user' in payload) {
const { theme = defaultTheme } = payload.user;
function* updateLocalThemeSaga({ payload: {user, theme } }) {
const currentTheme = store.get(themeKey) || defaultTheme;
if (user) {
const { theme = defaultTheme } = user;
return setTheme(currentTheme, theme);
}
if ('theme' in payload) {
return setTheme(currentTheme, payload.theme);
if (theme) {
return setTheme(currentTheme, theme);
}
return setTheme(currentTheme);
}

View File

@@ -0,0 +1,74 @@
import PropTypes from 'prop-types';
const FileType = PropTypes.shape({
key: PropTypes.string,
ext: PropTypes.string,
name: PropTypes.string,
contents: PropTypes.string,
head: PropTypes.string,
tail: PropTypes.string
});
export const MarkdownRemark = PropTypes.shape({
html: PropTypes.string,
frontmatter: PropTypes.shape({
title: PropTypes.string,
block: PropTypes.string,
superBlock: PropTypes.string
})
});
export const ChallengeNode = PropTypes.shape({
block: PropTypes.string,
challengeType: PropTypes.number,
dashedName: PropTypes.string,
description: PropTypes.arrayOf(PropTypes.string),
files: PropTypes.shape({
indexhtml: FileType,
indexjs: FileType
}),
fields: PropTypes.shape({
slug: PropTypes.string,
blockName: PropTypes.string
}),
guideUrl: PropTypes.string,
head: PropTypes.arrayOf(PropTypes.string),
helpRoom: PropTypes.string,
suborder: PropTypes.number,
isBeta: PropTypes.bool,
isComingSoon: PropTypes.bool,
isLocked: PropTypes.bool,
isPrivate: PropTypes.bool,
isRequired: PropTypes.bool,
name: PropTypes.string,
order: PropTypes.number,
required: PropTypes.arrayOf(
PropTypes.shape({
link: PropTypes.string,
raw: PropTypes.string,
src: PropTypes.string
})
),
superOrder: PropTypes.number,
superBlock: PropTypes.string,
tail: PropTypes.arrayOf(PropTypes.string),
time: PropTypes.string,
title: PropTypes.string,
videoUrl: PropTypes.string
});
export const AllChallengeNode = PropTypes.shape({
edges: PropTypes.arrayOf(
PropTypes.shape({
node: ChallengeNode
})
)
});
export const AllMarkdownRemark = PropTypes.shape({
edges: PropTypes.arrayOf(
PropTypes.shape({
node: MarkdownRemark
})
)
});

View File

@@ -0,0 +1,8 @@
import { combineEpics } from 'redux-observable';
import { epics as appEpics } from './';
import { epics as challengeEpics } from '../templates/Challenges/redux';
const rootEpic = combineEpics(...appEpics, ...challengeEpics);
export default rootEpic;

View File

@@ -1,11 +1,17 @@
import { combineReducers } from 'redux';
import {reducer as formReducer} from 'redux-form';
import { reducer as app } from './';
import { reducer as flash } from '../components/Flash/redux';
import { reducer as settings } from './settings';
import { reducer as curriculumMap } from '../components/Map/redux';
import { reducer as challenge } from '../templates/Challenges/redux';
export default combineReducers({
app,
challenge,
curriculumMap,
flash,
form: formReducer,
settings
});

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$, state$) {
return action$.pipe(
ofType(types.updateComplete),
filter(() => !isOnlineSelector(state$.value)),
mapTo(onlineStatusChange(true))
);
}