chore(learn): Merge learn in to the client app
This commit is contained in:
5
client/src/redux/cookieValues.js
Normal file
5
client/src/redux/cookieValues.js
Normal 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');
|
41
client/src/redux/createServices.js
Normal file
41
client/src/redux/createServices.js
Normal 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());
|
||||
// });
|
||||
// }
|
@@ -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', () => {
|
||||
|
89
client/src/redux/failed-updates-epic.js
Normal file
89
client/src/redux/failed-updates-epic.js
Normal 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;
|
15
client/src/redux/hard-go-to-epic.js
Normal file
15
client/src/redux/hard-go-to-epic.js
Normal 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()
|
||||
);
|
||||
}
|
@@ -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
|
||||
? {
|
||||
|
@@ -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);
|
||||
}
|
||||
|
74
client/src/redux/propTypes.js
Normal file
74
client/src/redux/propTypes.js
Normal 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
|
||||
})
|
||||
)
|
||||
});
|
8
client/src/redux/rootEpic.js
Normal file
8
client/src/redux/rootEpic.js
Normal 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;
|
@@ -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
|
||||
});
|
||||
|
12
client/src/redux/update-complete-epic.js
Normal file
12
client/src/redux/update-complete-epic.js
Normal 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))
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user