feat: prep for modern challenges (#15781)

* feat(seed): Add modern challenge

* chore(react): Use prop-types package

* feat: Initial refactor to redux-first-router

BREAKING CHANGE: Everything is different!

* feat: First rendering

* feat(routes): Challenges view render but failing

* fix(Challenges): Remove contain HOC

* fix(RFR): Add params selector

* fix(RFR): :en should be :lang

* fix: Update berks utils for redux

* fix(Map): Challenge link to arg

* fix(Map): Add trailing slash to map page

* fix(RFR): Use FCC Link

Use fcc Link to get around issue of lang being undefined

* fix(Router): Link to is required

* fix(app): Rely on RFR state for app lang

* chore(RFR): Remove unused RFR Link

* fix(RFR): Hydrate initial challenge using RFR and RO

* fix: Casing issue

* fix(RFR): Undefined links

* fix(RFR): Use onRoute<name> convention for route types

* feat(server/react): Add helpful redux logging/throwing

* fix(server/react): Strip out nonjson from state

This prevents thunks in routesMap from breaking serialization

* fix(RFR/Link): Should accept any renderable

* fix(RFR): Get redirects working

* fix(RFR): Redirects and not found's

* fix(Map): Move challenge onClick handler

* fix(Map): Allow Router.link to handle clicks after onClick

* fix(routes): Remove react-router-redux

* feat(Router): Add lang to all route actions by default

* fix(entities): Only fetch challenge if not already loaded

* fix(Files): Move files to own feature

* chore(Challenges): Remove vestigial hints logic

* fix(RFR): Update challenges on route challenges

* fix(code-storage): Should use events instead of commands

* fix(Map): ClickOnMap should not hold on to event

* chore(lint): Use eslint-config-freecodecamp

Closes #15938

* feat(Panes): Update panes on route instead of render

* fix(Panes): Store panesmap and update on fetchchallenges

* fix(Panes): Normalize panesmaps

* fix(Panes): Remove filter from createpanemap

* fix(Panes): Middleware on location meta object

* feat(Panes): Filter preview on nonhtml challenges

* build(babel): Add lodash babel plugin

* chore(lint): Lint js files

* fix(server/user-stats): Remove use of lodash chain

this interferes with babel-plugin-lodash

* feat(dev): Add remote redux devtools for ssr

* fix(Panes): Dispatch mount action

this is needed to trigger window/divider epics

* fix(Panes): Getpane to use new panesmap format

* fix(Panes): Always update panes after state

this lets the panes logic be affected by changes in state
This commit is contained in:
Berkeley Martinez
2017-11-09 17:10:30 -08:00
committed by Quincy Larson
parent 2e46e60557
commit dbecdc5618
141 changed files with 4984 additions and 3186 deletions

View File

@ -1,4 +1,7 @@
{
"presets": ["es2015", "react", "stage-0"],
"plugins": ["babel-plugin-add-module-exports"]
"plugins": [
"babel-plugin-add-module-exports",
"lodash"
]
}

206
.eslintrc
View File

@ -1,207 +1,3 @@
{
"parserOption": {
"ecmaVersion": 6,
"ecmaFeatures": {
"jsx": true
}
},
"env": {
"es6": true,
"browser": true,
"mocha": true,
"node": true
},
"parser": "babel-eslint",
"plugins": [
"react",
"import",
"prefer-object-spread"
],
"settings": {
"import/ignore": [
"node_modules",
"\\.json$"
],
"import/extensions": [
".js",
".jsx"
]
},
"globals": {
"Promise": true,
"window": true,
"$": true,
"ga": true,
"jQuery": true,
"router": true
},
"rules": {
"block-scoped-var": 0,
"brace-style": [ 2, "1tbs", { "allowSingleLine": true } ],
"camelcase": 2,
"comma-dangle": 2,
"comma-spacing": [ 2, { "before": false, "after": true } ],
"comma-style": [ 2, "last" ],
"complexity": 0,
"consistent-return": 2,
"consistent-this": 0,
"curly": 2,
"default-case": 2,
"dot-notation": 0,
"eol-last": 2,
"eqeqeq": 2,
"func-names": 0,
"func-style": 0,
"guard-for-in": 2,
"handle-callback-err": 2,
"jsx-quotes": [ 2, "prefer-single" ],
"key-spacing": [ 2, { "beforeColon": false, "afterColon": true } ],
"keyword-spacing": [ 2 ],
"max-depth": 0,
"max-len": [ 2, 80, 2 ],
"max-nested-callbacks": 0,
"max-params": 0,
"max-statements": 0,
"new-cap": 0,
"new-parens": 2,
"no-alert": 2,
"no-array-constructor": 2,
"no-bitwise": 2,
"no-caller": 2,
"no-catch-shadow": 2,
"no-cond-assign": 2,
"no-console": 0,
"no-constant-condition": 2,
"no-control-regex": 2,
"no-debugger": 2,
"no-delete-var": 2,
"no-div-regex": 2,
"no-dupe-keys": 2,
"no-else-return": 0,
"no-empty": 2,
"no-empty-character-class": 2,
"no-eq-null": 2,
"no-eval": 2,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": 0,
"no-extra-semi": 2,
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inline-comments": 2,
"no-inner-declarations": 2,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": 2,
"no-lone-blocks": 2,
"no-lonely-if": 2,
"no-loop-func": 2,
"no-mixed-requires": 0,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [ 2, { "max": 2 } ],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-nested-ternary": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-object": 2,
"no-new-require": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-path-concat": 2,
"no-plusplus": 0,
"no-process-env": 0,
"no-process-exit": 2,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-reserved-keys": 0,
"no-restricted-modules": 0,
"no-return-assign": 2,
"no-script-url": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 0,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-sync": 0,
"no-ternary": 0,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-undefined": 2,
"no-underscore-dangle": 0,
"no-unreachable": 2,
"no-unused-expressions": 2,
"no-unused-vars": 2,
"no-use-before-define": 0,
"no-void": 0,
"no-warning-comments": [ 2, { "terms": [ "fixme" ], "location": "start" } ],
"no-with": 2,
"one-var": 0,
"operator-assignment": 0,
"padded-blocks": 0,
"quote-props": [ 2, "as-needed" ],
"quotes": [ 2, "single", "avoid-escape" ],
"radix": 2,
"semi": [ 2, "always" ],
"semi-spacing": [ 2, { "before": false, "after": true } ],
"sort-vars": 0,
"space-before-blocks": [ 2, "always" ],
"space-before-function-paren": [ 2, "never" ],
"space-in-brackets": 0,
"space-in-parens": 0,
"space-infix-ops": 2,
"space-unary-ops": [ 2, { "words": true, "nonwords": false } ],
"spaced-comment": [ 2, "always", { "exceptions": [ "-" ] } ],
"strict": 0,
"use-isnan": 2,
"valid-jsdoc": 2,
"valid-typeof": 2,
"vars-on-top": 0,
"wrap-iife": [ 2, "any" ],
"wrap-regex": 2,
"yoda": 0,
"react/display-name": 2,
"react/jsx-boolean-value": [ 2, "always" ],
"react/jsx-closing-bracket-location": [ 2, { "selfClosing": "line-aligned", "nonEmpty": "props-aligned" } ],
"react/jsx-no-undef": 2,
"react/jsx-sort-props": [ 2, { "ignoreCase": true } ],
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
"react/jsx-wrap-multilines": 2,
"react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2,
"react/no-multi-comp": [ 2, { "ignoreStateless": true } ],
"react/no-unknown-property": 2,
"react/prop-types": 2,
"react/react-in-jsx-scope": 2,
"react/self-closing-comp": 2,
"react/sort-prop-types": 2,
"import/default": 2,
"import/export": 2,
"import/extensions": [ 0, "always" ],
"import/first": 2,
"import/named": 2,
"import/namespace": 2,
"import/newline-after-import": 2,
"import/no-duplicates": 2,
"import/no-unresolved": 2,
"import/unambiguous": 2,
"prefer-object-spread/prefer-object-spread": 2
}
"extends": "freecodecamp"
}

View File

@ -27,7 +27,7 @@ function formatFields({ type, ...fields }) {
}, { type });
}
export default function analyticsSaga(actions, { getState }, { window }) {
export default function analyticsSaga(actions, _, { window }) {
const { ga } = window;
if (typeof ga !== 'function') {
console.log('GA not found');

View File

@ -7,20 +7,21 @@ import { removeCodeUri, getCodeUri } from '../utils/code-uri';
import { setContent } from '../../common/utils/polyvinyl';
import {
types as app,
userSelector,
challengeSelector
} from '../../common/app/redux';
import { makeToast } from '../../common/app/Toasts/redux';
import {
types,
savedCodeFound,
updateMain,
lockUntrustedCode,
keySelector,
filesSelector,
codeLockedSelector
} from '../../common/app/routes/challenges/redux';
} from '../../common/app/routes/Challenges/redux';
import { filesSelector, savedCodeFound } from '../../common/app/files';
const legacyPrefixes = [
'Bonfire: ',
@ -59,18 +60,19 @@ function legacyToFile(code, files, key) {
}
export function clearCodeEpic(actions, { getState }) {
return actions::ofType(types.clearSavedCode)
.map(() => {
return actions::ofType(types.submitChallenge.complete)
.do(() => {
const { id } = challengeSelector(getState());
store.remove(id);
})
.ignoreElements();
}
export function saveCodeEpic(actions, { getState }) {
return actions::ofType(types.saveCode)
return actions::ofType(types.executeChallenge)
// do not save challenge if code is locked
.filter(() => !codeLockedSelector(getState()))
.map(() => {
.do(() => {
const { id } = challengeSelector(getState());
const files = filesSelector(getState());
store.set(id, files);
@ -79,7 +81,11 @@ export function saveCodeEpic(actions, { getState }) {
}
export function loadCodeEpic(actions, { getState }, { window, location }) {
return actions::ofType(types.loadCode)
return Observable.merge(
actions::ofType(app.appMounted),
actions::ofType(types.onRouteChallenges)
.distinctUntilChanged(({ payload: { dashedName } }) => dashedName)
)
.flatMap(() => {
let finalFiles;
const state = getState();

View File

@ -17,11 +17,11 @@ import {
frameMain,
frameTests,
initOutput,
saveCode,
filesSelector,
codeLockedSelector
} from '../../common/app/routes/challenges/redux';
} from '../../common/app/routes/Challenges/redux';
import { filesSelector } from '../../common/app/files';
export default function executeChallengeEpic(actions, { getState }) {
return actions::ofType(types.executeChallenge, types.updateMain)
@ -47,7 +47,7 @@ export default function executeChallengeEpic(actions, { getState }) {
frameMain(payload)
];
if (type === types.executeChallenge) {
actions.push(saveCode(), frameTests(payload));
actions.push(frameTests(payload));
}
return Observable.from(actions, null, null, Scheduler.default);
})

View File

@ -12,7 +12,7 @@ import {
codeLockedSelector,
testsSelector
} from '../../common/app/routes/challenges/redux';
} from '../../common/app/routes/Challenges/redux';
// we use two different frames to make them all essentially pure functions
// main iframe is responsible rendering the preview and is where we proxy the

View File

@ -1,7 +1,7 @@
import { types } from '../../common/app/redux';
import { ofType } from 'redux-epic';
export default function hardGoToSaga(actions, { getState }, { history }) {
export default function hardGoToSaga(actions, _, { history }) {
return actions::ofType(types.hardGoTo)
.map(({ payload = '/settings' }) => {
history.pushState(history.state, null, payload);

View File

@ -1,6 +1,6 @@
import { Observable } from 'rx';
import MouseTrap from 'mousetrap';
import { push } from 'react-router-redux';
import { push } from 'redux-first-router';
import {
toggleNightMode,
hardGoTo

View File

@ -1,21 +1,13 @@
import './es6-shims';
import Rx from 'rx';
import React from 'react';
import debug from 'debug';
import { Router } from 'react-router';
import {
routerMiddleware,
routerReducer as routing,
syncHistoryWithStore
} from 'react-router-redux';
import { render } from 'redux-epic';
import { createHistory } from 'history';
import createHistory from 'history/createBrowserHistory';
import useLangRoutes from './utils/use-lang-routes';
import sendPageAnalytics from './utils/send-page-analytics';
import flashToToast from './utils/flash-to-toast';
import createApp from '../common/app';
import provideStore from '../common/app/provide-store';
import { App, createApp, provideStore } from '../common/app';
import { getLangFromPath } from '../common/app/utils/lang';
// client specific epics
@ -32,13 +24,13 @@ const log = debug('fcc:client');
const hotReloadTimeout = 2000;
const { csrf: { token: csrfToken } = {} } = window.__fcc__;
const DOMContainer = document.getElementById('fcc');
const initialState = isColdStored() ?
const defaultState = isColdStored() ?
getColdStorage() :
window.__fcc__.data;
const primaryLang = getLangFromPath(window.location.pathname);
initialState.app.csrfToken = csrfToken;
initialState.toasts = flashToToast(window.__fcc__.flash);
defaultState.app.csrfToken = csrfToken;
defaultState.toasts = flashToToast(window.__fcc__.flash);
// make empty object so hot reload works
window.__fcc__ = {};
@ -49,7 +41,6 @@ const history = useLangRoutes(createHistory, primaryLang)();
sendPageAnalytics(history, window.ga);
const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
const adjustUrlOnReplay = !!window.devToolsExtension;
const epicOptions = {
isDev,
@ -61,14 +52,10 @@ const epicOptions = {
createApp({
history,
syncHistoryWithStore,
syncOptions: { adjustUrlOnReplay },
serviceOptions,
initialState,
middlewares: [ routerMiddleware(history) ],
defaultState,
epics,
epicOptions,
reducers: { routing },
enhancers: [ devTools ]
})
.doOnNext(({ store }) => {
@ -83,13 +70,7 @@ createApp({
}
})
.doOnNext(() => log('rendering'))
.flatMap(
({ props, store }) => render(
provideStore(React.createElement(Router, props), store),
DOMContainer
),
({ store }) => store
)
.flatMap(({ store }) => render(provideStore(App, store), DOMContainer))
.subscribe(
() => debug('react rendered'),
err => { throw err; },

View File

@ -1,29 +1,34 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import ns from './ns.json';
import {
appMounted,
fetchUser,
updateAppLang,
userSelector
isSignedInSelector
} from './redux';
import Nav from './Nav';
import Toasts from './Toasts';
import NotFound from './NotFound';
import { mainRouteSelector } from './routes/redux';
import Challenges from './routes/Challenges';
import Settings from './routes/Settings';
const mapDispatchToProps = {
appMounted,
fetchUser,
updateAppLang
fetchUser
};
const mapStateToProps = state => {
const { username } = userSelector(state);
const isSignedIn = isSignedInSelector(state);
const route = mainRouteSelector(state);
return {
toast: state.app.toast,
isSignedIn: !!username
isSignedIn,
route
};
};
@ -32,19 +37,17 @@ const propTypes = {
children: PropTypes.node,
fetchUser: PropTypes.func,
isSignedIn: PropTypes.bool,
params: PropTypes.object,
toast: PropTypes.object,
updateAppLang: PropTypes.func.isRequired
route: PropTypes.string,
toast: PropTypes.object
};
const routes = {
challenges: Challenges,
settings: Settings
};
// export plain class for testing
export class FreeCodeCamp extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.params.lang !== nextProps.params.lang) {
this.props.updateAppLang(nextProps.params.lang);
}
}
componentDidMount() {
this.props.appMounted();
if (!this.props.isSignedIn) {
@ -53,14 +56,18 @@ export class FreeCodeCamp extends React.Component {
}
render() {
const {
route
} = this.props;
const Child = routes[route] || NotFound;
// we render nav after the content
// to allow the panes to update
// redux store, which will update the bin
// buttons in the nav
return (
<div className={ `${ns}-container` }>
{ this.props.children }
<Nav />
<Child />
<Toasts />
</div>
);

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import ns from './ns.json';

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FA from 'react-fontawesome';

View File

@ -1,7 +1,7 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Link } from 'react-router';
import PureComponent from 'react-pure-render/component';
import classnames from 'classnames';
import debug from 'debug';
@ -13,6 +13,8 @@ import {
} from './redux';
import { userSelector } from '../redux';
import { challengeMapSelector } from '../entities';
import { Link } from '../Router';
import { onRouteChallenges } from '../routes/Challenges/redux';
const propTypes = {
block: PropTypes.string,
@ -27,15 +29,7 @@ const propTypes = {
isRequired: PropTypes.bool,
title: PropTypes.string
};
function mapDispatchToProps(dispatch, { dashedName }) {
const dispatchers = {
clickOnChallenge: e => {
e.preventDefault();
return dispatch(clickOnChallenge(dashedName));
}
};
return () => dispatchers;
}
const mapDispatchToProps = { clickOnChallenge };
function makeMapStateToProps(_, { dashedName }) {
return createSelector(
@ -152,8 +146,11 @@ export class Challenge extends PureComponent {
className={ challengeClassName }
key={ title }
>
<Link to={ `/challenges/${block}/${dashedName}` }>
<span onClick={ clickOnChallenge }>
<Link
onClick={ clickOnChallenge }
to={ onRouteChallenges({ dashedName, block }) }
>
<span >
{ title }
{ this.renderCompleted(isCompleted, isLocked) }
{ this.renderRequired(isRequired) }

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component';
import { Col, Row } from 'react-bootstrap';

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import PureComponent from 'react-pure-render/component';

View File

@ -1,11 +1,12 @@
import { createTypes } from 'redux-create-types';
import { createAction, handleActions } from 'redux-actions';
import {
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import { createSelector } from 'reselect';
import identity from 'lodash/identity';
import noop from 'lodash/noop';
import capitalize from 'lodash/capitalize';
import selectChallengeEpic from './select-challenge-epic.js';
import * as utils from './utils.js';
import ns from '../ns.json';
import {
@ -13,11 +14,10 @@ import {
createEventMetaCreator
} from '../../redux';
export const epics = [
selectChallengeEpic
];
export const epics = [];
export const types = createTypes([
'onRouteMap',
'initMap',
'toggleThisPanel',
@ -37,7 +37,7 @@ export const collapseAll = createAction(types.collapseAll);
export const expandAll = createAction(types.expandAll);
export const clickOnChallenge = createAction(
types.clickOnChallenge,
identity,
noop,
createEventMetaCreator({
category: capitalize(ns),
action: 'click',
@ -88,42 +88,38 @@ export function makePanelHiddenSelector(name) {
// }]
// }
// }
export default function createReducer() {
const reducer = handleActions(
{
[types.toggleThisPanel]: (state, { payload: name }) => {
return {
...state,
mapUi: utils.toggleThisPanel(state.mapUi, name)
};
},
[types.collapseAll]: state => {
const mapUi = utils.collapseAllPanels(state.mapUi);
mapUi.isAllCollapsed = true;
return {
...state,
mapUi
};
},
[types.expandAll]: state => {
const mapUi = utils.expandAllPanels(state.mapUi);
mapUi.isAllCollapsed = false;
return {
...state,
mapUi
};
},
[app.fetchChallenges.complete]: (state, { payload }) => {
const { entities, result } = payload;
return {
...state,
mapUi: utils.createMapUi(entities, result)
};
}
export default handleActions(
()=> ({
[types.toggleThisPanel]: (state, { payload: name }) => {
return {
...state,
mapUi: utils.toggleThisPanel(state.mapUi, name)
};
},
initialState
);
reducer.toString = () => ns;
return reducer;
}
[types.collapseAll]: state => {
const mapUi = utils.collapseAllPanels(state.mapUi);
mapUi.isAllCollapsed = true;
return {
...state,
mapUi
};
},
[types.expandAll]: state => {
const mapUi = utils.expandAllPanels(state.mapUi);
mapUi.isAllCollapsed = false;
return {
...state,
mapUi
};
},
[app.fetchChallenges.complete]: (state, { payload }) => {
const { entities, result } = payload;
return {
...state,
mapUi: utils.createMapUi(entities, result)
};
}
}),
initialState,
ns
);

View File

@ -1,10 +0,0 @@
import { ofType } from 'redux-epic';
import { types } from './';
import { updateCurrentChallenge } from '../../redux';
export default function selectChallengeEpic(actions) {
return actions::ofType(types.clickOnChallenge)
.pluck('payload')
.map(updateCurrentChallenge);
}

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { NavItem } from 'react-bootstrap';
const propTypes = {

View File

@ -1,10 +1,10 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import capitalize from 'lodash/capitalize';
import { createSelector } from 'reselect';
import { LinkContainer } from 'react-router-bootstrap';
import {
MenuItem,
Nav,
@ -14,6 +14,7 @@ import {
NavbarBrand
} from 'react-bootstrap';
import { Link } from '../Router';
import navLinks from './links.json';
import SignUp from './Sign-Up.jsx';
import BinButton from './Bin-Button.jsx';
@ -28,35 +29,36 @@ import {
} from './redux';
import {
userSelector,
isSignedInSelector,
signInLoadingSelector
} from '../redux';
import { nameToTypeSelector, panesSelector } from '../Panes/redux';
import { panesSelector } from '../Panes/redux';
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
const mapStateToProps = createSelector(
userSelector,
isSignedInSelector,
dropdownSelector,
signInLoadingSelector,
panesSelector,
nameToTypeSelector,
(
{ username, picture, points },
isSignedIn,
isDropdownOpen,
showLoading,
panes,
nameToType
) => {
return {
panes: panes.map(name => {
panes: panes.map(({ name, type }) => {
return {
content: name,
action: nameToType[name]
action: type
};
}, {}),
isDropdownOpen,
isSignedIn: !!username,
isSignedIn,
picture,
points,
showLoading,
@ -104,25 +106,19 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
};
}
const propTypes = navLinks.reduce(
(pt, { content }) => {
const handler = `handle${capitalize(content)}Click`;
pt[handler] = PropTypes.func.isRequired;
return pt;
},
{
panes: PropTypes.array,
clickOnLogo: PropTypes.func.isRequired,
closeDropdown: PropTypes.func.isRequired,
isDropdownOpen: PropTypes.bool,
openDropdown: PropTypes.func.isRequired,
picture: PropTypes.string,
points: PropTypes.number,
showLoading: PropTypes.bool,
signedIn: PropTypes.bool,
username: PropTypes.string
}
);
const propTypes = {
clickOnLogo: PropTypes.func.isRequired,
clickOnMap: PropTypes.func.isRequired,
closeDropdown: PropTypes.func.isRequired,
isDropdownOpen: PropTypes.bool,
openDropdown: PropTypes.func.isRequired,
panes: PropTypes.array,
picture: PropTypes.string,
points: PropTypes.number,
showLoading: PropTypes.bool,
signedIn: PropTypes.bool,
username: PropTypes.string
};
export class FCCNav extends React.Component {
renderLink(isNavItem, { isReact, isDropdown, content, link, links, target }) {
@ -154,7 +150,7 @@ export class FCCNav extends React.Component {
}
if (isReact) {
return (
<LinkContainer
<Link
key={ content }
onClick={ this.props[`handle${content}Click`] }
to={ link }
@ -164,7 +160,7 @@ export class FCCNav extends React.Component {
>
{ content }
</Component>
</LinkContainer>
</Link>
);
}
return (

View File

@ -1,7 +1,10 @@
import React, { PropTypes } from 'react';
import { Link } from 'react-router';
import React from 'react';
import PropTypes from 'prop-types';
import { NavItem } from 'react-bootstrap';
import { Link } from '../Router';
import { onRouteSettings } from '../routes/Settings/redux';
// this is separated out to prevent react bootstrap's
// NavBar from injecting unknown props to the li component
@ -36,7 +39,7 @@ export default function SignUpButton({
className='nav-avatar'
key='user'
>
<Link to='/settings'>
<Link to={ onRouteSettings() }>
<span className='nav-username hidden-sm'> { username } </span>
<span className='nav-points'> [ { points || 1 } ] </span>
<span className='nav-picture-container'>

View File

@ -1,7 +1,10 @@
import capitalize from 'lodash/capitalize';
import { createTypes } from 'redux-create-types';
import { createAction, handleActions } from 'redux-actions';
import noop from 'lodash/noop';
import {
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import loadCurrentChallengeEpic from './load-current-challenge-epic.js';
import binEpic from './bin-epic.js';
@ -62,8 +65,8 @@ const initialState = {
export const dropdownSelector = state => state[ns].isDropdownOpen;
export default function createReducer() {
const reducer = handleActions({
export default handleActions(
() => ({
[types.closeDropdown]: state => ({
...state,
isDropdownOpen: false
@ -72,8 +75,7 @@ export default function createReducer() {
...state,
isDropdownOpen: true
})
}, initialState);
reducer.toString = () => ns;
return reducer;
}
}),
initialState,
ns
);

View File

@ -2,12 +2,11 @@ import { ofType } from 'redux-epic';
import { types } from './';
import {
updateCurrentChallenge,
userSelector,
firstChallengeSelector,
challengeSelector
} from '../../redux';
import { onRouteChallenges } from '../../routes/Challenges/redux';
import { entitiesSelector } from '../../entities';
export default function loadCurrentChallengeEpic(actions, { getState }) {
@ -55,7 +54,5 @@ export default function loadCurrentChallengeEpic(actions, { getState }) {
// don't reload if the challenge is already loaded.
// This may change to toast to avoid user confusion
))
.map(({ finalChallenge }) => {
return updateCurrentChallenge(finalChallenge.dashedName);
});
.map(({ finalChallenge }) => onRouteChallenges(finalChallenge));
}

View File

@ -1,23 +1,15 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { hardGoTo } from '../redux';
import React from 'react';
const propTypes = {
hardGoTo: PropTypes.func,
location: PropTypes.object
};
// import PropTypes from 'prop-types';
export class NotFound extends React.Component {
componentWillMount() {
this.props.hardGoTo(this.props.location.pathname);
}
const propTypes = {};
render() {
return <span />;
}
export default function NotFound() {
return (
<div>404 Not Found</div>
);
}
NotFound.displayName = 'NotFound';
NotFound.propTypes = propTypes;
export default connect(null, { hardGoTo })(NotFound);

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { dividerClicked } from './redux';

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
const mapStateToProps = null;

View File

@ -1,48 +1,24 @@
import React, { PureComponent, PropTypes } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import Panes from './Panes.jsx';
import {
panesMounted,
panesUpdated,
panesWillMount,
panesWillUnmount
} from './redux';
import { panesMounted } from './redux';
const mapStateToProps = null;
const mapDispatchToProps = {
panesMounted,
panesUpdated,
panesWillMount,
panesWillUnmount
panesMounted
};
const propTypes = {
nameToComponent: PropTypes.object.isRequired,
panesMounted: PropTypes.func.isRequired,
panesUpdated: PropTypes.func.isRequired,
panesWillMount: PropTypes.func.isRequired,
panesWillUnmount: PropTypes.func.isRequired
panesMounted: PropTypes.func.isRequired
};
export class PanesContainer extends PureComponent {
componentWillMount() {
this.props.panesWillMount(Object.keys(this.props.nameToComponent));
}
componentDidMount() {
this.props.panesMounted();
}
componentWillUnmount() {
this.props.panesWillUnmount();
}
componentWillReceiveProps(nextProps) {
if (nextProps.nameToComponent !== this.props.nameToComponent) {
this.props.panesUpdated(Object.keys(nextProps.nameToComponent));
}
}
render() {
return (
<Panes { ...this.props } />

View File

@ -1,4 +1,5 @@
import React, { PureComponent, PropTypes } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
@ -20,7 +21,7 @@ const mapStateToProps = createSelector(
let lastDividerPosition = 0;
return {
panes: panes
.map(name => panesByName[name])
.map(({ name }) => panesByName[name])
.filter(({ isHidden })=> !isHidden)
.map((pane, index, { length: numOfPanes }) => {
const dividerLeft = pane.dividerLeft || 0;

View File

@ -1,11 +1,18 @@
import { combineActions, createAction, handleActions } from 'redux-actions';
import { createTypes } from 'redux-create-types';
import clamp from 'lodash/clamp';
import { isLocationAction } from 'redux-first-router';
import _ from 'lodash';
import {
composeReducers,
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import ns from '../ns.json';
import windowEpic from './window-epic.js';
import dividerEpic from './divider-epic.js';
import { challengeMetaSelector } from '../../routes/Challenges/redux';
import { types as app } from '../../redux';
const isDev = process.env.NODE_ENV !== 'production';
export const epics = [
@ -14,6 +21,7 @@ export const epics = [
];
export const types = createTypes([
'panesUpdatedThroughFetch',
'panesMounted',
'panesUpdated',
'panesWillMount',
@ -30,6 +38,11 @@ export const types = createTypes([
'hidePane'
], ns);
export const panesUpdatedThroughFetch = createAction(
types.panesUpdatedThroughFetch,
null,
panesView => ({ panesView })
);
export const panesMounted = createAction(types.panesMounted);
export const panesUpdated = createAction(types.panesUpdated);
export const panesWillMount = createAction(types.panesWillMount);
@ -78,9 +91,45 @@ function getDividerLeft(numOfPanes, index) {
return dividerLeft;
}
export default function createPanesAspects(typeToName) {
function forEachConfig(config, cb) {
return _.forEach(config, (val, key) => {
// val is a sub config
if (_.isObject(val) && !val.name) {
return forEachConfig(val, cb);
}
return cb(config, key);
});
}
function reduceConfig(config, cb, acc = {}) {
return _.reduce(config, (acc, val, key) => {
if (_.isObject(val) && !val.name) {
return reduceConfig(val, cb, acc);
}
return cb(acc, val, key);
}, acc);
}
const getPaneName = (panes, index) => (panes[index] || {}).name || '';
export const createPaneMap = (ns, getPanesMap) => {
const panesMap = _.reduce(getPanesMap(), (map, val, key) => {
let paneConfig = val;
if (typeof val === 'string') {
paneConfig = {
name: val
};
}
map[key] = paneConfig;
return map;
}, {});
return Object.defineProperty(panesMap, 'toString', { value: () => ns });
};
export default function createPanesAspects(config) {
if (isDev) {
Object.keys(typeToName).forEach(actionType => {
forEachConfig(config, (typeToName, actionType) => {
if (actionType === 'undefined') {
throw new Error(
`action type for ${typeToName[actionType]} is undefined`
@ -88,18 +137,24 @@ export default function createPanesAspects(typeToName) {
}
});
}
const nameToType = Object.keys(typeToName).reduce((map, type) => {
map[typeToName[type]] = type;
return map;
}, {});
function getInitialState() {
return {
...initialState,
nameToType
};
}
const typeToName = reduceConfig(config, (acc, val, type) => {
const name = _.isObject(val) ? val.name : val;
acc[type] = name;
return acc;
});
function middleware() {
function middleware({ getState }) {
const filterPanes = panesMap => _.reduce(panesMap, (panes, pane, type) => {
if (typeof pane.filter !== 'function' || pane.filter(getState())) {
panes[type] = pane;
}
return panes;
}, {});
// we cache the previous map so that we can attach it to the fetchChallenge
let previousMap;
// show panes on challenge route
// select panes map on viewType (this is state dependent)
// filter panes out on state
return next => action => {
let finalAction = action;
if (isPanesAction(action, typeToName)) {
@ -107,130 +162,150 @@ export default function createPanesAspects(typeToName) {
...action,
meta: {
...action.meta,
isPaneAction: true
isPaneAction: true,
paneName: typeToName[action.type]
}
};
}
return next(finalAction);
const result = next(finalAction);
if (isLocationAction(action)) {
// location matches a panes route
if (config[action.type]) {
const paneMap = previousMap = config[action.type];
const meta = challengeMetaSelector(getState());
const viewMap = paneMap[meta.viewType] || {};
next(panesUpdatedThroughFetch(filterPanes(viewMap)));
} else {
next(panesUpdatedThroughFetch({}));
}
}
if (action.type === app.fetchChallenge.complete) {
const meta = challengeMetaSelector(getState());
const viewMap = previousMap[meta.viewType] || {};
next(panesUpdatedThroughFetch(filterPanes(viewMap)));
}
return result;
};
}
const reducer = handleActions({
[types.dividerClicked]: (state, { payload: name }) => ({
...state,
pressedDivider: name
}),
[types.dividerMoved]: (state, { payload: clientX }) => {
const { width, pressedDivider: paneName } = state;
const dividerBuffer = (200 / width) * 100;
const paneIndex = state.panes.indexOf(paneName);
const currentPane = state.panesByName[paneName];
const rightPane = state.panesByName[state.panes[paneIndex + 1]] || {};
const leftPane = state.panesByName[state.panes[paneIndex - 1]] || {};
const rightBound = (rightPane.dividerLeft || 100) - dividerBuffer;
const leftBound = (leftPane.dividerLeft || 0) + dividerBuffer;
const newPosition = clamp(
(clientX / width) * 100,
leftBound,
rightBound
);
return {
...state,
panesByName: {
...state.panesByName,
[currentPane.name]: {
...currentPane,
dividerLeft: newPosition
}
}
};
},
[types.mouseReleased]: state => ({ ...state, pressedDivider: null }),
[types.windowResized]: (state, { payload: { height, width } }) => ({
...state,
height,
width
}),
// used to clear bin buttons
[types.panesWillUnmount]: state => ({
...state,
panes: [],
panesByName: {},
pressedDivider: null
}),
[
combineActions(
panesWillMount,
panesUpdated
)
]: (state, { payload: panes }) => {
const numOfPanes = panes.length;
return {
...state,
panes,
panesByName: panes.reduce((panes, name, index) => {
const dividerLeft = getDividerLeft(numOfPanes, index);
panes[name] = {
name,
dividerLeft,
isHidden: false
};
return panes;
}, {})
};
},
[types.updateNavHeight]: (state, { payload: navHeight }) => ({
...state,
navHeight
})
}, getInitialState());
function metaReducer(state = getInitialState(), action) {
if (action.meta && action.meta.isPaneAction) {
const name = typeToName[action.type];
const oldPane = state.panesByName[name];
const pane = {
...oldPane,
isHidden: !oldPane.isHidden
};
const panesByName = {
...state.panesByName,
[name]: pane
};
const numOfPanes = state.panes.reduce((sum, name) => {
return panesByName[name].isHidden ? sum : sum + 1;
}, 0);
let numOfHidden = 0;
return {
...state,
panesByName: state.panes.reduce(
(panesByName, name, index) => {
if (!panesByName[name].isHidden) {
const dividerLeft = getDividerLeft(
numOfPanes,
index - numOfHidden
);
panesByName[name] = {
...panesByName[name],
dividerLeft
};
} else {
numOfHidden = numOfHidden + 1;
const reducer = composeReducers(
ns,
handleActions(
() => ({
[types.dividerClicked]: (state, { payload: name }) => ({
...state,
pressedDivider: name
}),
[types.dividerMoved]: (state, { payload: clientX }) => {
const { width, pressedDivider: paneName } = state;
const dividerBuffer = (200 / width) * 100;
const paneIndex =
_.findIndex(state.panes, ({ name }) => paneName === name);
const currentPane = state.panesByName[paneName];
const rightPane =
state.panesByName[getPaneName(state.panes, paneIndex + 1)] || {};
const leftPane =
state.panesByName[getPaneName(state.panes, paneIndex - 1)] || {};
const rightBound = (rightPane.dividerLeft || 100) - dividerBuffer;
const leftBound = (leftPane.dividerLeft || 0) + dividerBuffer;
const newPosition = _.clamp(
(clientX / width) * 100,
leftBound,
rightBound
);
return {
...state,
panesByName: {
...state.panesByName,
[currentPane.name]: {
...currentPane,
dividerLeft: newPosition
}
}
return panesByName;
},
panesByName
)
};
};
},
[types.mouseReleased]: state => ({ ...state, pressedDivider: null }),
[types.windowResized]: (state, { payload: { height, width } }) => ({
...state,
height,
width
}),
// used to clear bin buttons
[types.panesWillUnmount]: state => ({
...state,
panes: [],
panesByName: {},
pressedDivider: null
}),
[types.updateNavHeight]: (state, { payload: navHeight }) => ({
...state,
navHeight
})
}),
initialState,
),
function metaReducer(state = initialState, action) {
if (action.meta && action.meta.panesView) {
const panesView = action.meta.panesView;
const panes = _.map(panesView, ({ name }, type) => ({ name, type }));
const numOfPanes = Object.keys(panes).length;
return {
...state,
panes,
panesByName: panes.reduce((panes, { name }, index) => {
const dividerLeft = getDividerLeft(numOfPanes, index);
panes[name] = {
name,
dividerLeft,
isHidden: false
};
return panes;
}, {})
};
}
if (action.meta && action.meta.isPaneAction) {
const name = action.meta.paneName;
const oldPane = state.panesByName[name];
const pane = {
...oldPane,
isHidden: !oldPane.isHidden
};
const panesByName = {
...state.panesByName,
[name]: pane
};
const numOfPanes = state.panes.reduce((sum, { name }) => {
return panesByName[name].isHidden ? sum : sum + 1;
}, 0);
let numOfHidden = 0;
return {
...state,
panesByName: state.panes.reduce(
(panesByName, { name }, index) => {
if (!panesByName[name].isHidden) {
const dividerLeft = getDividerLeft(
numOfPanes,
index - numOfHidden
);
panesByName[name] = {
...panesByName[name],
dividerLeft
};
} else {
numOfHidden = numOfHidden + 1;
}
return panesByName;
},
panesByName
)
};
}
return state;
}
return state;
}
);
function finalReducer(state, action) {
return reducer(metaReducer(state, action), action);
}
finalReducer.toString = () => ns;
return {
reducer: finalReducer,
reducer,
middleware
};
}

View File

@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { selectLocationState } from 'redux-first-router';
import toUrl from './to-url.js';
import createHandler from './handle-press.js';
import { langSelector } from './redux';
const propTypes = {
children: PropTypes.node,
dispatch: PropTypes.func,
onClick: PropTypes.func,
redirect: PropTypes.bool,
replace: PropTypes.bool,
shouldDispatch: PropTypes.bool,
target: PropTypes.string,
to: PropTypes.oneOfType([ PropTypes.object, PropTypes.string ]).isRequired
};
export const Link = (
{
children,
dispatch,
onClick,
redirect,
replace,
shouldDispatch = true,
target,
to
},
{ store }
) => {
const state = store.getState();
const lang = langSelector(state);
const location = selectLocationState(state);
const { routesMap } = location;
const url = toUrl(to, routesMap, lang);
const handler = createHandler(
url,
routesMap,
onClick,
shouldDispatch,
target,
dispatch,
to,
replace || redirect
);
const localProps = {};
if (url) {
localProps.href = url;
}
if (handler) {
localProps.onMouseDown = handler;
localProps.onTouchStart = handler;
}
if (target) {
localProps.target = target;
}
return (
<a
onClick={ handler }
{ ...localProps }
>
{children}
</a>
);
};
Link.contextTypes = {
store: PropTypes.object.isRequired
};
Link.propTypes = propTypes;
export default connect()(Link);

View File

@ -0,0 +1,50 @@
import { pathToAction, redirect, getOptions } from 'redux-first-router';
const isAction = to => typeof to === 'object' &&
!Array.isArray(to);
const isModified = e => !!(
e.metaKey ||
e.altKey ||
e.ctrlKey ||
e.shiftKey
);
export default (
url,
routesMap,
onClick,
shouldDispatch,
target,
dispatch,
to,
dispatchRedirect
) => e => {
let shouldGo = true;
if (onClick) {
// onClick can return false to prevent dispatch
shouldGo = onClick(e);
shouldGo = typeof shouldGo === 'undefined' ? true : shouldGo;
}
const prevented = e.defaultPrevented;
if (!target && e && e.preventDefault && !isModified(e)) {
e.preventDefault();
}
if (
shouldGo &&
shouldDispatch &&
!target &&
!prevented &&
e.button === 0 &&
!isModified(e)
) {
const { querySerializer: serializer } = getOptions();
let action = isAction(to) ? to : pathToAction(url, routesMap, serializer);
action = dispatchRedirect ? redirect(action) : action;
dispatch(action);
}
};

View File

@ -0,0 +1 @@
export { default as Link } from './Link.jsx';

View File

@ -0,0 +1,28 @@
import { langSelector } from './';
// This enhancers sole purpose is to add the lang prop to route actions so that
// they do not need to be explicitally added when using a RFR route action.
export default function addLangToRouteEnhancer(routesMap) {
return createStore => (...args) => {
const store = createStore(...args);
return {
...store,
dispatch(action) {
if (
routesMap[action.type] &&
(action && action.payload && !action.payload.lang)
) {
action = {
...action,
payload: {
...action.payload,
lang: langSelector(store.getState()) || 'en'
}
};
}
return store.dispatch(action);
}
};
};
}

View File

@ -0,0 +1,5 @@
import { selectLocationState } from 'redux-first-router';
export const paramsSelector = state => selectLocationState(state).payload || {};
export const langSelector = state => paramsSelector(state).lang || 'en';
export const routesMapSelector = state => paramsSelector(state).routesMap || {};

View File

@ -0,0 +1,45 @@
import { actionToPath, getOptions } from 'redux-first-router';
import { addLang } from '../utils/lang.js';
export default (to, routesMap, lang = 'en') => {
if (to && typeof to === 'string') {
return addLang(to, lang);
}
if (typeof to === 'object') {
const { payload = {}, ...action } = to;
try {
const { querySerializer } = getOptions();
return actionToPath(
{
...action,
payload: {
...payload,
lang: payload.lang || lang
}
},
routesMap,
querySerializer
);
} catch (e) {
console.error(e);
console.warn(
'[Link] could not create path from action:',
action,
'For reference, here are your current routes:',
routesMap
);
return '#';
}
}
console.warn(
'[Link] `to` prop must be a string or action object. You provided: ',
to
);
return '#';
};

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { NotificationStack } from 'react-notification';
@ -6,12 +7,12 @@ import { NotificationStack } from 'react-notification';
import { removeToast } from './redux';
import {
submitChallenge,
resetChallenge
} from '../routes/challenges/redux';
clickOnReset
} from '../routes/Challenges/redux';
const registeredActions = {
submitChallenge,
resetChallenge
clickOnReset
};
const mapStateToProps = state => ({ toasts: state.toasts });
// we use styles here to overwrite those built into the library

View File

@ -1,12 +1,15 @@
import { createTypes } from 'redux-create-types';
import { createAction, handleActions } from 'redux-actions';
import {
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import ns from '../ns.json';
export const types = createTypes([
'makeToast',
'removeToast'
], 'toast');
], ns);
let key = 0;
export const makeToast = createAction(
@ -29,8 +32,8 @@ export const removeToast = createAction(
const initialState = [];
export default function createReducer() {
const reducer = handleActions({
export default handleActions(
() => ({
[types.makeToast]: (state, { payload: toast }) => [
...state,
toast
@ -38,8 +41,7 @@ export default function createReducer() {
[types.removeToast]: (state, { payload: key }) => state.filter(
toast => toast.key !== key
)
}, initialState);
reducer.toString = () => ns;
return reducer;
}
}),
initialState,
ns
);

View File

@ -7,7 +7,7 @@
// Here we invert the order in which
// they are painted using css so the
// nav is on top again
.grid(@direction: column-reverse);
.grid(@direction: column);
}
.@{ns}-content {

View File

@ -1,42 +1,35 @@
import { Observable } from 'rx';
import { match } from 'react-router';
import { compose, createStore, applyMiddleware } from 'redux';
import { selectLocationState, connectRoutes } from 'redux-first-router';
import { combineReducers } from 'berkeleys-redux-utils';
import { createEpic } from 'redux-epic';
import createReducer from './create-reducer';
import createRoutes from './create-routes.js';
import appReducer from './reducer.js';
import routesMap from './routes-map.js';
import createPanesMap from './create-panes-map.js';
import createPanesAspects from './Panes/redux';
import addLangToRoutesEnhancer from './Router/redux/add-lang-enhancer.js';
import epics from './epics';
import { onBeforeChange } from './utils/redux-first-router.js';
import servicesCreator from '../utils/services-creator';
const createRouteProps = Observable.fromNodeCallback(match);
//
// createApp(settings: {
// location?: Location|String,
// history?: History,
// syncHistoryWithStore?: ((history, store) => history) = (x) => x,
// initialState?: Object|Void,
// defaultState?: Object|Void,
// serviceOptions?: Object,
// middlewares?: Function[],
// sideReducers?: Object
// enhancers?: Function[],
// epics?: Function[],
// }) => Observable
//
// Either location or history must be defined
export default function createApp({
location,
history,
syncHistoryWithStore = (x) => x,
syncOptions = {},
initialState,
defaultState,
serviceOptions = {},
middlewares: sideMiddlewares = [],
enhancers: sideEnhancers = [],
reducers: sideReducers = {},
epics: sideEpics = [],
epicOptions: sideEpicOptions = {}
}) {
@ -50,12 +43,25 @@ export default function createApp({
...epics,
...sideEpics
);
const {
reducer: routesReducer,
middleware: routesMiddleware,
enhancer: routesEnhancer
} = connectRoutes(history, routesMap, { onBeforeChange });
routesReducer.toString = () => 'location';
const {
reducer: panesReducer,
middleware: panesMiddleware
} = createPanesAspects(createPanesMap());
const enhancer = compose(
addLangToRoutesEnhancer(routesMap),
routesEnhancer,
applyMiddleware(
routesMiddleware,
panesMiddleware,
epicMiddleware,
...sideMiddlewares
@ -64,33 +70,31 @@ export default function createApp({
// on client side these are things like Redux DevTools
...sideEnhancers
);
const reducer = createReducer(
{
[panesReducer]: panesReducer,
...sideReducers
},
const reducer = combineReducers(
appReducer,
panesReducer,
routesReducer
);
// create composed store enhancer
// use store enhancer function to enhance `createStore` function
// call enhanced createStore function with reducer and initialState
// call enhanced createStore function with reducer and defaultState
// to create store
const store = createStore(reducer, initialState, enhancer);
// sync history client side with store.
// server side this is an identity function and history is undefined
history = syncHistoryWithStore(history, store, syncOptions);
const routes = createRoutes(store);
// createRouteProps({
// redirect: LocationDescriptor,
// history: History,
// routes: Object
// }) => Observable
return createRouteProps({ routes, location, history })
.map(([ redirect, props ]) => ({
redirect,
props,
reducer,
store,
epic: epicMiddleware
}));
const store = createStore(reducer, defaultState, enhancer);
const location = selectLocationState(store.getState());
// ({
// redirect,
// props,
// reducer,
// store,
// epic: epicMiddleware
// }));
return Observable.of({
store,
epic: epicMiddleware,
location,
notFound: false
});
}

View File

@ -1,42 +0,0 @@
import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import app from './redux';
import entities from './entities';
import map from './Map/redux';
import nav from './Nav/redux';
import routes from './routes/redux';
import toasts from './Toasts/redux';
// not ideal but should go away once we move to react-redux-form
import { projectNormalizer } from './routes/challenges/redux';
export default function createReducer(sideReducers) {
// reducers exported from features need to be factories
// this helps avoid cyclic requires messing up reducer creation
// We end up with exports from files being undefined as node tries
// to resolve cyclic dependencies.
// This prevents that by wrapping the `handleActions` call so that the ref
// to types imported from parent features are closures and can be resolved
// by node before we need them.
const reducerMap = [
app,
entities,
map,
nav,
routes,
toasts
]
.map(createReducer => createReducer())
.reduce((arr, cur) => arr.concat(cur), [])
.reduce(
(reducerMap, reducer) => {
reducerMap[reducer] = reducer;
return reducerMap;
},
{
form: formReducer.normalize({ ...projectNormalizer }),
...sideReducers
}
);
return combineReducers(reducerMap);
}

View File

@ -1,9 +0,0 @@
import App from './App.jsx';
import createChildRoute from './routes';
export default function createRoutes(store) {
return {
components: App,
...createChildRoute(store)
};
}

View File

@ -1,23 +1,22 @@
import { createTypes } from 'redux-create-types';
import { createAction, handleActions } from 'redux-actions';
import {
composeReducers,
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import { types as app } from '../routes/Challenges/redux';
export const ns = 'entities';
export const getNS = state => state[ns];
export const entitiesSelector = getNS;
export const types = createTypes([
'updateUserPoints',
'updateUserFlag',
'updateUserEmail',
'updateUserLang',
'updateUserChallenge',
'updateUserCurrentChallenge'
], ns);
// updateUserPoints(username: String, points: Number) => Action
export const updateUserPoints = createAction(
types.updateUserPoints,
(username, points) => ({ username, points })
);
// updateUserFlag(username: String, flag: String) => Action
export const updateUserFlag = createAction(
types.updateUserFlag,
@ -34,21 +33,12 @@ export const updateUserLang = createAction(
(username, lang) => ({ username, languageTag: lang })
);
// updateUserChallenge(
// username: String,
// challengeInfo: Object
// ) => Action
export const updateUserChallenge = createAction(
types.updateUserChallenge,
(username, challengeInfo) => ({ username, challengeInfo })
);
export const updateUserCurrentChallenge = createAction(
types.updateUserCurrentChallenge
);
const initialState = {
const defaultState = {
superBlock: {},
block: {},
challenge: {},
@ -69,14 +59,33 @@ export function makeSuperBlockSelector(name) {
};
}
export default function createReducer() {
const userReducer = handleActions(
{
[types.updateUserPoints]: (state, { payload: { username, points } }) => ({
export const isChallengeLoaded = (state, { dashedName }) =>
!!challengeMapSelector(state)[dashedName];
export default composeReducers(
ns,
function metaReducer(state = defaultState, action) {
if (action.meta && action.meta.entities) {
return {
...state,
...action.meta.entities
};
}
return state;
},
handleActions(
() => ({
[
app.submitChallenge.complete
]: (state, { payload: { username, points, challengeInfo } }) => ({
...state,
[username]: {
...state[username],
points
points,
challengeMap: {
...state[username].challengeMap,
[challengeInfo.id]: challengeInfo
}
}
}),
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
@ -118,46 +127,8 @@ export default function createReducer() {
...state[username],
currentChallengeId
}
}),
[types.updateUserChallenge]:
(
state,
{
payload: { username, challengeInfo }
}
) => ({
...state,
[username]: {
...state[username],
challengeMap: {
...state[username].challengeMap,
[challengeInfo.id]: challengeInfo
}
}
})
},
initialState.user
);
function metaReducer(state = initialState, action) {
if (action.meta && action.meta.entities) {
return {
...state,
...action.meta.entities
};
}
return state;
}
function entitiesReducer(state, action) {
const newState = metaReducer(state, action);
const user = userReducer(newState.user, action);
if (newState.user !== user) {
return { ...newState, user };
}
return newState;
}
entitiesReducer.toString = () => ns;
return entitiesReducer;
}
}),
defaultState
)
);

View File

@ -1,6 +1,6 @@
import { epics as app } from './redux';
import { epics as challenge } from './routes/challenges/redux';
import { epics as settings } from './routes/settings/redux';
import { epics as challenge } from './routes/Challenges/redux';
import { epics as settings } from './routes/Settings/redux';
import { epics as nav } from './Nav/redux';
import { epics as map } from './Map/redux';
import { epics as panes } from './Panes/redux';

97
common/app/files/index.js Normal file
View File

@ -0,0 +1,97 @@
import {
combineActions,
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import { bonfire, html, js } from '../utils/challengeTypes.js';
import { createPoly, setContent } from '../../utils/polyvinyl.js';
import { arrayToString, buildSeed, getPreFile } from '../utils/classic-file.js';
import { types as app } from '../redux';
import { types as challenges } from '../routes/Challenges/redux';
const ns = 'files';
export const types = createTypes([
'updateFile',
'updateFiles',
'savedCodeFound'
], ns);
export const updateFile = createAction(types.updateFile);
export const updateFiles = createAction(types.updateFiles);
export const savedCodeFound = createAction(
types.savedCodeFound,
(files, challenge) => ({ files, challenge })
);
export const filesSelector = state => state[ns];
export default handleActions(
() => ({
[types.updateFile]: (state, { payload: { key, content }}) => ({
...state,
[key]: setContent(content, state[key])
}),
[types.updateFiles]: (state, { payload: files }) => {
return files
.reduce((files, file) => {
files[file.key] = file;
return files;
}, { ...state });
},
[types.savedCodeFound]: (state, { payload: { files, challenge } }) => {
if (challenge.type === 'mod') {
// this may need to change to update head/tail
return challenge.files;
}
if (
challenge.challengeType !== html &&
challenge.challengeType !== js &&
challenge.challengeType !== bonfire
) {
return {};
}
// classic challenge to modern format
const preFile = getPreFile(challenge);
return {
[preFile.key]: createPoly({
...files[preFile.key],
// make sure head/tail are always fresh
head: arrayToString(challenge.head),
tail: arrayToString(challenge.tail)
})
};
},
[
combineActions(
challenges.challengeUpdated,
app.fetchChallenge.complete
)
]: (state, { payload: { challenge } }) => {
if (challenge.type === 'mod') {
return challenge.files;
}
if (
challenge.challengeType !== html &&
challenge.challengeType !== js &&
challenge.challengeType !== bonfire
) {
return {};
}
// classic challenge to modern format
const preFile = getPreFile(challenge);
return {
[preFile.key]: createPoly({
...preFile,
contents: buildSeed(challenge),
head: arrayToString(challenge.head),
tail: arrayToString(challenge.tail)
})
};
}
}),
{},
ns
);

View File

@ -1 +1,3 @@
export default from './create-app.jsx';
export { default as createApp } from './create-app.jsx';
export { default as App } from './App.jsx';
export { default as provideStore } from './provide-store.js';

View File

@ -1,11 +1,11 @@
/* eslint-disable react/display-name */
import React from 'react';
import { createElement } from 'react';
import { Provider } from 'react-redux';
export default function provideStore(element, store) {
return React.createElement(
export default function provideStore(Component, store) {
return createElement(
Provider,
{ store },
element
createElement(Component)
);
}

26
common/app/reducer.js Normal file
View File

@ -0,0 +1,26 @@
import { combineReducers } from 'berkeleys-redux-utils';
import { reducer as formReducer } from 'redux-form';
import app from './redux';
import entities from './entities';
import map from './Map/redux';
import nav from './Nav/redux';
import routes from './routes/redux';
import toasts from './Toasts/redux';
import files from './files';
// not ideal but should go away once we move to react-redux-form
import { projectNormalizer } from './routes/Challenges/redux';
const _formReducer = formReducer.normalize({ ...projectNormalizer });
_formReducer.toString = () => 'form';
export default combineReducers(
app,
entities,
map,
nav,
routes,
toasts,
files,
_formReducer
);

View File

@ -9,21 +9,23 @@ import {
delayedRedirect,
fetchChallengeCompleted,
fetchChallengesCompleted,
langSelector
fetchChallengesCompleted
} from './';
import { isChallengeLoaded } from '../entities/index.js';
import { shapeChallenges } from './utils';
import { types as challenge } from '../routes/Challenges/redux';
import { langSelector } from '../Router/redux';
const isDev = debug.enabled('fcc:*');
export function fetchChallengeEpic(actions, { getState }, { services }) {
return actions::ofType('' + types.fetchChallenge)
.flatMap(({ payload: { dashedName, block } }) => {
const lang = langSelector(getState());
return actions::ofType(challenge.onRouteChallenges)
.filter(({ payload }) => !isChallengeLoaded(getState(), payload))
.flatMapLatest(({ payload: params }) => {
const options = {
service: 'map',
params: { block, dashedName, lang }
params
};
return services.readService$(options)
.retry(3)
@ -52,11 +54,7 @@ export function fetchChallengesEpic(
{ getState },
{ services }
) {
return actions::ofType(
// async type
'' + types.fetchChallenges,
types.appMounted
)
return actions::ofType(types.appMounted)
.flatMapLatest(() => {
const lang = langSelector(getState());
const options = {

View File

@ -11,7 +11,7 @@ import {
addThemeToBody
} from './';
export default function getUserEpic(actions, { getState }, { services }) {
export default function getUserEpic(actions, _, { services }) {
return actions::ofType(types.fetchUser)
.flatMap(() => {
return services.readService$({ service: 'user' })

View File

@ -1,6 +1,11 @@
import { Observable } from 'rx';
import { createTypes, createAsyncTypes } from 'redux-create-types';
import { combineActions, createAction, handleActions } from 'redux-actions';
import {
combineActions,
createAction,
createAsyncTypes,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import { createSelector } from 'reselect';
import noop from 'lodash/noop';
import identity from 'lodash/identity';
@ -10,6 +15,7 @@ import fetchUserEpic from './fetch-user-epic.js';
import updateMyCurrentChallengeEpic from './update-my-challenge-epic.js';
import fetchChallengesEpic from './fetch-challenges-epic.js';
import navSizeEpic from './nav-size-epic.js';
import { types as challenges } from '../routes/Challenges/redux';
import ns from '../ns.json';
@ -21,14 +27,14 @@ export const epics = [
];
export const types = createTypes([
'onRouteHome',
'appMounted',
'analytics',
'updateTitle',
'updateAppLang',
createAsyncTypes('fetchChallenge'),
createAsyncTypes('fetchChallenges'),
'updateCurrentChallenge',
'fetchUser',
'addUser',
@ -80,6 +86,7 @@ export const createEventMetaCreator = ({
}
});
export const onRouteHome = createAction(types.onRouteHome);
export const appMounted = createAction(types.appMounted);
export const fetchChallenge = createAction(
'' + types.fetchChallenge,
@ -96,9 +103,6 @@ export const fetchChallengesCompleted = createAction(
(entities, result) => ({ entities, result }),
entities => ({ entities })
);
export const updateCurrentChallenge = createAction(
types.updateCurrentChallenge
);
// updateTitle(title: String) => Action
export const updateTitle = createAction(types.updateTitle);
@ -118,8 +122,6 @@ export const addUser = createAction(
export const updateThisUser = createAction(types.updateThisUser);
export const showSignIn = createAction(types.showSignIn);
export const updateAppLang = createAction(types.updateAppLang);
// used when server needs client to redirect
export const delayedRedirect = createAction(types.delayedRedirect);
@ -156,7 +158,6 @@ const initialState = {
title: 'Learn To Code | freeCodeCamp',
isSignInAttempted: false,
user: '',
lang: '',
csrfToken: '',
theme: 'default',
// eventually this should be only in the user object
@ -165,7 +166,6 @@ const initialState = {
};
export const getNS = state => state[ns];
export const langSelector = state => getNS(state).lang;
export const csrfSelector = state => getNS(state).csrfToken;
export const themeSelector = state => getNS(state).theme;
export const titleSelector = state => getNS(state).title;
@ -180,6 +180,8 @@ export const userSelector = createSelector(
(username, userMap) => userMap[username] || {}
);
export const isSignedInSelector = state => !!userSelector(state).username;
export const challengeSelector = createSelector(
currentChallengeSelector,
state => entitiesSelector(state).challenge,
@ -222,58 +224,46 @@ export const firstChallengeSelector = createSelector(
}
);
export default function createReducer() {
const reducer = handleActions(
{
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
...state,
title: payload + ' | freeCodeCamp'
}),
export default handleActions(
() => ({
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
...state,
title: payload + ' | freeCodeCamp'
}),
[types.updateThisUser]: (state, { payload: user }) => ({
...state,
user
}),
[types.fetchChallenge.complete]: (state, { payload }) => ({
...state,
currentChallenge: payload.currentChallenge
}),
[combineActions(
types.fetchChallenge.complete,
types.fetchChallenges.complete
)]: (state, { payload }) => ({
...state,
superBlocks: payload.result.superBlocks
}),
[types.updateCurrentChallenge]: (state, { payload = '' }) => ({
...state,
currentChallenge: payload
}),
[types.updateAppLang]: (state, { payload = 'en' }) =>({
...state,
lang: payload
}),
[types.updateTheme]: (state, { payload = 'default' }) => ({
...state,
theme: payload
}),
[combineActions(types.showSignIn, types.updateThisUser)]: state => ({
...state,
isSignInAttempted: true
}),
[types.updateThisUser]: (state, { payload: user }) => ({
...state,
user
}),
[combineActions(
types.fetchChallenge.complete,
types.fetchChallenges.complete
)]: (state, { payload }) => ({
...state,
superBlocks: payload.result.superBlocks
}),
[challenges.onRouteChallenges]: (state, { payload: { dashedName } }) => ({
...state,
currentChallenge: dashedName
}),
[types.updateTheme]: (state, { payload = 'default' }) => ({
...state,
theme: payload
}),
[combineActions(types.showSignIn, types.updateThisUser)]: state => ({
...state,
isSignInAttempted: true
}),
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
...state,
points
}),
[types.delayedRedirect]: (state, { payload }) => ({
...state,
delayedRedirect: payload
})
},
initialState
);
reducer.toString = () => ns;
return reducer;
}
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
...state,
points
}),
[types.delayedRedirect]: (state, { payload }) => ({
...state,
delayedRedirect: payload
})
}),
initialState,
ns
);

View File

@ -12,12 +12,15 @@ import {
} from './';
import { updateUserCurrentChallenge } from '../entities';
import { postJSON$ } from '../../utils/ajax-stream';
import { types as challenges } from '../routes/Challenges/redux';
const log = debug('fcc:app:redux:up-my-challenge-epic');
export default function updateMyCurrentChallengeEpic(actions, { getState }) {
const updateChallenge = actions::ofType(types.updateCurrentChallenge)
const updateChallenge = actions::ofType(types.appMounted)
.flatMapLatest(() => actions::ofType(challenges.onRouteChallenges))
.map(() => {
const state = getState();
// username is never defined SSR
const { username } = userSelector(state);
const { id } = challengeSelector(state);
const csrf = csrfSelector(state);

View File

@ -10,7 +10,7 @@ export function filterComingSoonBetaChallenge(
}
export function filterComingSoonBetaFromEntities(
{ challenge: challengeMap, block: blockMap, ...rest },
{ challenge: challengeMap, block: blockMap = {}, ...rest },
isDev = false
) {
const filter = filterComingSoonBetaChallenge.bind(null, isDev);

19
common/app/routes-map.js Normal file
View File

@ -0,0 +1,19 @@
import reduce from 'lodash/reduce';
import { types } from './redux';
import routes from './routes';
const base = '/:lang';
export default {
...reduce(routes, (routes, route, type) => {
let newRoute;
if (typeof route === 'string') {
newRoute = base + route;
} else {
newRoute = { ...route, path: base + route.path };
}
routes[type] = newRoute;
return routes;
}, {}),
[types.routeOnHome]: base
};

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button, Modal } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component';

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { Col, Row } from 'react-bootstrap';
import ns from './ns.json';

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import ns from './ns.json';

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import PureComponent from 'react-pure-render/component';
import { Grid, Col, Row } from 'react-bootstrap';

View File

@ -1,5 +1,6 @@
import noop from 'lodash/noop';
import React, { PureComponent, PropTypes } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Button, Modal } from 'react-bootstrap';

View File

@ -1,4 +1,5 @@
import React, { PureComponent, PropTypes } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import NoSSR from 'react-no-ssr';
import Codemirror from 'react-codemirror';

View File

@ -0,0 +1,113 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { challengeMetaSelector } from './redux';
import CompletionModal from './Completion-Modal.jsx';
import Classic from './views/classic';
import Step from './views/step';
import Project from './views/project';
import BackEnd from './views/backend';
import Quiz from './views/quiz';
import {
fetchChallenge,
challengeSelector
} from '../../redux';
import { makeToast } from '../../Toasts/redux';
import { paramsSelector } from '../../Router/redux';
const views = {
backend: BackEnd,
classic: Classic,
project: Project,
simple: Project,
step: Step,
quiz: Quiz
};
const mapDispatchToProps = {
fetchChallenge,
makeToast
};
const mapStateToProps = createSelector(
challengeSelector,
challengeMetaSelector,
paramsSelector,
(
{ dashedName, isTranslated },
{ viewType },
params,
) => ({
challenge: dashedName,
isTranslated,
params,
viewType
})
);
const link = 'http://forum.freecodecamp.org/t/' +
'guidelines-for-translating-free-code-camp' +
'-to-any-language/19111';
const helpUsTranslate = <a href={ link } target='_blank'>Help Us</a>;
const propTypes = {
isTranslated: PropTypes.bool,
makeToast: PropTypes.func.isRequired,
params: PropTypes.shape({
block: PropTypes.string,
dashedName: PropTypes.string,
lang: PropTypes.string.isRequired
}),
viewType: PropTypes.string
};
export class Show extends PureComponent {
isNotTranslated({ isTranslated, params: { lang } }) {
return lang !== 'en' && !isTranslated;
}
makeTranslateToast() {
this.props.makeToast({
message: 'We haven\'t translated this challenge yet.',
action: helpUsTranslate,
timeout: 15000
});
}
componentDidMount() {
if (this.isNotTranslated(this.props)) {
this.makeTranslateToast();
}
}
componentWillReceiveProps(nextProps) {
const { params: { dashedName } } = nextProps;
if (
this.props.params.dashedName !== dashedName &&
this.isNotTranslated(nextProps)
) {
this.makeTranslateToast();
}
}
render() {
const { viewType } = this.props;
const View = views[viewType] || Classic;
return (
<div>
<View />
<CompletionModal />
</div>
);
}
}
Show.displayName = 'Show(ChallengeView)';
Show.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(Show);

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { HelpBlock, FormGroup, FormControl } from 'react-bootstrap';
import { getValidationState, DOMOnlyProps } from '../../utils/form';

View File

@ -1,4 +1,5 @@
import React, { PropTypes, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Col, Row } from 'react-bootstrap';

View File

@ -0,0 +1,33 @@
import { redirect } from 'redux-first-router';
import { types } from './redux';
import { panesMap as backendPanesMap } from './views/backend';
import { panesMap as classicPanesMap } from './views/classic';
import { panesMap as stepPanesMap } from './views/step';
import { panesMap as projectPanesMap } from './views/project';
import { panesMap as quizPanesMap } from './views/quiz';
export const routes = {
[types.onRouteChallengeRoot]: {
path: '/challenges',
thunk: (dispatch) =>
dispatch(redirect({ type: types.onRouteCurrentChallenge }))
},
[types.onRouteChallenges]: '/challenges/:block/:dashedName',
[types.onRouteCurrentChallenge]: '/challenges/current-challenge'
};
export function createPanesMap() {
return {
// the route to use this panes map on
[types.onRouteChallenges]: {
[backendPanesMap]: backendPanesMap,
[classicPanesMap]: classicPanesMap,
[stepPanesMap]: stepPanesMap,
[projectPanesMap]: projectPanesMap,
[quizPanesMap]: quizPanesMap
}
};
}
export { default } from './Show.jsx';

View File

@ -1,11 +1,10 @@
import { ofType } from 'redux-epic';
import {
types,
closeBugModal,
filesSelector
closeBugModal
} from '../redux';
import { filesSelector } from '../../../files';
import { currentChallengeSelector } from '../../../redux';
function filesToMarkdown(files = {}) {

View File

@ -1,13 +1,14 @@
import debug from 'debug';
import { Observable } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import { push } from 'react-router-redux';
import {
types,
updateMain,
challengeUpdated
challengeUpdated,
onRouteChallenges,
onRouteCurrentChallenge,
updateMain
} from './';
import { getNS as entitiesSelector } from '../../../entities';
import {
@ -16,40 +17,42 @@ import {
getFirstChallengeOfNextSuperBlock
} from '../utils';
import {
types as app,
createErrorObservable,
updateCurrentChallenge,
currentChallengeSelector,
challengeSelector,
superBlocksSelector
} from '../../../redux';
import { langSelector } from '../../../Router/redux';
import { makeToast } from '../../../Toasts/redux';
const isDev = debug.enabled('fcc:*');
// When we change challenge, update the current challenge
// UI data.
export function challengeUpdatedEpic(actions, { getState }) {
return actions::ofType(app.updateCurrentChallenge)
.flatMap(() => {
const challenge = challengeSelector(getState());
return Observable.of(
challengeUpdated(challenge),
push(`/challenges/${challenge.block}/${challenge.dashedName}`)
);
});
return actions::ofType(types.onRouteChallenges)
// prevent subsequent onRouteChallenges to cause UI to refresh
.distinctUntilChanged(({ payload: { dashedName }}) => dashedName)
.map(() => challengeSelector(getState()))
// if the challenge isn't loaded in the current state,
// this will be an empty object
// We wait instead for the fetchChallenge.complete to complete the UI state
.filter(({ dashedName }) => !!dashedName)
.flatMap(challenge =>
// send the challenge to update UI and update main iframe with inital
// challenge
Observable.of(challengeUpdated(challenge), updateMain())
);
}
// used to reset users code on request
export function resetChallengeEpic(actions, { getState }) {
return actions::ofType(types.resetChallenge)
.flatMap(() => {
const currentChallenge = currentChallengeSelector(getState());
return Observable.of(
updateCurrentChallenge(currentChallenge),
return actions::ofType(types.clickOnReset)
.flatMap(() =>
Observable.of(
challengeUpdated(challengeSelector(getState())),
updateMain()
);
});
));
}
export function nextChallengeEpic(actions, { getState }) {
@ -64,6 +67,7 @@ export function nextChallengeEpic(actions, { getState }) {
const superBlocks = superBlocksSelector(state);
const challenge = currentChallengeSelector(state);
const entities = entitiesSelector(state);
const lang = langSelector(state);
nextChallenge = getNextChallenge(challenge, entities, { isDev });
// block completed.
if (!nextChallenge) {
@ -107,11 +111,15 @@ export function nextChallengeEpic(actions, { getState }) {
'that have not been passed yet. ',
timeout: 15000
}),
push('/map')
onRouteCurrentChallenge()
);
}
return Observable.of(
updateCurrentChallenge(nextChallenge.dashedName),
// normally we wouldn't need to add the lang as
// addLangToRoutesEnhancer should add langs for us, but the way
// enhancers/middlewares and RFR orders things this action will not
// see addLangToRoutesEnhancer and cause RFR to render NotFound
onRouteChallenges({ lang, ...nextChallenge }),
makeToast({ message: 'Your next challenge has arrived.' })
);
} catch (err) {

View File

@ -2,48 +2,45 @@ import { Observable } from 'rx';
import { ofType } from 'redux-epic';
import {
types,
moveToNextChallenge,
clearSavedCode,
challengeMetaSelector,
filesSelector,
testsSelector
moveToNextChallenge,
submitChallengeComplete,
testsSelector,
types
} from './';
import {
createErrorObservable,
challengeSelector,
createErrorObservable,
csrfSelector,
userSelector
} from '../../../redux';
import {
updateUserPoints,
updateUserChallenge
} from '../../../entities';
import { filesSelector } from '../../../files';
import { backEndProject } from '../../../utils/challengeTypes.js';
import { makeToast } from '../../../Toasts/redux';
import { postJSON$ } from '../../../../utils/ajax-stream.js';
function postChallenge(url, username, _csrf, challengeInfo) {
const body = { ...challengeInfo, _csrf };
const saveChallenge = postJSON$(url, body)
.retry(3)
.flatMap(({ points, lastUpdated, completedDate }) => {
return Observable.of(
updateUserPoints(username, points),
updateUserChallenge(
username,
{ ...challengeInfo, lastUpdated, completedDate }
),
clearSavedCode()
);
})
.catch(createErrorObservable);
const challengeCompleted = Observable.of(moveToNextChallenge());
return Observable.merge(saveChallenge, challengeCompleted);
return Observable.if(
() => !!username,
Observable.defer(() => {
const body = { ...challengeInfo, _csrf };
const saveChallenge = postJSON$(url, body)
.retry(3)
.map(({ points, lastUpdated, completedDate }) =>
submitChallengeComplete(
username,
points,
{ ...challengeInfo, lastUpdated, completedDate }
)
)
.catch(createErrorObservable);
const challengeCompleted = Observable.of(moveToNextChallenge());
return Observable.merge(saveChallenge, challengeCompleted)
.startWith({ type: types.submitChallenge.start });
}),
Observable.of(moveToNextChallenge())
);
}
function submitModern(type, state) {
@ -53,7 +50,7 @@ function submitModern(type, state) {
return Observable.empty();
}
if (type === types.submitChallenge) {
if (type === types.submitChallenge.toString()) {
const { id } = challengeSelector(state);
const files = filesSelector(state);
const { username } = userSelector(state);
@ -145,7 +142,7 @@ const submitters = {
};
export default function completionEpic(actions, { getState }) {
return actions::ofType(types.checkChallenge, types.submitChallenge)
return actions::ofType(types.checkChallenge, types.submitChallenge.toString())
.flatMap(({ type, payload }) => {
const state = getState();
const { submitType } = challengeMetaSelector(state);

View File

@ -2,11 +2,11 @@ import { ofType } from 'redux-epic';
import {
types,
updateFile,
keySelector
} from './';
import { updateFile } from '../../../files';
export default function editorEpic(actions, { getState }) {
return actions::ofType(types.classicEditorUpdated)
.pluck('payload')

View File

@ -1,5 +1,11 @@
import { createTypes } from 'redux-create-types';
import { createAction, combineActions, handleActions } from 'redux-actions';
import {
combineActions,
combineReducers,
createAction,
createAsyncTypes,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import { createSelector } from 'reselect';
import noop from 'lodash/noop';
@ -10,11 +16,7 @@ import editorEpic from './editor-epic.js';
import ns from '../ns.json';
import {
arrayToString,
buildSeed,
createTests,
getFileKey,
getPreFile,
loggerToStr,
submitTypes,
viewTypes
@ -23,12 +25,12 @@ import {
types as app,
challengeSelector
} from '../../../redux';
import { bonfire, html, js } from '../../../utils/challengeTypes';
import blockNameify from '../../../utils/blockNameify';
import { createPoly, setContent } from '../../../../utils/polyvinyl';
import createStepReducer, { epics as stepEpics } from '../views/step/redux';
import createQuizReducer from '../views/quiz/redux';
import createProjectReducer from '../views/project/redux';
import { html } from '../../../utils/challengeTypes.js';
import blockNameify from '../../../utils/blockNameify.js';
import { getFileKey } from '../../../utils/classic-file.js';
import stepReducer, { epics as stepEpics } from '../views/step/redux';
import quizReducer from '../views/quiz/redux';
import projectReducer from '../views/project/redux';
// this is not great but is ok until we move to a different form type
export projectNormalizer from '../views/project/redux';
@ -42,21 +44,20 @@ export const epics = [
];
export const types = createTypes([
'onRouteChallengeRoot',
'onRouteChallenges',
'onRouteCurrentChallenge',
// challenges
// |- classic
'classicEditorUpdated',
'challengeUpdated',
'resetChallenge',
'clickOnReset',
'updateHint',
'lockUntrustedCode',
'unlockUntrustedCode',
'closeChallengeModal',
'updateSuccessMessage',
// files
'updateFile',
'updateFiles',
// rechallenge
'executeChallenge',
'updateMain',
@ -67,15 +68,9 @@ export const types = createTypes([
'initOutput',
'updateTests',
'checkChallenge',
'submitChallenge',
createAsyncTypes('submitChallenge'),
'moveToNextChallenge',
// code storage
'saveCode',
'loadCode',
'savedCodeFound',
'clearSavedCode',
// bug
'openBugModal',
'closeBugModal',
@ -91,6 +86,11 @@ export const types = createTypes([
'toggleStep'
], ns);
// routes
export const onRouteChallenges = createAction(types.onRouteChallenges);
export const onRouteCurrentChallenge =
createAction(types.onRouteCurrentChallenge);
// classic
export const classicEditorUpdated = createAction(types.classicEditorUpdated);
// challenges
@ -106,10 +106,7 @@ export const challengeUpdated = createAction(
types.challengeUpdated,
challenge => ({ challenge })
);
export const resetChallenge = createAction(types.resetChallenge);
// files
export const updateFile = createAction(types.updateFile);
export const updateFiles = createAction(types.updateFiles);
export const clickOnReset = createAction(types.clickOnReset);
// rechallenge
export const executeChallenge = createAction(
@ -130,16 +127,12 @@ export const updateOutput = createAction(types.updateOutput, loggerToStr);
export const checkChallenge = createAction(types.checkChallenge);
export const submitChallenge = createAction(types.submitChallenge);
export const moveToNextChallenge = createAction(types.moveToNextChallenge);
// code storage
export const saveCode = createAction(types.saveCode);
export const loadCode = createAction(types.loadCode);
export const savedCodeFound = createAction(
types.savedCodeFound,
(files, challenge) => ({ files, challenge })
export const submitChallengeComplete = createAction(
types.submitChallenge.complete,
(username, points, challengeInfo) => ({ username, points, challengeInfo })
);
export const clearSavedCode = createAction(types.clearSavedCode);
export const moveToNextChallenge = createAction(types.moveToNextChallenge);
// bug
export const openBugModal = createAction(types.openBugModal);
@ -151,9 +144,7 @@ const initialUiState = {
output: null,
isChallengeModalOpen: false,
isBugOpen: false,
successMessage: 'Happy Coding!',
hintIndex: 0,
numOfHints: 0
successMessage: 'Happy Coding!'
};
const initialState = {
@ -163,7 +154,6 @@ const initialState = {
helpChatRoom: 'Help',
// old code storage key
legacyKey: '',
files: {},
// map
superBlocks: [],
// misc
@ -172,7 +162,6 @@ const initialState = {
export const getNS = state => state[ns];
export const keySelector = state => getNS(state).key;
export const filesSelector = state => getNS(state).files;
export const testsSelector = state => getNS(state).tests;
export const outputSelector = state => getNS(state).output;
@ -186,7 +175,8 @@ export const challengeModalSelector =
export const bugModalSelector = state => getNS(state).isBugOpen;
export const challengeMetaSelector = createSelector(
challengeSelector,
// use closure to get around circular deps
(...args) => challengeSelector(...args),
challenge => {
if (!challenge.id) {
return {};
@ -214,15 +204,15 @@ export const challengeMetaSelector = createSelector(
}
);
export default function createReducers() {
const setChallengeType = combineActions(
types.challengeUpdated,
app.fetchChallenge.complete
);
const mainReducer = handleActions(
{
[setChallengeType]: (state, { payload: { challenge } }) => {
export default combineReducers(
handleActions(
() => ({
[
combineActions(
types.challengeUpdated,
app.fetchChallenge.complete
)
]: (state, { payload: { challenge } }) => {
return {
...state,
...initialUiState,
@ -230,10 +220,7 @@ export default function createReducers() {
challenge: challenge.dashedName,
key: getFileKey(challenge),
tests: createTests(challenge),
helpChatRoom: challenge.helpRoom || 'Help',
numOfHints: Array.isArray(challenge.hints) ?
challenge.hints.length :
0
helpChatRoom: challenge.helpRoom || 'Help'
};
},
[types.updateTests]: (state, { payload: tests }) => ({
@ -252,12 +239,6 @@ export default function createReducers() {
...state,
successMessage: payload
}),
[types.updateHint]: state => ({
...state,
hintIndex: state.hintIndex + 1 >= state.numOfHints ?
0 :
state.hintIndex + 1
}),
[types.lockUntrustedCode]: state => ({
...state,
isCodeLocked: true
@ -283,86 +264,11 @@ export default function createReducers() {
[types.openBugModal]: state => ({ ...state, isBugOpen: true }),
[types.closeBugModal]: state => ({ ...state, isBugOpen: false })
},
initialState
);
const filesReducer = handleActions(
{
[types.updateFile]: (state, { payload: { key, content }}) => ({
...state,
[key]: setContent(content, state[key])
}),
[types.updateFiles]: (state, { payload: files }) => {
return files
.reduce((files, file) => {
files[file.key] = file;
return files;
}, { ...state });
},
[types.savedCodeFound]: (state, { payload: { files, challenge } }) => {
if (challenge.type === 'mod') {
// this may need to change to update head/tail
return challenge.files;
}
if (
challenge.challengeType !== html &&
challenge.challengeType !== js &&
challenge.challengeType !== bonfire
) {
return {};
}
// classic challenge to modern format
const preFile = getPreFile(challenge);
return {
[preFile.key]: createPoly({
...files[preFile.key],
// make sure head/tail are always fresh
head: arrayToString(challenge.head),
tail: arrayToString(challenge.tail)
})
};
},
[setChallengeType]: (state, { payload: { challenge } }) => {
if (challenge.type === 'mod') {
return challenge.files;
}
if (
challenge.challengeType !== html &&
challenge.challengeType !== js &&
challenge.challengeType !== bonfire
) {
return {};
}
// classic challenge to modern format
const preFile = getPreFile(challenge);
return {
[preFile.key]: createPoly({
...preFile,
contents: buildSeed(challenge),
head: arrayToString(challenge.head),
tail: arrayToString(challenge.tail)
})
};
}
},
{}
);
function reducer(state, action) {
const newState = mainReducer(state, action);
const files = filesReducer(state && state.files || {}, action);
if (newState.files !== files) {
return { ...newState, files };
}
return newState;
}
reducer.toString = () => ns;
return [
reducer,
...createStepReducer(),
...createProjectReducer(),
...createQuizReducer()
];
}
}),
initialState,
ns
),
stepReducer,
quizReducer,
projectReducer
);

View File

@ -1,6 +1,4 @@
import flow from 'lodash/flow';
import * as challengeTypes from '../../utils/challengeTypes';
import { decodeScriptTags } from '../../../utils/encode-decode';
// determine the component to view for each challenge
export const viewTypes = {
@ -43,36 +41,6 @@ export const submitTypes = {
// has html that should be rendered
export const descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
export function arrayToString(seedData = ['']) {
seedData = Array.isArray(seedData) ? seedData : [seedData];
return seedData.reduce((seed, line) => '' + seed + line + '\n', '\n');
}
export function buildSeed({ challengeSeed = [] } = {}) {
return flow(
arrayToString,
decodeScriptTags
)(challengeSeed);
}
const pathsMap = {
[ challengeTypes.html ]: 'html',
[ challengeTypes.js ]: 'js',
[ challengeTypes.bonfire ]: 'js'
};
export function getPreFile({ challengeType }) {
return {
name: 'index',
ext: pathsMap[challengeType] || 'html',
key: getFileKey({ challengeType })
};
}
export function getFileKey({ challengeType }) {
return 'index' + (pathsMap[challengeType] || 'html');
}
export function createTests({ tests = [] }) {
return tests
.map(test => {

View File

@ -1,4 +1,5 @@
import React, { PropTypes, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { createSelector } from 'reselect';
import { reduxForm } from 'redux-form';
import {

View File

@ -3,15 +3,19 @@ import React from 'react';
import BackEnd from './Back-End.jsx';
import { types } from '../../redux';
import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {};
export const panesMap = {
[types.toggleMap]: 'Map',
[types.toggleMain]: 'Main'
};
export const panesMap = createPaneMap(
'backend',
() => ({
[types.toggleMap]: 'Map',
[types.toggleMain]: 'Main'
})
);
const nameToComponentDef = {
Map: {

View File

@ -1,4 +1,5 @@
import React, { PureComponent, PropTypes } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
@ -12,10 +13,11 @@ import {
executeChallenge,
classicEditorUpdated,
challengeMetaSelector,
filesSelector,
keySelector
} from '../../redux';
import { filesSelector } from '../../../../files';
const envProps = typeof window !== 'undefined' ? Object.keys(window) : [];
const options = {
lint: {

View File

@ -3,19 +3,26 @@ import React from 'react';
import SidePanel from './Side-Panel.jsx';
import Editor from './Editor.jsx';
import Preview from './Preview.jsx';
import { types } from '../../redux';
import { types, challengeMetaSelector } from '../../redux';
import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {};
export const panesMap = {
[types.toggleMap]: 'Map',
[types.toggleSidePanel]: 'Side Panel',
[types.toggleClassicEditor]: 'Editor',
[types.togglePreview]: 'Preview'
};
export const panesMap = createPaneMap(
'classic',
() => ({
[types.toggleMap]: 'Map',
[types.toggleSidePanel]: 'Side Panel',
[types.toggleClassicEditor]: 'Editor',
[types.togglePreview]: {
name: 'Preview',
filter: state => !!challengeMetaSelector(state).showPreview
}
})
);
const nameToComponent = {
Map: {

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import ReactDom from 'react-dom';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';
@ -46,10 +47,7 @@ const mapStateToProps = createSelector(
codeLockedSelector,
chatRoomSelector,
(
{
description,
hints = []
},
{ description },
{ title },
tests,
output,
@ -61,7 +59,6 @@ const mapStateToProps = createSelector(
description,
tests,
output,
hint: hints[hintIndex],
isCodeLocked,
helpChatRoom
})

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { Button, ButtonGroup, Tooltip, OverlayTrigger } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component';
@ -39,7 +40,7 @@ export default class ToolPanel extends PureComponent {
this.props.makeToast({
message: 'This will restore your code editor to its original state.',
action: 'clear my code',
actionCreator: 'resetChallenge',
actionCreator: 'clickOnReset',
timeout: 4000
});
}

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { reduxForm } from 'redux-form';
import {
Button,

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component';

View File

@ -1,16 +1,21 @@
import React from 'react';
import ns from './ns.json';
import Main from './Project.jsx';
import { types } from '../../redux';
import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {};
export const panesMap = {
[types.toggleMap]: 'Map',
[types.toggleMain]: 'Main'
};
export const panesMap = createPaneMap(
ns,
() => ({
[types.toggleMap]: 'Map',
[types.toggleMain]: 'Main'
})
);
const nameToComponent = {
Map: {

View File

@ -1,4 +1,5 @@
import React, { PropTypes, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import ChallengeTitle from '../../Challenge-Title.jsx';
const propTypes = {

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import PureComponent from 'react-pure-render/component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';

View File

@ -1,5 +1,8 @@
import { createTypes } from 'redux-create-types';
import { createAction, handleActions } from 'redux-actions';
import {
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import ns from '../ns.json';
export const types = createTypes([
@ -14,14 +17,13 @@ const initialState = {
};
export const submittingSelector = state => state[ns].isSubmitting;
export default function createReducer() {
const reducer = handleActions({
export default handleActions(
() => ({
[types.showProjectSubmit]: state => ({
...state,
isSubmitting: true
})
}, initialState);
reducer.toString = () => ns;
return [ reducer ];
}
}),
initialState,
ns
);

View File

@ -1,4 +1,5 @@
import React, { PropTypes, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import classnames from 'classnames';

View File

@ -1,4 +1,5 @@
import React, { PropTypes, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';

View File

@ -1,16 +1,21 @@
import React from 'react';
import ns from './ns.json';
import Main from './Quiz.jsx';
import { types } from '../../redux';
import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {};
export const panesMap = {
[types.toggleMap]: 'Map',
[types.toggleMain]: 'Main'
};
export const panesMap = createPaneMap(
ns,
() => ({
[types.toggleMap]: 'Map',
[types.toggleMain]: 'Main'
})
);
const nameToComponent = {
Map: {

View File

@ -1,5 +1,8 @@
import { createTypes } from 'redux-create-types';
import { createAction, handleActions } from 'redux-actions';
import {
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import noop from 'lodash/noop';
import ns from '../ns.json';
@ -48,8 +51,8 @@ export const currentIndexSelector = state => getNS(state).currentIndex;
export const selectedChoiceSelector = state => getNS(state).selectedChoice;
export const correctSelector = state => getNS(state).correct;
export default function createReducers() {
const reducer = handleActions({
export default handleActions(
() => ({
[types.nextQuestion]: state => ({
...state,
currentIndex: state.currentIndex + 1
@ -72,8 +75,7 @@ export default function createReducers() {
...state,
selectedChoice: null
})
}, initialState);
reducer.toString = () => ns;
return [ reducer ];
}
}),
initialState,
ns
);

View File

@ -1,16 +1,21 @@
import React from 'react';
import ns from './ns.json';
import Step from './Step.jsx';
import { types } from '../../redux';
import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {};
export const panesMap = {
[types.toggleMap]: 'Map',
[types.toggleStep]: 'Step'
};
export const panesMap = createPaneMap(
ns,
() => ({
[types.toggleMap]: 'Map',
[types.toggleStep]: 'Step'
})
);
const nameToComponent = {
Map: {

Some files were not shown because too many files have changed in this diff Show More