feat(updates): Store updates if the server is not responding
This commit is contained in:
committed by
Mrugesh Mohapatra
parent
54e14b9433
commit
e4e41e6fe3
@ -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",
|
||||
|
@ -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;
|
1
packages/learn/src/components/OfflineWarning/index.js
Normal file
1
packages/learn/src/components/OfflineWarning/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './OfflineWarning';
|
@ -0,0 +1,7 @@
|
||||
.offline-warning {
|
||||
height: 38px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: lightblue;
|
||||
}
|
@ -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 />
|
||||
|
74
packages/learn/src/redux/app/failed-updates-epic.js
Normal file
74
packages/learn/src/redux/app/failed-updates-epic.js
Normal 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;
|
@ -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,
|
||||
|
12
packages/learn/src/redux/app/update-complete-epic.js
Normal file
12
packages/learn/src/redux/app/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$, { getState }) {
|
||||
return action$.pipe(
|
||||
ofType(types.updateComplete),
|
||||
filter(() => !isOnlineSelector(getState())),
|
||||
mapTo(onlineStatusChange(true))
|
||||
);
|
||||
}
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 }));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { postJSON$ } from './ajax-stream';
|
||||
|
||||
export default function postUpdate$({ endpoint, payload }) {
|
||||
return postJSON$(endpoint, payload);
|
||||
}
|
@ -23,3 +23,7 @@ exports.unDasherize = function unDasherize(name) {
|
||||
};
|
||||
|
||||
exports.descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
|
||||
|
||||
exports.isBrowser = function isBrowser() {
|
||||
return typeof window !== 'undefined';
|
||||
};
|
||||
|
Reference in New Issue
Block a user