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"], "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": { "extends": "freecodecamp"
"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
}
} }

View File

@ -27,7 +27,7 @@ function formatFields({ type, ...fields }) {
}, { type }); }, { type });
} }
export default function analyticsSaga(actions, { getState }, { window }) { export default function analyticsSaga(actions, _, { window }) {
const { ga } = window; const { ga } = window;
if (typeof ga !== 'function') { if (typeof ga !== 'function') {
console.log('GA not found'); 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 { setContent } from '../../common/utils/polyvinyl';
import { import {
types as app,
userSelector, userSelector,
challengeSelector challengeSelector
} from '../../common/app/redux'; } from '../../common/app/redux';
import { makeToast } from '../../common/app/Toasts/redux'; import { makeToast } from '../../common/app/Toasts/redux';
import { import {
types, types,
savedCodeFound,
updateMain, updateMain,
lockUntrustedCode, lockUntrustedCode,
keySelector, keySelector,
filesSelector,
codeLockedSelector codeLockedSelector
} from '../../common/app/routes/challenges/redux'; } from '../../common/app/routes/Challenges/redux';
import { filesSelector, savedCodeFound } from '../../common/app/files';
const legacyPrefixes = [ const legacyPrefixes = [
'Bonfire: ', 'Bonfire: ',
@ -59,18 +60,19 @@ function legacyToFile(code, files, key) {
} }
export function clearCodeEpic(actions, { getState }) { export function clearCodeEpic(actions, { getState }) {
return actions::ofType(types.clearSavedCode) return actions::ofType(types.submitChallenge.complete)
.map(() => { .do(() => {
const { id } = challengeSelector(getState()); const { id } = challengeSelector(getState());
store.remove(id); store.remove(id);
}) })
.ignoreElements(); .ignoreElements();
} }
export function saveCodeEpic(actions, { getState }) { export function saveCodeEpic(actions, { getState }) {
return actions::ofType(types.saveCode) return actions::ofType(types.executeChallenge)
// do not save challenge if code is locked // do not save challenge if code is locked
.filter(() => !codeLockedSelector(getState())) .filter(() => !codeLockedSelector(getState()))
.map(() => { .do(() => {
const { id } = challengeSelector(getState()); const { id } = challengeSelector(getState());
const files = filesSelector(getState()); const files = filesSelector(getState());
store.set(id, files); store.set(id, files);
@ -79,7 +81,11 @@ export function saveCodeEpic(actions, { getState }) {
} }
export function loadCodeEpic(actions, { getState }, { window, location }) { 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(() => { .flatMap(() => {
let finalFiles; let finalFiles;
const state = getState(); const state = getState();

View File

@ -17,11 +17,11 @@ import {
frameMain, frameMain,
frameTests, frameTests,
initOutput, initOutput,
saveCode,
filesSelector,
codeLockedSelector 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 }) { export default function executeChallengeEpic(actions, { getState }) {
return actions::ofType(types.executeChallenge, types.updateMain) return actions::ofType(types.executeChallenge, types.updateMain)
@ -47,7 +47,7 @@ export default function executeChallengeEpic(actions, { getState }) {
frameMain(payload) frameMain(payload)
]; ];
if (type === types.executeChallenge) { if (type === types.executeChallenge) {
actions.push(saveCode(), frameTests(payload)); actions.push(frameTests(payload));
} }
return Observable.from(actions, null, null, Scheduler.default); return Observable.from(actions, null, null, Scheduler.default);
}) })

View File

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

View File

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

View File

@ -1,21 +1,13 @@
import './es6-shims'; import './es6-shims';
import Rx from 'rx'; import Rx from 'rx';
import React from 'react';
import debug from 'debug'; 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 { render } from 'redux-epic';
import { createHistory } from 'history'; import createHistory from 'history/createBrowserHistory';
import useLangRoutes from './utils/use-lang-routes'; import useLangRoutes from './utils/use-lang-routes';
import sendPageAnalytics from './utils/send-page-analytics'; import sendPageAnalytics from './utils/send-page-analytics';
import flashToToast from './utils/flash-to-toast'; import flashToToast from './utils/flash-to-toast';
import createApp from '../common/app'; import { App, createApp, provideStore } from '../common/app';
import provideStore from '../common/app/provide-store';
import { getLangFromPath } from '../common/app/utils/lang'; import { getLangFromPath } from '../common/app/utils/lang';
// client specific epics // client specific epics
@ -32,13 +24,13 @@ const log = debug('fcc:client');
const hotReloadTimeout = 2000; const hotReloadTimeout = 2000;
const { csrf: { token: csrfToken } = {} } = window.__fcc__; const { csrf: { token: csrfToken } = {} } = window.__fcc__;
const DOMContainer = document.getElementById('fcc'); const DOMContainer = document.getElementById('fcc');
const initialState = isColdStored() ? const defaultState = isColdStored() ?
getColdStorage() : getColdStorage() :
window.__fcc__.data; window.__fcc__.data;
const primaryLang = getLangFromPath(window.location.pathname); const primaryLang = getLangFromPath(window.location.pathname);
initialState.app.csrfToken = csrfToken; defaultState.app.csrfToken = csrfToken;
initialState.toasts = flashToToast(window.__fcc__.flash); defaultState.toasts = flashToToast(window.__fcc__.flash);
// make empty object so hot reload works // make empty object so hot reload works
window.__fcc__ = {}; window.__fcc__ = {};
@ -49,7 +41,6 @@ const history = useLangRoutes(createHistory, primaryLang)();
sendPageAnalytics(history, window.ga); sendPageAnalytics(history, window.ga);
const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f; const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
const adjustUrlOnReplay = !!window.devToolsExtension;
const epicOptions = { const epicOptions = {
isDev, isDev,
@ -61,14 +52,10 @@ const epicOptions = {
createApp({ createApp({
history, history,
syncHistoryWithStore,
syncOptions: { adjustUrlOnReplay },
serviceOptions, serviceOptions,
initialState, defaultState,
middlewares: [ routerMiddleware(history) ],
epics, epics,
epicOptions, epicOptions,
reducers: { routing },
enhancers: [ devTools ] enhancers: [ devTools ]
}) })
.doOnNext(({ store }) => { .doOnNext(({ store }) => {
@ -83,13 +70,7 @@ createApp({
} }
}) })
.doOnNext(() => log('rendering')) .doOnNext(() => log('rendering'))
.flatMap( .flatMap(({ store }) => render(provideStore(App, store), DOMContainer))
({ props, store }) => render(
provideStore(React.createElement(Router, props), store),
DOMContainer
),
({ store }) => store
)
.subscribe( .subscribe(
() => debug('react rendered'), () => debug('react rendered'),
err => { throw err; }, 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 { connect } from 'react-redux';
import ns from './ns.json'; import ns from './ns.json';
import { import {
appMounted, appMounted,
fetchUser, fetchUser,
updateAppLang,
userSelector isSignedInSelector
} from './redux'; } from './redux';
import Nav from './Nav'; import Nav from './Nav';
import Toasts from './Toasts'; 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 = { const mapDispatchToProps = {
appMounted, appMounted,
fetchUser, fetchUser
updateAppLang
}; };
const mapStateToProps = state => { const mapStateToProps = state => {
const { username } = userSelector(state); const isSignedIn = isSignedInSelector(state);
const route = mainRouteSelector(state);
return { return {
toast: state.app.toast, toast: state.app.toast,
isSignedIn: !!username isSignedIn,
route
}; };
}; };
@ -32,19 +37,17 @@ const propTypes = {
children: PropTypes.node, children: PropTypes.node,
fetchUser: PropTypes.func, fetchUser: PropTypes.func,
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
params: PropTypes.object, route: PropTypes.string,
toast: PropTypes.object, toast: PropTypes.object
updateAppLang: PropTypes.func.isRequired };
const routes = {
challenges: Challenges,
settings: Settings
}; };
// export plain class for testing // export plain class for testing
export class FreeCodeCamp extends React.Component { export class FreeCodeCamp extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.params.lang !== nextProps.params.lang) {
this.props.updateAppLang(nextProps.params.lang);
}
}
componentDidMount() { componentDidMount() {
this.props.appMounted(); this.props.appMounted();
if (!this.props.isSignedIn) { if (!this.props.isSignedIn) {
@ -53,14 +56,18 @@ export class FreeCodeCamp extends React.Component {
} }
render() { render() {
const {
route
} = this.props;
const Child = routes[route] || NotFound;
// we render nav after the content // we render nav after the content
// to allow the panes to update // to allow the panes to update
// redux store, which will update the bin // redux store, which will update the bin
// buttons in the nav // buttons in the nav
return ( return (
<div className={ `${ns}-container` }> <div className={ `${ns}-container` }>
{ this.props.children }
<Nav /> <Nav />
<Child />
<Toasts /> <Toasts />
</div> </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 classnames from 'classnames';
import ns from './ns.json'; 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 { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import FA from 'react-fontawesome'; 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 { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Link } from 'react-router';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
import classnames from 'classnames'; import classnames from 'classnames';
import debug from 'debug'; import debug from 'debug';
@ -13,6 +13,8 @@ import {
} from './redux'; } from './redux';
import { userSelector } from '../redux'; import { userSelector } from '../redux';
import { challengeMapSelector } from '../entities'; import { challengeMapSelector } from '../entities';
import { Link } from '../Router';
import { onRouteChallenges } from '../routes/Challenges/redux';
const propTypes = { const propTypes = {
block: PropTypes.string, block: PropTypes.string,
@ -27,15 +29,7 @@ const propTypes = {
isRequired: PropTypes.bool, isRequired: PropTypes.bool,
title: PropTypes.string title: PropTypes.string
}; };
function mapDispatchToProps(dispatch, { dashedName }) { const mapDispatchToProps = { clickOnChallenge };
const dispatchers = {
clickOnChallenge: e => {
e.preventDefault();
return dispatch(clickOnChallenge(dashedName));
}
};
return () => dispatchers;
}
function makeMapStateToProps(_, { dashedName }) { function makeMapStateToProps(_, { dashedName }) {
return createSelector( return createSelector(
@ -152,8 +146,11 @@ export class Challenge extends PureComponent {
className={ challengeClassName } className={ challengeClassName }
key={ title } key={ title }
> >
<Link to={ `/challenges/${block}/${dashedName}` }> <Link
<span onClick={ clickOnChallenge }> onClick={ clickOnChallenge }
to={ onRouteChallenges({ dashedName, block }) }
>
<span >
{ title } { title }
{ this.renderCompleted(isCompleted, isLocked) } { this.renderCompleted(isCompleted, isLocked) }
{ this.renderRequired(isRequired) } { 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 { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
import { Col, Row } from 'react-bootstrap'; 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 { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,15 @@
import React, { PropTypes } from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { hardGoTo } from '../redux';
const propTypes = { // import PropTypes from 'prop-types';
hardGoTo: PropTypes.func,
location: PropTypes.object
};
export class NotFound extends React.Component { const propTypes = {};
componentWillMount() {
this.props.hardGoTo(this.props.location.pathname);
}
render() { export default function NotFound() {
return <span />; return (
} <div>404 Not Found</div>
);
} }
NotFound.displayName = 'NotFound'; NotFound.displayName = 'NotFound';
NotFound.propTypes = propTypes; 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 { connect } from 'react-redux';
import { dividerClicked } from './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'; import { connect } from 'react-redux';
const mapStateToProps = null; 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 { connect } from 'react-redux';
import Panes from './Panes.jsx'; import Panes from './Panes.jsx';
import { import { panesMounted } from './redux';
panesMounted,
panesUpdated,
panesWillMount,
panesWillUnmount
} from './redux';
const mapStateToProps = null; const mapStateToProps = null;
const mapDispatchToProps = { const mapDispatchToProps = {
panesMounted, panesMounted
panesUpdated,
panesWillMount,
panesWillUnmount
}; };
const propTypes = { const propTypes = {
nameToComponent: PropTypes.object.isRequired, nameToComponent: PropTypes.object.isRequired,
panesMounted: PropTypes.func.isRequired, panesMounted: PropTypes.func.isRequired
panesUpdated: PropTypes.func.isRequired,
panesWillMount: PropTypes.func.isRequired,
panesWillUnmount: PropTypes.func.isRequired
}; };
export class PanesContainer extends PureComponent { export class PanesContainer extends PureComponent {
componentWillMount() {
this.props.panesWillMount(Object.keys(this.props.nameToComponent));
}
componentDidMount() { componentDidMount() {
this.props.panesMounted(); this.props.panesMounted();
} }
componentWillUnmount() {
this.props.panesWillUnmount();
}
componentWillReceiveProps(nextProps) {
if (nextProps.nameToComponent !== this.props.nameToComponent) {
this.props.panesUpdated(Object.keys(nextProps.nameToComponent));
}
}
render() { render() {
return ( return (
<Panes { ...this.props } /> <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 { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
@ -20,7 +21,7 @@ const mapStateToProps = createSelector(
let lastDividerPosition = 0; let lastDividerPosition = 0;
return { return {
panes: panes panes: panes
.map(name => panesByName[name]) .map(({ name }) => panesByName[name])
.filter(({ isHidden })=> !isHidden) .filter(({ isHidden })=> !isHidden)
.map((pane, index, { length: numOfPanes }) => { .map((pane, index, { length: numOfPanes }) => {
const dividerLeft = pane.dividerLeft || 0; const dividerLeft = pane.dividerLeft || 0;

View File

@ -1,11 +1,18 @@
import { combineActions, createAction, handleActions } from 'redux-actions'; import { isLocationAction } from 'redux-first-router';
import { createTypes } from 'redux-create-types'; import _ from 'lodash';
import clamp from 'lodash/clamp'; import {
composeReducers,
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import ns from '../ns.json'; import ns from '../ns.json';
import windowEpic from './window-epic.js'; import windowEpic from './window-epic.js';
import dividerEpic from './divider-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'; const isDev = process.env.NODE_ENV !== 'production';
export const epics = [ export const epics = [
@ -14,6 +21,7 @@ export const epics = [
]; ];
export const types = createTypes([ export const types = createTypes([
'panesUpdatedThroughFetch',
'panesMounted', 'panesMounted',
'panesUpdated', 'panesUpdated',
'panesWillMount', 'panesWillMount',
@ -30,6 +38,11 @@ export const types = createTypes([
'hidePane' 'hidePane'
], ns); ], ns);
export const panesUpdatedThroughFetch = createAction(
types.panesUpdatedThroughFetch,
null,
panesView => ({ panesView })
);
export const panesMounted = createAction(types.panesMounted); export const panesMounted = createAction(types.panesMounted);
export const panesUpdated = createAction(types.panesUpdated); export const panesUpdated = createAction(types.panesUpdated);
export const panesWillMount = createAction(types.panesWillMount); export const panesWillMount = createAction(types.panesWillMount);
@ -78,9 +91,45 @@ function getDividerLeft(numOfPanes, index) {
return dividerLeft; 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) { if (isDev) {
Object.keys(typeToName).forEach(actionType => { forEachConfig(config, (typeToName, actionType) => {
if (actionType === 'undefined') { if (actionType === 'undefined') {
throw new Error( throw new Error(
`action type for ${typeToName[actionType]} is undefined` `action type for ${typeToName[actionType]} is undefined`
@ -88,18 +137,24 @@ export default function createPanesAspects(typeToName) {
} }
}); });
} }
const nameToType = Object.keys(typeToName).reduce((map, type) => { const typeToName = reduceConfig(config, (acc, val, type) => {
map[typeToName[type]] = type; const name = _.isObject(val) ? val.name : val;
return map; acc[type] = name;
}, {}); return acc;
function getInitialState() { });
return {
...initialState,
nameToType
};
}
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 => { return next => action => {
let finalAction = action; let finalAction = action;
if (isPanesAction(action, typeToName)) { if (isPanesAction(action, typeToName)) {
@ -107,15 +162,36 @@ export default function createPanesAspects(typeToName) {
...action, ...action,
meta: { meta: {
...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({ const reducer = composeReducers(
ns,
handleActions(
() => ({
[types.dividerClicked]: (state, { payload: name }) => ({ [types.dividerClicked]: (state, { payload: name }) => ({
...state, ...state,
pressedDivider: name pressedDivider: name
@ -123,13 +199,16 @@ export default function createPanesAspects(typeToName) {
[types.dividerMoved]: (state, { payload: clientX }) => { [types.dividerMoved]: (state, { payload: clientX }) => {
const { width, pressedDivider: paneName } = state; const { width, pressedDivider: paneName } = state;
const dividerBuffer = (200 / width) * 100; const dividerBuffer = (200 / width) * 100;
const paneIndex = state.panes.indexOf(paneName); const paneIndex =
_.findIndex(state.panes, ({ name }) => paneName === name);
const currentPane = state.panesByName[paneName]; const currentPane = state.panesByName[paneName];
const rightPane = state.panesByName[state.panes[paneIndex + 1]] || {}; const rightPane =
const leftPane = state.panesByName[state.panes[paneIndex - 1]] || {}; state.panesByName[getPaneName(state.panes, paneIndex + 1)] || {};
const leftPane =
state.panesByName[getPaneName(state.panes, paneIndex - 1)] || {};
const rightBound = (rightPane.dividerLeft || 100) - dividerBuffer; const rightBound = (rightPane.dividerLeft || 100) - dividerBuffer;
const leftBound = (leftPane.dividerLeft || 0) + dividerBuffer; const leftBound = (leftPane.dividerLeft || 0) + dividerBuffer;
const newPosition = clamp( const newPosition = _.clamp(
(clientX / width) * 100, (clientX / width) * 100,
leftBound, leftBound,
rightBound rightBound
@ -158,17 +237,22 @@ export default function createPanesAspects(typeToName) {
panesByName: {}, panesByName: {},
pressedDivider: null pressedDivider: null
}), }),
[ [types.updateNavHeight]: (state, { payload: navHeight }) => ({
combineActions( ...state,
panesWillMount, navHeight
panesUpdated })
) }),
]: (state, { payload: panes }) => { initialState,
const numOfPanes = panes.length; ),
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 { return {
...state, ...state,
panes, panes,
panesByName: panes.reduce((panes, name, index) => { panesByName: panes.reduce((panes, { name }, index) => {
const dividerLeft = getDividerLeft(numOfPanes, index); const dividerLeft = getDividerLeft(numOfPanes, index);
panes[name] = { panes[name] = {
name, name,
@ -178,15 +262,9 @@ export default function createPanesAspects(typeToName) {
return panes; return panes;
}, {}) }, {})
}; };
}, }
[types.updateNavHeight]: (state, { payload: navHeight }) => ({
...state,
navHeight
})
}, getInitialState());
function metaReducer(state = getInitialState(), action) {
if (action.meta && action.meta.isPaneAction) { if (action.meta && action.meta.isPaneAction) {
const name = typeToName[action.type]; const name = action.meta.paneName;
const oldPane = state.panesByName[name]; const oldPane = state.panesByName[name];
const pane = { const pane = {
...oldPane, ...oldPane,
@ -196,14 +274,14 @@ export default function createPanesAspects(typeToName) {
...state.panesByName, ...state.panesByName,
[name]: pane [name]: pane
}; };
const numOfPanes = state.panes.reduce((sum, name) => { const numOfPanes = state.panes.reduce((sum, { name }) => {
return panesByName[name].isHidden ? sum : sum + 1; return panesByName[name].isHidden ? sum : sum + 1;
}, 0); }, 0);
let numOfHidden = 0; let numOfHidden = 0;
return { return {
...state, ...state,
panesByName: state.panes.reduce( panesByName: state.panes.reduce(
(panesByName, name, index) => { (panesByName, { name }, index) => {
if (!panesByName[name].isHidden) { if (!panesByName[name].isHidden) {
const dividerLeft = getDividerLeft( const dividerLeft = getDividerLeft(
numOfPanes, numOfPanes,
@ -224,13 +302,10 @@ export default function createPanesAspects(typeToName) {
} }
return state; return state;
} }
);
function finalReducer(state, action) {
return reducer(metaReducer(state, action), action);
}
finalReducer.toString = () => ns;
return { return {
reducer: finalReducer, reducer,
middleware 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 { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { NotificationStack } from 'react-notification'; import { NotificationStack } from 'react-notification';
@ -6,12 +7,12 @@ import { NotificationStack } from 'react-notification';
import { removeToast } from './redux'; import { removeToast } from './redux';
import { import {
submitChallenge, submitChallenge,
resetChallenge clickOnReset
} from '../routes/challenges/redux'; } from '../routes/Challenges/redux';
const registeredActions = { const registeredActions = {
submitChallenge, submitChallenge,
resetChallenge clickOnReset
}; };
const mapStateToProps = state => ({ toasts: state.toasts }); const mapStateToProps = state => ({ toasts: state.toasts });
// we use styles here to overwrite those built into the library // we use styles here to overwrite those built into the library

View File

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

View File

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

View File

@ -1,42 +1,35 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import { match } from 'react-router';
import { compose, createStore, applyMiddleware } from 'redux'; 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 { createEpic } from 'redux-epic';
import createReducer from './create-reducer'; import appReducer from './reducer.js';
import createRoutes from './create-routes.js'; import routesMap from './routes-map.js';
import createPanesMap from './create-panes-map.js'; import createPanesMap from './create-panes-map.js';
import createPanesAspects from './Panes/redux'; import createPanesAspects from './Panes/redux';
import addLangToRoutesEnhancer from './Router/redux/add-lang-enhancer.js';
import epics from './epics'; import epics from './epics';
import { onBeforeChange } from './utils/redux-first-router.js';
import servicesCreator from '../utils/services-creator'; import servicesCreator from '../utils/services-creator';
const createRouteProps = Observable.fromNodeCallback(match);
//
// createApp(settings: { // createApp(settings: {
// location?: Location|String,
// history?: History, // history?: History,
// syncHistoryWithStore?: ((history, store) => history) = (x) => x, // defaultState?: Object|Void,
// initialState?: Object|Void,
// serviceOptions?: Object, // serviceOptions?: Object,
// middlewares?: Function[], // middlewares?: Function[],
// sideReducers?: Object
// enhancers?: Function[], // enhancers?: Function[],
// epics?: Function[], // epics?: Function[],
// }) => Observable // }) => Observable
// //
// Either location or history must be defined // Either location or history must be defined
export default function createApp({ export default function createApp({
location,
history, history,
syncHistoryWithStore = (x) => x, defaultState,
syncOptions = {},
initialState,
serviceOptions = {}, serviceOptions = {},
middlewares: sideMiddlewares = [], middlewares: sideMiddlewares = [],
enhancers: sideEnhancers = [], enhancers: sideEnhancers = [],
reducers: sideReducers = {},
epics: sideEpics = [], epics: sideEpics = [],
epicOptions: sideEpicOptions = {} epicOptions: sideEpicOptions = {}
}) { }) {
@ -50,12 +43,25 @@ export default function createApp({
...epics, ...epics,
...sideEpics ...sideEpics
); );
const {
reducer: routesReducer,
middleware: routesMiddleware,
enhancer: routesEnhancer
} = connectRoutes(history, routesMap, { onBeforeChange });
routesReducer.toString = () => 'location';
const { const {
reducer: panesReducer, reducer: panesReducer,
middleware: panesMiddleware middleware: panesMiddleware
} = createPanesAspects(createPanesMap()); } = createPanesAspects(createPanesMap());
const enhancer = compose( const enhancer = compose(
addLangToRoutesEnhancer(routesMap),
routesEnhancer,
applyMiddleware( applyMiddleware(
routesMiddleware,
panesMiddleware, panesMiddleware,
epicMiddleware, epicMiddleware,
...sideMiddlewares ...sideMiddlewares
@ -64,33 +70,31 @@ export default function createApp({
// on client side these are things like Redux DevTools // on client side these are things like Redux DevTools
...sideEnhancers ...sideEnhancers
); );
const reducer = createReducer(
{ const reducer = combineReducers(
[panesReducer]: panesReducer, appReducer,
...sideReducers panesReducer,
}, routesReducer
); );
// create composed store enhancer // create composed store enhancer
// use store enhancer function to enhance `createStore` function // 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 // to create store
const store = createStore(reducer, initialState, enhancer); const store = createStore(reducer, defaultState, enhancer);
// sync history client side with store. const location = selectLocationState(store.getState());
// server side this is an identity function and history is undefined
history = syncHistoryWithStore(history, store, syncOptions); // ({
const routes = createRoutes(store); // redirect,
// createRouteProps({ // props,
// redirect: LocationDescriptor, // reducer,
// history: History, // store,
// routes: Object // epic: epicMiddleware
// }) => Observable // }));
return createRouteProps({ routes, location, history }) return Observable.of({
.map(([ redirect, props ]) => ({
redirect,
props,
reducer,
store, store,
epic: epicMiddleware 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 {
import { createAction, handleActions } from 'redux-actions'; composeReducers,
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import { types as app } from '../routes/Challenges/redux';
export const ns = 'entities'; export const ns = 'entities';
export const getNS = state => state[ns]; export const getNS = state => state[ns];
export const entitiesSelector = getNS; export const entitiesSelector = getNS;
export const types = createTypes([ export const types = createTypes([
'updateUserPoints',
'updateUserFlag', 'updateUserFlag',
'updateUserEmail', 'updateUserEmail',
'updateUserLang', 'updateUserLang',
'updateUserChallenge',
'updateUserCurrentChallenge' 'updateUserCurrentChallenge'
], ns); ], ns);
// updateUserPoints(username: String, points: Number) => Action
export const updateUserPoints = createAction(
types.updateUserPoints,
(username, points) => ({ username, points })
);
// updateUserFlag(username: String, flag: String) => Action // updateUserFlag(username: String, flag: String) => Action
export const updateUserFlag = createAction( export const updateUserFlag = createAction(
types.updateUserFlag, types.updateUserFlag,
@ -34,21 +33,12 @@ export const updateUserLang = createAction(
(username, lang) => ({ username, languageTag: lang }) (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( export const updateUserCurrentChallenge = createAction(
types.updateUserCurrentChallenge types.updateUserCurrentChallenge
); );
const initialState = { const defaultState = {
superBlock: {}, superBlock: {},
block: {}, block: {},
challenge: {}, challenge: {},
@ -69,14 +59,33 @@ export function makeSuperBlockSelector(name) {
}; };
} }
export default function createReducer() { export const isChallengeLoaded = (state, { dashedName }) =>
const userReducer = handleActions( !!challengeMapSelector(state)[dashedName];
{
[types.updateUserPoints]: (state, { payload: { username, points } }) => ({ 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, ...state,
[username]: { [username]: {
...state[username], ...state[username],
points points,
challengeMap: {
...state[username].challengeMap,
[challengeInfo.id]: challengeInfo
}
} }
}), }),
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({ [types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
@ -118,46 +127,8 @@ export default function createReducer() {
...state[username], ...state[username],
currentChallengeId currentChallengeId
} }
}),
[types.updateUserChallenge]:
(
state,
{
payload: { username, challengeInfo }
}
) => ({
...state,
[username]: {
...state[username],
challengeMap: {
...state[username].challengeMap,
[challengeInfo.id]: challengeInfo
}
}
}) })
}, }),
initialState.user defaultState
)
); );
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;
}

View File

@ -1,6 +1,6 @@
import { epics as app } from './redux'; import { epics as app } from './redux';
import { epics as challenge } from './routes/challenges/redux'; import { epics as challenge } from './routes/Challenges/redux';
import { epics as settings } from './routes/settings/redux'; import { epics as settings } from './routes/Settings/redux';
import { epics as nav } from './Nav/redux'; import { epics as nav } from './Nav/redux';
import { epics as map } from './Map/redux'; import { epics as map } from './Map/redux';
import { epics as panes } from './Panes/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 */ /* eslint-disable react/display-name */
import React from 'react'; import { createElement } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
export default function provideStore(element, store) { export default function provideStore(Component, store) {
return React.createElement( return createElement(
Provider, Provider,
{ store }, { 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, delayedRedirect,
fetchChallengeCompleted, fetchChallengeCompleted,
fetchChallengesCompleted, fetchChallengesCompleted
langSelector
} from './'; } from './';
import { isChallengeLoaded } from '../entities/index.js';
import { shapeChallenges } from './utils'; import { shapeChallenges } from './utils';
import { types as challenge } from '../routes/Challenges/redux';
import { langSelector } from '../Router/redux';
const isDev = debug.enabled('fcc:*'); const isDev = debug.enabled('fcc:*');
export function fetchChallengeEpic(actions, { getState }, { services }) { export function fetchChallengeEpic(actions, { getState }, { services }) {
return actions::ofType('' + types.fetchChallenge) return actions::ofType(challenge.onRouteChallenges)
.flatMap(({ payload: { dashedName, block } }) => { .filter(({ payload }) => !isChallengeLoaded(getState(), payload))
const lang = langSelector(getState()); .flatMapLatest(({ payload: params }) => {
const options = { const options = {
service: 'map', service: 'map',
params: { block, dashedName, lang } params
}; };
return services.readService$(options) return services.readService$(options)
.retry(3) .retry(3)
@ -52,11 +54,7 @@ export function fetchChallengesEpic(
{ getState }, { getState },
{ services } { services }
) { ) {
return actions::ofType( return actions::ofType(types.appMounted)
// async type
'' + types.fetchChallenges,
types.appMounted
)
.flatMapLatest(() => { .flatMapLatest(() => {
const lang = langSelector(getState()); const lang = langSelector(getState());
const options = { const options = {

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export function filterComingSoonBetaChallenge(
} }
export function filterComingSoonBetaFromEntities( export function filterComingSoonBetaFromEntities(
{ challenge: challengeMap, block: blockMap, ...rest }, { challenge: challengeMap, block: blockMap = {}, ...rest },
isDev = false isDev = false
) { ) {
const filter = filterComingSoonBetaChallenge.bind(null, isDev); 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 { connect } from 'react-redux';
import { Button, Modal } from 'react-bootstrap'; import { Button, Modal } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component'; 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 { Col, Row } from 'react-bootstrap';
import ns from './ns.json'; 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'; 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 PureComponent from 'react-pure-render/component';
import { Grid, Col, Row } from 'react-bootstrap'; import { Grid, Col, Row } from 'react-bootstrap';

View File

@ -1,5 +1,6 @@
import noop from 'lodash/noop'; 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 { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Button, Modal } from 'react-bootstrap'; 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 NoSSR from 'react-no-ssr';
import Codemirror from 'react-codemirror'; 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 { HelpBlock, FormGroup, FormControl } from 'react-bootstrap';
import { getValidationState, DOMOnlyProps } from '../../utils/form'; 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 classnames from 'classnames';
import { Col, Row } from 'react-bootstrap'; 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 { ofType } from 'redux-epic';
import { import {
types, types,
closeBugModal, closeBugModal
filesSelector
} from '../redux'; } from '../redux';
import { filesSelector } from '../../../files';
import { currentChallengeSelector } from '../../../redux'; import { currentChallengeSelector } from '../../../redux';
function filesToMarkdown(files = {}) { function filesToMarkdown(files = {}) {

View File

@ -1,13 +1,14 @@
import debug from 'debug'; import debug from 'debug';
import { Observable } from 'rx'; import { Observable } from 'rx';
import { combineEpics, ofType } from 'redux-epic'; import { combineEpics, ofType } from 'redux-epic';
import { push } from 'react-router-redux';
import { import {
types, types,
updateMain, challengeUpdated,
challengeUpdated onRouteChallenges,
onRouteCurrentChallenge,
updateMain
} from './'; } from './';
import { getNS as entitiesSelector } from '../../../entities'; import { getNS as entitiesSelector } from '../../../entities';
import { import {
@ -16,40 +17,42 @@ import {
getFirstChallengeOfNextSuperBlock getFirstChallengeOfNextSuperBlock
} from '../utils'; } from '../utils';
import { import {
types as app,
createErrorObservable, createErrorObservable,
updateCurrentChallenge,
currentChallengeSelector, currentChallengeSelector,
challengeSelector, challengeSelector,
superBlocksSelector superBlocksSelector
} from '../../../redux'; } from '../../../redux';
import { langSelector } from '../../../Router/redux';
import { makeToast } from '../../../Toasts/redux'; import { makeToast } from '../../../Toasts/redux';
const isDev = debug.enabled('fcc:*'); const isDev = debug.enabled('fcc:*');
// When we change challenge, update the current challenge
// UI data.
export function challengeUpdatedEpic(actions, { getState }) { export function challengeUpdatedEpic(actions, { getState }) {
return actions::ofType(app.updateCurrentChallenge) return actions::ofType(types.onRouteChallenges)
.flatMap(() => { // prevent subsequent onRouteChallenges to cause UI to refresh
const challenge = challengeSelector(getState()); .distinctUntilChanged(({ payload: { dashedName }}) => dashedName)
return Observable.of( .map(() => challengeSelector(getState()))
challengeUpdated(challenge), // if the challenge isn't loaded in the current state,
push(`/challenges/${challenge.block}/${challenge.dashedName}`) // 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 // used to reset users code on request
export function resetChallengeEpic(actions, { getState }) { export function resetChallengeEpic(actions, { getState }) {
return actions::ofType(types.resetChallenge) return actions::ofType(types.clickOnReset)
.flatMap(() => { .flatMap(() =>
const currentChallenge = currentChallengeSelector(getState()); Observable.of(
return Observable.of( challengeUpdated(challengeSelector(getState())),
updateCurrentChallenge(currentChallenge),
updateMain() updateMain()
); ));
});
} }
export function nextChallengeEpic(actions, { getState }) { export function nextChallengeEpic(actions, { getState }) {
@ -64,6 +67,7 @@ export function nextChallengeEpic(actions, { getState }) {
const superBlocks = superBlocksSelector(state); const superBlocks = superBlocksSelector(state);
const challenge = currentChallengeSelector(state); const challenge = currentChallengeSelector(state);
const entities = entitiesSelector(state); const entities = entitiesSelector(state);
const lang = langSelector(state);
nextChallenge = getNextChallenge(challenge, entities, { isDev }); nextChallenge = getNextChallenge(challenge, entities, { isDev });
// block completed. // block completed.
if (!nextChallenge) { if (!nextChallenge) {
@ -107,11 +111,15 @@ export function nextChallengeEpic(actions, { getState }) {
'that have not been passed yet. ', 'that have not been passed yet. ',
timeout: 15000 timeout: 15000
}), }),
push('/map') onRouteCurrentChallenge()
); );
} }
return Observable.of( 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.' }) makeToast({ message: 'Your next challenge has arrived.' })
); );
} catch (err) { } catch (err) {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,4 @@
import flow from 'lodash/flow';
import * as challengeTypes from '../../utils/challengeTypes'; import * as challengeTypes from '../../utils/challengeTypes';
import { decodeScriptTags } from '../../../utils/encode-decode';
// determine the component to view for each challenge // determine the component to view for each challenge
export const viewTypes = { export const viewTypes = {
@ -43,36 +41,6 @@ export const submitTypes = {
// has html that should be rendered // has html that should be rendered
export const descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/; 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 = [] }) { export function createTests({ tests = [] }) {
return tests return tests
.map(test => { .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 { createSelector } from 'reselect';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import { import {

View File

@ -3,15 +3,19 @@ import React from 'react';
import BackEnd from './Back-End.jsx'; import BackEnd from './Back-End.jsx';
import { types } from '../../redux'; import { types } from '../../redux';
import Panes from '../../../../Panes'; import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map'; import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx'; import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {}; const propTypes = {};
export const panesMap = { export const panesMap = createPaneMap(
'backend',
() => ({
[types.toggleMap]: 'Map', [types.toggleMap]: 'Map',
[types.toggleMain]: 'Main' [types.toggleMain]: 'Main'
}; })
);
const nameToComponentDef = { const nameToComponentDef = {
Map: { 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 { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
@ -12,10 +13,11 @@ import {
executeChallenge, executeChallenge,
classicEditorUpdated, classicEditorUpdated,
challengeMetaSelector, challengeMetaSelector,
filesSelector,
keySelector keySelector
} from '../../redux'; } from '../../redux';
import { filesSelector } from '../../../../files';
const envProps = typeof window !== 'undefined' ? Object.keys(window) : []; const envProps = typeof window !== 'undefined' ? Object.keys(window) : [];
const options = { const options = {
lint: { lint: {

View File

@ -3,19 +3,26 @@ import React from 'react';
import SidePanel from './Side-Panel.jsx'; import SidePanel from './Side-Panel.jsx';
import Editor from './Editor.jsx'; import Editor from './Editor.jsx';
import Preview from './Preview.jsx'; import Preview from './Preview.jsx';
import { types } from '../../redux'; import { types, challengeMetaSelector } from '../../redux';
import Panes from '../../../../Panes'; import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map'; import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx'; import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {}; const propTypes = {};
export const panesMap = { export const panesMap = createPaneMap(
'classic',
() => ({
[types.toggleMap]: 'Map', [types.toggleMap]: 'Map',
[types.toggleSidePanel]: 'Side Panel', [types.toggleSidePanel]: 'Side Panel',
[types.toggleClassicEditor]: 'Editor', [types.toggleClassicEditor]: 'Editor',
[types.togglePreview]: 'Preview' [types.togglePreview]: {
}; name: 'Preview',
filter: state => !!challengeMetaSelector(state).showPreview
}
})
);
const nameToComponent = { const nameToComponent = {
Map: { 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 ReactDom from 'react-dom';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -46,10 +47,7 @@ const mapStateToProps = createSelector(
codeLockedSelector, codeLockedSelector,
chatRoomSelector, chatRoomSelector,
( (
{ { description },
description,
hints = []
},
{ title }, { title },
tests, tests,
output, output,
@ -61,7 +59,6 @@ const mapStateToProps = createSelector(
description, description,
tests, tests,
output, output,
hint: hints[hintIndex],
isCodeLocked, isCodeLocked,
helpChatRoom 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 { Button, ButtonGroup, Tooltip, OverlayTrigger } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
@ -39,7 +40,7 @@ export default class ToolPanel extends PureComponent {
this.props.makeToast({ this.props.makeToast({
message: 'This will restore your code editor to its original state.', message: 'This will restore your code editor to its original state.',
action: 'clear my code', action: 'clear my code',
actionCreator: 'resetChallenge', actionCreator: 'clickOnReset',
timeout: 4000 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 { reduxForm } from 'redux-form';
import { import {
Button, 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 { createSelector } from 'reselect';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';

View File

@ -1,16 +1,21 @@
import React from 'react'; import React from 'react';
import ns from './ns.json';
import Main from './Project.jsx'; import Main from './Project.jsx';
import { types } from '../../redux'; import { types } from '../../redux';
import Panes from '../../../../Panes'; import Panes from '../../../../Panes';
import { createPaneMap } from '../../../../Panes/redux';
import _Map from '../../../../Map'; import _Map from '../../../../Map';
import ChildContainer from '../../../../Child-Container.jsx'; import ChildContainer from '../../../../Child-Container.jsx';
const propTypes = {}; const propTypes = {};
export const panesMap = { export const panesMap = createPaneMap(
ns,
() => ({
[types.toggleMap]: 'Map', [types.toggleMap]: 'Map',
[types.toggleMain]: 'Main' [types.toggleMain]: 'Main'
}; })
);
const nameToComponent = { const nameToComponent = {
Map: { 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'; import ChallengeTitle from '../../Challenge-Title.jsx';
const propTypes = { 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 PureComponent from 'react-pure-render/component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';

View File

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

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 { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import classnames from 'classnames'; 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 { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';

View File

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

View File

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

View File

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

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