Feat: Welcome Page (#17150)

This commit is contained in:
Stuart Taylor
2018-05-15 06:12:05 +01:00
committed by mrugesh mohapatra
parent 47bb4ca5e3
commit 156ea1af76
172 changed files with 693 additions and 9738 deletions

View File

@ -4,20 +4,13 @@ import Rx from 'rx';
import debug from 'debug';
import { render } from 'redux-epic';
import createHistory from 'history/createBrowserHistory';
import useLangRoutes from './utils/use-lang-routes';
import sendPageAnalytics from './utils/send-page-analytics';
import { App, createApp, provideStore } from '../common/app';
import { getLangFromPath } from '../common/app/utils/lang';
// client specific epics
import epics from './epics';
import {
isColdStored,
getColdStorage,
saveToColdStorage
} from './cold-reload';
const {
__OPBEAT__ORG_ID,
@ -47,7 +40,7 @@ const {
document,
ga,
__fcc__: {
data: ssrState = {},
data: defaultState = {},
csrf: {
token: csrfToken
} = {}
@ -63,10 +56,6 @@ const epicOptions = {
const DOMContainer = document.getElementById('fcc');
const defaultState = isColdStored() ?
getColdStorage() :
ssrState;
const primaryLang = getLangFromPath(location.pathname);
defaultState.app.csrfToken = csrfToken;
@ -76,7 +65,7 @@ const serviceOptions = {
xhrTimeout: 15000
};
const history = useLangRoutes(createHistory, primaryLang)();
const history = createHistory();
sendPageAnalytics(history, ga);
createApp({
@ -88,14 +77,13 @@ createApp({
enhancers: isDev && devToolsExtension && [ devToolsExtension() ],
middlewares: enableOpbeat && [ createOpbeatMiddleware() ]
})
.doOnNext(({ store }) => {
.doOnNext(() => {
if (module.hot && typeof module.hot.accept === 'function') {
module.hot.accept(() => {
// note(berks): not sure this ever runs anymore after adding
// RHR?
log('saving state and refreshing.');
log('ignore react ssr warning.');
saveToColdStorage(store.getState());
setTimeout(() => location.reload(), hotReloadTimeout);
});
}

View File

@ -152,6 +152,24 @@ h1, h2, h3, h4, h5, h6, p, li {
width: 100px;
}
p.stats {
font-size: 2rem;
}
.green-text {
color: #006400;
}
.more-button-container {
display: flex;
justify-content: center;
align-items: center;
div {
flex-grow: 1;
}
}
.completion-icon {
font-size: 150px;
}

View File

@ -1,44 +0,0 @@
import { addLang, getLangFromPath } from '../../common/app/utils/lang.js';
function addLangToLocation(location, lang) {
if (!location) {
return location;
}
if (typeof location === 'string') {
return addLang(location, lang);
}
return {
...location,
pathname: addLang(location.pathname, lang)
};
}
function getLangFromLocation(location) {
if (!location) {
return location;
}
if (typeof location === 'string') {
return getLangFromPath(location);
}
return getLangFromPath(location.pathname);
}
export default function useLangRoutes(createHistory, primaryLang) {
return (options = {}) => {
let lang = primaryLang || 'en';
const history = createHistory(options);
const unsubscribeFromHistory = history.listen(nextLocation => {
lang = getLangFromLocation(nextLocation);
});
const push = location => history.push(addLangToLocation(location, lang));
const replace = location => history.replace(
addLangToLocation(location, lang)
);
return {
...history,
push,
replace,
unsubscribe() { unsubscribeFromHistory(); }
};
};
}

View File

@ -10,17 +10,19 @@ import {
isSignedInSelector
} from './redux';
import { fetchMapUi } from './Map/redux';
import Flash from './Flash';
import Nav from './Nav';
import Toasts from './Toasts';
import NotFound from './NotFound';
import { mainRouteSelector } from './routes/redux';
import Challenges from './routes/Challenges';
import Profile from './routes/Profile';
import Settings from './routes/Settings';
const mapDispatchToProps = {
appMounted,
fetchMapUi,
fetchUser
};
@ -37,6 +39,7 @@ const mapStateToProps = state => {
const propTypes = {
appMounted: PropTypes.func.isRequired,
children: PropTypes.node,
fetchMapUi: PropTypes.func.isRequired,
fetchUser: PropTypes.func,
isSignedIn: PropTypes.bool,
route: PropTypes.string,
@ -44,7 +47,6 @@ const propTypes = {
};
const routes = {
challenges: Challenges,
profile: Profile,
settings: Settings
};
@ -53,6 +55,7 @@ const routes = {
export class FreeCodeCamp extends React.Component {
componentDidMount() {
this.props.appMounted();
this.props.fetchMapUi();
if (!this.props.isSignedIn) {
this.props.fetchUser();
}

View File

@ -1,105 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FA from 'react-fontawesome';
import { Panel } from 'react-bootstrap';
import ns from './ns.json';
import Challenges from './Challenges.jsx';
import {
toggleThisPanel,
makePanelOpenSelector
} from './redux';
import { fetchNewBlock } from '../redux';
import { makeBlockSelector } from '../entities';
const mapDispatchToProps = {
fetchNewBlock,
toggleThisPanel
};
function makeMapStateToProps(_, { dashedName }) {
return createSelector(
makeBlockSelector(dashedName),
makePanelOpenSelector(dashedName),
(block, isOpen) => {
return {
isOpen,
dashedName,
title: block.title,
time: block.time,
challenges: block.challenges || []
};
}
);
}
const propTypes = {
challenges: PropTypes.array,
dashedName: PropTypes.string,
fetchNewBlock: PropTypes.func.isRequired,
isOpen: PropTypes.bool,
time: PropTypes.string,
title: PropTypes.string,
toggleThisPanel: PropTypes.func
};
export class Block extends PureComponent {
constructor(...props) {
super(...props);
this.handleSelect = this.handleSelect.bind(this);
}
handleSelect(eventKey, e) {
e.preventDefault();
this.props.toggleThisPanel(eventKey);
}
renderHeader(isOpen, title, time, isCompleted) {
return (
<div className={ isCompleted ? 'faded' : '' }>
<FA
className='map-caret'
name={ isOpen ? 'caret-down' : 'caret-right' }
size='lg'
/>
<span>
{ title }
</span>
{
time && <span className={ `${ns}-block-time` }>({ time })</span>
}
</div>
);
}
render() {
const {
title,
time,
dashedName,
isOpen,
challenges,
fetchNewBlock
} = this.props;
return (
<Panel
bsClass={ `${ns}-accordion-panel` }
collapsible={ true }
eventKey={ dashedName || title }
expanded={ isOpen }
header={ this.renderHeader(isOpen, title, time) }
id={ title }
key={ title }
onClick={ () => fetchNewBlock(dashedName) }
onSelect={ this.handleSelect }
>
{ isOpen && <Challenges challenges={ challenges } /> }
</Panel>
);
}
}
Block.displayName = 'Block';
Block.propTypes = propTypes;
export default connect(makeMapStateToProps, mapDispatchToProps)(Block);

View File

@ -1,39 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ns from './ns.json';
import Block from './Block.jsx';
const propTypes = {
blocks: PropTypes.array.isRequired
};
export default class Blocks extends Component {
shouldComponentUpdate(nextProps) {
return this.props.blocks !== nextProps.blocks;
}
render() {
const {
blocks
} = this.props;
if (blocks.length <= 0) {
return null;
}
return (
<div className={ `${ns}-accordion-block` }>
{
blocks.map(dashedName => (
<Block
dashedName={ dashedName }
key={ dashedName }
/>
))
}
</div>
);
}
}
Blocks.displayName = 'Blocks';
Blocks.propTypes = propTypes;

View File

@ -1,155 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import classnames from 'classnames';
import debug from 'debug';
import { clickOnChallenge } from './redux';
import { userSelector } from '../redux';
import { paramsSelector } from '../Router/redux';
import { challengeMapSelector } from '../entities';
import { Link } from '../Router';
import { onRouteChallenges } from '../routes/Challenges/redux';
const propTypes = {
block: PropTypes.string,
challenge: PropTypes.object,
clickOnChallenge: PropTypes.func.isRequired,
dashedName: PropTypes.string,
isComingSoon: PropTypes.bool,
isCompleted: PropTypes.bool,
isDev: PropTypes.bool,
isLocked: PropTypes.bool,
selected: PropTypes.bool,
title: PropTypes.string
};
const mapDispatchToProps = { clickOnChallenge };
function makeMapStateToProps(_, { dashedName }) {
return createSelector(
userSelector,
challengeMapSelector,
paramsSelector,
(
{ challengeMap: userChallengeMap },
challengeMap,
params
) => {
const {
id,
title,
block,
isLocked,
isComingSoon
} = challengeMap[dashedName] || {};
const isCompleted = userChallengeMap ? !!userChallengeMap[id] : false;
const selected = dashedName === params.dashedName;
return {
dashedName,
isCompleted,
title,
block,
isLocked,
isComingSoon,
isDev: debug.enabled('fcc:*'),
selected
};
}
);
}
export class Challenge extends PureComponent {
renderCompleted(isCompleted, isLocked) {
if (isLocked || !isCompleted) {
return null;
}
return <span className='sr-only'>completed</span>;
}
renderComingSoon(isComingSoon) {
if (!isComingSoon) {
return null;
}
return (
<span className='text-info small'>
&thinsp; &thinsp;
<strong>
<em>Coming Soon</em>
</strong>
</span>
);
}
renderLocked(title, isComingSoon, className) {
return (
<p
className={ className }
key={ title }
>
{ title }
{ this.renderComingSoon(isComingSoon) }
</p>
);
}
render() {
const {
block,
clickOnChallenge,
dashedName,
isComingSoon,
isCompleted,
isDev,
isLocked,
title,
selected
} = this.props;
if (!title) {
return null;
}
const challengeClassName = classnames({
'text-primary': true,
'padded-ionic-icon': true,
'map-challenge-title': true,
'ion-checkmark-circled faded': !(isLocked || isComingSoon) && isCompleted,
'ion-ios-circle-outline': !(isLocked || isComingSoon) && !isCompleted,
'ion-locked': isLocked || isComingSoon,
disabled: isLocked || (!isDev && isComingSoon),
selectedChallenge: selected
});
if (isLocked || (!isDev && isComingSoon)) {
return this.renderLocked(
title,
isComingSoon,
challengeClassName
);
}
return (
<div
className={ challengeClassName }
data-challenge={dashedName}
key={ title }
>
<Link
onClick={ clickOnChallenge }
to={ onRouteChallenges({ dashedName, block }) }
>
<span >
{ title }
{ this.renderCompleted(isCompleted, isLocked) }
</span>
</Link>
</div>
);
}
}
Challenge.propTypes = propTypes;
Challenge.dispalyName = 'Challenge';
export default connect(
makeMapStateToProps,
mapDispatchToProps
)(Challenge);

View File

@ -1,35 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Challenge from './Challenge.jsx';
const propTypes = {
challenges: PropTypes.array.isRequired
};
export default class Challenges extends Component {
shouldComponentUpdate(nextProps) {
return this.props.challenges !== nextProps.challenges;
}
render() {
const { challenges } = this.props;
if (!challenges.length) {
return <div>No Challenges Found</div>;
}
return (
<div>
{
challenges.map(dashedName => (
<Challenge
dashedName={ dashedName }
key={ dashedName }
/>
))
}
</div>
);
}
}
Challenges.displayName = 'Challenges';
Challenges.propTypes = propTypes;

View File

@ -1,127 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Col, Row } from 'react-bootstrap';
import ns from './ns.json';
import { Loader } from '../helperComponents';
import SuperBlock from './Super-Block.jsx';
import { currentChallengeSelector, superBlocksSelector } from '../redux';
import { fetchMapUi } from './redux';
const mapStateToProps = state => ({
currentChallenge: currentChallengeSelector(state),
superBlocks: superBlocksSelector(state)
});
const mapDispatchToProps = { fetchMapUi };
const propTypes = {
currentChallenge: PropTypes.string,
fetchMapUi: PropTypes.func.isRequired,
params: PropTypes.object,
superBlocks: PropTypes.array
};
export class ShowMap extends PureComponent {
componentDidMount() {
this.setupMapScroll();
}
componentDidUpdate() {
this.setupMapScroll();
}
setupMapScroll() {
this.updateMapScrollAttempts = 0;
this.updateMapScroll();
}
updateMapScroll() {
const { currentChallenge } = this.props;
const rowNode = this._row;
const challengeNode = rowNode.querySelector(
`[data-challenge="${currentChallenge}"]`
);
if ( !challengeNode ) {
this.retryUpdateMapScroll();
return;
}
const containerScrollHeight = rowNode.scrollHeight;
const containerHeight = rowNode.clientHeight;
const offset = 100;
const itemTop = challengeNode.offsetTop;
const itemBottom = itemTop + challengeNode.clientHeight;
const currentViewBottom = rowNode.scrollTop + containerHeight;
if ( itemBottom + offset < currentViewBottom ) {
// item is visible with enough offset from bottom => no need to scroll
return;
}
if ( containerHeight === containerScrollHeight ) {
/*
* During a first run containerNode scrollHeight may be not updated yet.
* In this case containerNode ignores changes of scrollTop property.
* So we have to wait some time before scrollTop can be updated
* */
this.retryUpdateMapScroll();
return;
}
const scrollTop = itemBottom + offset - containerHeight;
rowNode.scrollTop = scrollTop;
}
retryUpdateMapScroll() {
const maxAttempts = 5;
this.updateMapScrollAttempts++;
if (this.updateMapScrollAttempts < maxAttempts) {
setTimeout(() => this.updateMapScroll(), 300);
}
}
renderSuperBlocks() {
const { superBlocks } = this.props;
if (!Array.isArray(superBlocks) || !superBlocks.length) {
return (
<div style={{ height: '300px' }}>
<Loader />
</div>
);
}
return superBlocks.map(dashedName => (
<SuperBlock
dashedName={ dashedName }
key={ dashedName }
/>
));
}
render() {
return (
<div className = { `${ns}-container`} ref={ ref => { this._row = ref; }}>
<Row>
<Col xs={ 12 }>
<div className={ `${ns}-accordion center-block` }>
{ this.renderSuperBlocks() }
<div className='spacer' />
</div>
</Col>
</Row>
</div>
);
}
}
ShowMap.displayName = 'Map';
ShowMap.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(ShowMap);

View File

@ -1,96 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FA from 'react-fontawesome';
import { Panel } from 'react-bootstrap';
import ns from './ns.json';
import Blocks from './Blocks.jsx';
import {
toggleThisPanel,
makePanelOpenSelector
} from './redux';
import { makeSuperBlockSelector } from '../entities';
const mapDispatchToProps = { toggleThisPanel };
// make selectors unique to each component
// see
// reactjs/reselect
// sharing-selectors-with-props-across-multiple-components
function mapStateToProps(_, { dashedName }) {
return createSelector(
makeSuperBlockSelector(dashedName),
makePanelOpenSelector(dashedName),
(superBlock, isOpen) => ({
isOpen,
dashedName,
title: superBlock.title || dashedName,
blocks: superBlock.blocks || []
})
);
}
const propTypes = {
blocks: PropTypes.array,
dashedName: PropTypes.string,
isOpen: PropTypes.bool,
title: PropTypes.string,
toggleThisPanel: PropTypes.func
};
export class SuperBlock extends PureComponent {
constructor(...props) {
super(...props);
this.handleSelect = this.handleSelect.bind(this);
}
handleSelect(eventKey, e) {
e.preventDefault();
this.props.toggleThisPanel(eventKey);
}
renderHeader(isOpen, title, isCompleted) {
return (
<div className={ isCompleted ? 'faded' : '' }>
<FA
className={ `${ns}-caret` }
name={ isOpen ? 'caret-down' : 'caret-right' }
size='lg'
/>
{ title }
</div>
);
}
render() {
const {
title,
dashedName,
blocks,
isOpen
} = this.props;
return (
<Panel
bsClass={ `${ns}-accordion-panel` }
collapsible={ true }
eventKey={ dashedName || title }
expanded={ isOpen }
header={ this.renderHeader(isOpen, title) }
id={ title }
key={ dashedName || title }
onSelect={ this.handleSelect }
>
<Blocks blocks={ blocks } />
</Panel>
);
}
}
SuperBlock.displayName = 'SuperBlock';
SuperBlock.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(SuperBlock);

View File

@ -1 +0,0 @@
export default from './Map.jsx';

View File

@ -1,144 +0,0 @@
// should be the same as the filename and ./ns.json
@ns: map;
.@{ns}-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow-x: hidden;
overflow-y: auto;
}
.@{ns}-accordion {
max-width: 700px;
overflow-y: auto;
width: 100%;
a:focus {
text-decoration: none;
color:darkgreen;
}
a:focus:hover {
text-decoration: underline;
color:#001800;
}
@media (max-width: 720px) {
left: 0;
right: 0;
width: 100%;
top: 195px;
bottom: 0;
// position:absolute;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
h2 {
margin:15px 0;
padding:0;
&:first-of-type {
margin-top:0;
}
> a {
padding: 10px 0;
padding-left: 50px;
padding-right: 20px;
font-size: 20px;
}
}
a {
margin: 10px 0;
padding: 0;
> h3 {
clear:both;
font-size:20px;
}
}
}
.@{ns}-caret {
color: @gray-dark;
text-decoration: none;
// make sure all carats are fixed width
width: 10px;
}
}
.@{ns}-accordion-block .@{ns}-accordion-panel-heading {
padding-left: 26px;
}
.@{ns}-challenge-title, .@{ns}-accordion-panel-heading {
background: @body-bg;
line-height: 26px;
padding: 2px 12px 2px 10px;
width:100%;
}
.@{ns}-challenge-title {
&::before {
padding-right: 6px;
}
padding-left: 40px;
a {
cursor: pointer;
}
@media (min-width: 721px) {
clear: both;
}
}
.@{ns}-accordion-panel-heading a {
cursor: pointer;
display: block;
}
.@{ns}-accordion-panel-collapse {
transition: height 0.001s;
}
.@{ns}-accordion-panel-title {
@media (min-width: 721px) {
clear: both;
}
}
.@{ns}-block-time {
color: #555555;
@media (min-width: 721px) {
float: right;
}
}
.@{ns}-block-description {
margin:0;
margin-top:-10px;
padding:0 15px 23px 30px;
}
.night {
.@{ns}-accordion .no-link-underline {
color: @brand-primary;
}
.@{ns}-accordion h2 > a {
background: #666;
}
.@{ns}-accordion a:focus, #noneFound {
color: #ABABAB;
}
.@{ns}-challenge-title, .@{ns}-accordion-panel-heading {
background: @night-body-bg;
color: @night-text-color;
a {
color: @night-text-color;
}
}
.@{ns}-caret {
color: @night-text-color;
}
}

View File

@ -1 +0,0 @@
"map"

View File

@ -3,38 +3,34 @@ import debug from 'debug';
import {
types as appTypes,
createErrorObservable,
currentChallengeSelector
createErrorObservable
} from '../../redux';
import { types, fetchMapUiComplete } from './';
import { langSelector } from '../../Router/redux';
import { shapeChallenges } from '../../redux/utils';
const isDev = debug.enabled('fcc:*');
export default function fetchMapUiEpic(
actions,
{ getState },
_,
{ services }
) {
return actions::ofType(
return actions.do(console.log)::ofType(
appTypes.appMounted,
types.fetchMapUi.start
)
.flatMapLatest(() => {
const lang = langSelector(getState());
const options = {
params: { lang },
service: 'map-ui'
};
return services.readService$(options)
.retry(3)
.do(console.info)
.map(({ entities, ...res }) => ({
entities: shapeChallenges(
entities,
isDev
),
initialNode: currentChallengeSelector(getState()),
...res
}))
.map(fetchMapUiComplete)

View File

@ -8,13 +8,14 @@ import { createSelector } from 'reselect';
import { capitalize, noop } from 'lodash';
import * as utils from './utils.js';
import ns from '../ns.json';
import {
createEventMetaCreator
} from '../../redux';
import fetchMapUiEpic from './fetch-map-ui-epic';
const ns = 'map';
export const epics = [ fetchMapUiEpic ];
export const types = createTypes([

View File

@ -2,11 +2,11 @@ import React from 'react';
import Media from 'react-media';
import { Col, Navbar, Row } from 'react-bootstrap';
import FCCSearchBar from 'react-freecodecamp-search';
import { NavLogo, BinButtons, NavLinks } from './components';
import { NavLogo, NavLinks } from './components';
import propTypes from './navPropTypes';
function LargeNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
function LargeNav({ clickOnLogo }) {
return (
<Media
query='(min-width: 956px)'
@ -19,15 +19,10 @@ function LargeNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
<FCCSearchBar />
</Navbar.Header>
</Col>
<Col className='nav-component bins' sm={ 3 } xs={ 6 }>
<BinButtons panes={ panes } />
</Col>
<Col className='nav-component bins' sm={ 3 } xs={ 6 }/>
<Col className='nav-component nav-links' sm={ 4 } xs={ 0 }>
<Navbar.Collapse>
<NavLinks
clickOnMap={ clickOnMap }
shouldShowMapButton={ shouldShowMapButton }
/>
<NavLinks />
</Navbar.Collapse>
</Col>
</Row>

View File

@ -2,11 +2,11 @@ import React from 'react';
import Media from 'react-media';
import { Navbar, Row } from 'react-bootstrap';
import FCCSearchBar from 'react-freecodecamp-search';
import { NavLogo, BinButtons, NavLinks } from './components';
import { NavLogo, NavLinks } from './components';
import propTypes from './navPropTypes';
function MediumNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
function MediumNav({ clickOnLogo }) {
return (
<Media
query={{ maxWidth: 955, minWidth: 751 }}
@ -21,17 +21,12 @@ function MediumNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
<NavLogo clickOnLogo={ clickOnLogo } />
<FCCSearchBar />
</div>
<div className='nav-component bins'>
<BinButtons panes={ panes } />
</div>
<div className='nav-component bins'/>
</Navbar.Header>
</Row>
<Row className='collapse-row'>
<Navbar.Collapse>
<NavLinks
clickOnMap={ clickOnMap }
shouldShowMapButton={ shouldShowMapButton }
/>
<NavLinks />
</Navbar.Collapse>
</Row>
</div>

View File

@ -1,67 +1,25 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Navbar } from 'react-bootstrap';
import LargeNav from './LargeNav.jsx';
import MediumNav from './MediumNav.jsx';
import SmallNav from './SmallNav.jsx';
import {
clickOnLogo,
clickOnMap
clickOnLogo
} from './redux';
import { panesSelector, panesByNameSelector } from '../Panes/redux';
import propTypes from './navPropTypes';
const mapStateToProps = createSelector(
panesSelector,
panesByNameSelector,
(panes, panesByName) => {
return {
panes: panes.map(({ name, type }) => {
return {
content: name,
action: type,
isHidden: panesByName[name].isHidden
};
}, {}),
shouldShowMapButton: panes.length === 0
};
}
);
const mapStateToProps = () => ({});
function mapDispatchToProps(dispatch) {
const dispatchers = bindActionCreators(
return bindActionCreators(
{
clickOnMap: e => {
e.preventDefault();
return clickOnMap();
},
clickOnLogo: e => {
e.preventDefault();
return clickOnLogo();
}
clickOnLogo
},
dispatch
);
dispatchers.dispatch = dispatch;
return () => dispatchers;
}
function mergeProps(stateProps, dispatchProps, ownProps) {
const panes = stateProps.panes.map(pane => {
return {
...pane,
actionCreator: () => dispatchProps.dispatch({ type: pane.action })
};
});
return {
...ownProps,
...stateProps,
...dispatchProps,
panes
};
}
const allNavs = [
@ -72,18 +30,12 @@ const allNavs = [
function FCCNav(props) {
const {
panes,
clickOnLogo,
clickOnMap,
shouldShowMapButton
clickOnLogo
} = props;
const withNavProps = Component => (
<Component
clickOnLogo={ clickOnLogo }
clickOnMap={ clickOnMap }
key={ Component.displayName }
panes={ panes }
shouldShowMapButton={ shouldShowMapButton }
/>
);
return (
@ -104,6 +56,5 @@ FCCNav.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps,
mergeProps
mapDispatchToProps
)(FCCNav);

View File

@ -2,11 +2,11 @@ import React from 'react';
import Media from 'react-media';
import { Navbar, Row } from 'react-bootstrap';
import FCCSearchBar from 'react-freecodecamp-search';
import { NavLogo, BinButtons, NavLinks } from './components';
import { NavLogo, NavLinks } from './components';
import propTypes from './navPropTypes';
function SmallNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
function SmallNav({ clickOnLogo }) {
return (
<Media
query='(max-width: 750px)'
@ -20,17 +20,12 @@ function SmallNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
<Navbar.Toggle />
<NavLogo clickOnLogo={ clickOnLogo } />
</div>
<div className='nav-component bins'>
<BinButtons panes={ panes } />
</div>
<div className='nav-component bins'/>
</Navbar.Header>
</Row>
<Row className='collapse-row'>
<Navbar.Collapse>
<NavLinks
clickOnMap={ clickOnMap }
shouldShowMapButton={ shouldShowMapButton }
>
<NavLinks>
<FCCSearchBar />
</NavLinks>
</Navbar.Collapse>

View File

@ -8,10 +8,8 @@ import { MenuItem, NavDropdown, NavItem, Nav } from 'react-bootstrap';
import navLinks from '../links.json';
import SignUp from './Sign-Up.jsx';
import NoPropsPassThrough from '../../utils/No-Props-Passthrough.jsx';
import { Link } from '../../Router';
import { onRouteCurrentChallenge } from '../../routes/Challenges/redux';
import {
openDropdown,
closeDropdown,
@ -58,14 +56,12 @@ const navLinkPropType = PropTypes.shape({
const propTypes = {
children: PropTypes.any,
clickOnMap: PropTypes.func.isRequired,
closeDropdown: PropTypes.func.isRequired,
isDropdownOpen: PropTypes.bool,
isInNav: PropTypes.bool,
isSignedIn: PropTypes.bool,
navLinks: PropTypes.arrayOf(navLinkPropType),
openDropdown: PropTypes.func.isRequired,
shouldShowMapButton: PropTypes.bool,
showLoading: PropTypes.bool
};
@ -125,8 +121,6 @@ class NavLinks extends PureComponent {
render() {
const {
shouldShowMapButton,
clickOnMap,
showLoading,
isSignedIn,
navLinks,
@ -136,20 +130,6 @@ class NavLinks extends PureComponent {
return (
<Nav id='nav-links' navbar={ true } pullRight={ true }>
{ children }
{
shouldShowMapButton ?
<NoPropsPassThrough>
<li>
<Link
onClick={ clickOnMap }
to={ onRouteCurrentChallenge() }
>
Map
</Link>
</li>
</NoPropsPassThrough> :
null
}
{
navLinks.map(
this.renderLink.bind(this, isInNav)

View File

@ -4,9 +4,7 @@ import { NavbarBrand } from 'react-bootstrap';
import Media from 'react-media';
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
// TODO @freecodecamp-team: place this glyph in S3 like above, PR in /assets
const fCCglyph = 'https://raw.githubusercontent.com/freeCodeCamp/assets/' +
'3b9cafc312802199ebba8b31fb1ed9b466a3efbb/assets/logos/FFCFire.png';
const fCCglyph = 'https://s3.amazonaws.com/freecodecamp/FFCFire.png';
const propTypes = {
clickOnLogo: PropTypes.func.isRequired
@ -16,7 +14,7 @@ function NavLogo({ clickOnLogo }) {
return (
<NavbarBrand>
<a
href='/challenges/current-challenge'
href='/'
onClick={ clickOnLogo }
>
<Media query='(min-width: 735px)'>

View File

@ -1,43 +1,12 @@
[
{
"content": "Community",
"isDropdown": true,
"links": [
{
"content": "Chat",
"link": "https://gitter.im/freecodecamp/home",
"content": "Learn",
"link": "https://learn.freecodecamp.org",
"target": "_blank"
},
{
"content": "Forum",
"link": "https://forum.freecodecamp.org/",
"target": "_blank"
},
{
"content": "Medium",
"link": "https://medium.freecodecamp.org",
"target": "_blank"
},
{
"content": "YouTube",
"link": "https://youtube.com/freecodecamp",
"target": "_blank"
},
{
"content": "In your city",
"link": "https://forum.freecodecamp.org/t/free-code-camp-city-based-local-groups/19574",
"target": "_blank"
},
{
"content": "About",
"link": "https://www.freecodecamp.org/about",
"target": "_blank"
}
]
},
{
"content": "Donate",
"link": "https://www.freecodecamp.org/donate",
"target": "_blank"
}
]

View File

@ -1,8 +1,5 @@
import PropTypes from 'prop-types';
export default {
clickOnLogo: PropTypes.func.isRequired,
clickOnMap: PropTypes.func.isRequired,
panes: PropTypes.array,
shouldShowMapButton: PropTypes.bool
clickOnLogo: PropTypes.func.isRequired
};

View File

@ -6,13 +6,10 @@ import {
handleActions
} from 'berkeleys-redux-utils';
import loadCurrentChallengeEpic from './load-current-challenge-epic.js';
import ns from '../ns.json';
import { createEventMetaCreator } from '../../analytics/index';
export const epics = [
loadCurrentChallengeEpic
];
export const epics = [];
export const types = createTypes([
'clickOnLogo',

View File

@ -1,58 +0,0 @@
import _ from 'lodash';
import { ofType } from 'redux-epic';
import { types } from './';
import {
userSelector,
firstChallengeSelector,
challengeSelector
} from '../../redux';
import { onRouteChallenges } from '../../routes/Challenges/redux';
import { entitiesSelector } from '../../entities';
import { langSelector, pathnameSelector } from '../../Router/redux';
export default function loadCurrentChallengeEpic(actions, { getState }) {
return actions::ofType(types.clickOnLogo, types.clickOnMap)
.debounce(500)
.map(getState)
.map(state => {
let finalChallenge;
const lang = langSelector(state);
const { id: currentlyLoadedChallengeId } = challengeSelector(state);
const {
challenge: challengeMap,
challengeIdToName
} = entitiesSelector(state);
const pathname = pathnameSelector(state);
const firstChallenge = firstChallengeSelector(state);
const { currentChallengeId } = userSelector(state);
const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname);
if (!currentChallengeId) {
finalChallenge = firstChallenge;
} else {
finalChallenge = challengeMap[
challengeIdToName[ currentChallengeId ]
];
}
return {
..._.pick(finalChallenge, ['id', 'block', 'dashedName']),
lang,
isOnAChallenge,
currentlyLoadedChallengeId
};
})
.filter(({
id,
isOnAChallenge,
currentlyLoadedChallengeId
}) => (
// data might not be there yet, filter out for now
!!id &&
// are we already on that challenge? if not load challenge
(!isOnAChallenge || id !== currentlyLoadedChallengeId)
// don't reload if the challenge is already loaded.
// This may change to toast to avoid user confusion
))
.map(onRouteChallenges);
}

View File

@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { dividerClicked } from './redux';
const mapStateToProps = null;
function mapDispatchToProps(dispatch, { name }) {
const dispatchers = {
dividerClicked: () => dispatch(dividerClicked(name))
};
return () => dispatchers;
}
const propTypes = {
dividerClicked: PropTypes.func.isRequired,
left: PropTypes.number.isRequired,
name: PropTypes.string.isRequired
};
export function Divider({ left, dividerClicked }) {
const style = {
borderLeft: '1px solid rgb(204, 204, 204)',
bottom: 0,
cursor: 'col-resize',
height: '100%',
left: left + '%',
marginLeft: '0px',
position: 'absolute',
right: 'auto',
top: 0,
width: '8px',
zIndex: 100
};
// use onMouseDown as onClick does not fire
// until onMouseUp
// note(berks): do we need touch support?
return (
<div
onMouseDown={ dividerClicked }
style={ style }
/>
);
}
Divider.displayName = 'Divider';
Divider.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Divider);

View File

@ -1,41 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
const mapStateToProps = null;
const mapDispatchToProps = null;
const propTypes = {
children: PropTypes.element,
left: PropTypes.number.isRequired,
right: PropTypes.number.isRequired
};
export function Pane({
children,
left,
right
}) {
const style = {
bottom: 0,
left: left + '%',
overflowX: 'hidden',
overflowY: 'auto',
position: 'absolute',
right: right + '%',
top: 0,
paddingLeft: '0px',
paddingRight: '0px'
};
return (
<div style={ style }>
{ children }
</div>
);
}
Pane.displayName = 'Pane';
Pane.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Pane);

View File

@ -1,127 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import {
panesSelector,
panesByNameSelector,
panesMounted,
widthSelector
} from './redux';
import Pane from './Pane.jsx';
import Divider from './Divider.jsx';
import { fetchRandomQuote } from './quotes';
const mapStateToProps = createSelector(
panesSelector,
panesByNameSelector,
widthSelector,
(panes, panesByName) => {
let lastDividerPosition = 0;
return {
panes: panes
.map(({ name }) => panesByName[name])
.filter(({ isHidden })=> !isHidden)
.map((pane, index, { length: numOfPanes }) => {
const dividerLeft = pane.dividerLeft || 0;
const left = lastDividerPosition;
lastDividerPosition = dividerLeft;
return {
...pane,
left: index === 0 ? 0 : left,
right: index + 1 === numOfPanes ? 0 : 100 - dividerLeft
};
}, {})
};
}
);
const mapDispatchToProps = { panesMounted };
const propTypes = {
panes: PropTypes.array,
panesMounted: PropTypes.func.isRequired,
render: PropTypes.func.isRequired
};
let quoteObj = fetchRandomQuote();
export class Panes extends PureComponent {
componentDidMount() {
this.props.panesMounted();
}
componentDidUpdate(prevProps) {
if (prevProps.panes.length === 0 && this.props.panes.length === 1) {
const currentQuote = quoteObj.quote;
let newQuoteObj = fetchRandomQuote();
while (currentQuote === newQuoteObj.quote) {
newQuoteObj = fetchRandomQuote();
}
quoteObj = newQuoteObj;
}
}
renderPanes() {
const {
render,
panes
} = this.props;
if (panes.length > 0) {
return panes.map(({ name, left, right, dividerLeft }) => {
const divider = dividerLeft ?
(
<Divider
key={ name + 'divider' }
left={ dividerLeft }
name={ name }
/>
) :
null;
return [
<Pane
key={ name }
left={ left }
right={ right }
>
{ render(name) }
</Pane>,
divider
];
}).reduce((panes, pane) => panes.concat(pane), [])
.filter(Boolean);
} else {
return this.renderQuote();
}
}
renderQuote() {
return (
<div className='outer-flex-container'>
<div className='quote-container'>
<h2 className='quote-style'>&ldquo;{quoteObj.quote}&rdquo;</h2>
<h2 className='author-style'><i>&mdash;{quoteObj.author}</i></h2>
</div>
</div>
);
}
render() {
return (
<div className='outer-style'>
<div className='inner-style'>
{ this.renderPanes() }
</div>
</div>
);
}
}
Panes.displayName = 'Panes';
Panes.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Panes);

View File

@ -1 +0,0 @@
export default from './Panes.jsx';

View File

@ -1 +0,0 @@
"panes"

View File

@ -1,42 +0,0 @@
.outer-style {
position: relative;
height: 100%;
width: 100%;
}
.inner-style {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.outer-flex-container {
display: flex;
flex-direction: column;
height: 100%;
width: 40%;
margin: auto;
}
.quote-container {
color: #006400;
margin: auto;
}
.quote-style {
margin: auto;
line-height: 150%;
}
.author-style {
text-align: center;
margin: 10px 0 0 0;
}
.night {
.quote-container {
color: @night-text-color;
}
}

View File

@ -1,71 +0,0 @@
const quotes = [
{
quote: 'Never, never, never, never, never, never give up.',
author: 'Winston Churchill'
},
{
quote: 'The pessimist sees difficulty in every opportunity. ' +
'The optimist sees opportunity in every difficulty.',
author: 'Winston Churchill'
},
{
quote: 'Twenty years from now you will be more disappointed by the ' +
'things that you didn\'t do than by the ones you did do. So throw ' +
'off the bowlines. Sail away from the safe harbor. Catch the trade ' +
'winds in your sails.',
author: 'Mark Twain'
},
{
quote: 'The secret of getting ahead is getting started.',
author: 'Mark Twain'
},
{
quote: 'Change your life today. Dont gamble on the future, act now, ' +
'without delay.',
author: 'Simone de Beauvoir'
},
{
quote: 'A person who never made a mistake never tried anything new.',
author: 'Albert Einstein'
},
{
quote: 'Life is like riding a bicycle. To keep your balance, you must ' +
'keep moving.',
author: 'Albert Einstein'
},
{
quote: 'Nothing will work unless you do.',
author: 'Maya Angelou'
},
{
quote: 'The most difficult thing is the decision to act, the rest is ' +
'merely tenacity.',
author: 'Amelia Earhart'
},
{
quote: 'When you reach for the stars, you may not quite get them, but ' +
'you won\'t come up with a handful of mud, either.',
author: 'Leo Burnett'
},
{
quote: 'The only person you are destined to become is the person you ' +
'decide to be.',
author: 'Ralph Waldo Emerson'
},
{
quote: 'You must do the things you think you cannot do.',
author: 'Eleanor Roosevelt'
},
{
quote: 'You are never too old to set another goal or to dream a new dream.',
author: 'C.S. Lewis'
},
{
quote: 'Believe you can and you\'re halfway there.',
author: 'Theodore Roosevelt'
}
];
export function fetchRandomQuote() {
return quotes[Math.floor(Math.random() * quotes.length)];
}

View File

@ -1,40 +0,0 @@
import 'rx-dom';
import { Observable, Scheduler } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import {
types,
mouseReleased,
dividerMoved,
pressedDividerSelector
} from './';
export function dividerReleasedEpic(actions, _, { document }) {
return actions::ofType(types.dividerClicked)
.switchMap(() => Observable.fromEvent(document, 'mouseup')
.map(() => mouseReleased())
// allow mouse up on divider to go first
.delay(1)
.takeUntil(actions::ofType(types.mouseReleased))
);
}
export function dividerMovedEpic(actions, { getState }, { document }) {
return actions::ofType(types.dividerClicked)
.switchMap(() => Observable.fromEvent(document, 'mousemove')
// prevent mouse drags from highlighting text
.do(e => e.preventDefault())
.map(({ clientX }) => clientX)
.throttle(1, Scheduler.requestAnimationFrame)
.filter(() => {
const divider = pressedDividerSelector(getState());
return !!divider || divider === 0;
})
.map(dividerMoved)
.takeUntil(actions::ofType(types.mouseReleased))
);
}
export default combineEpics(dividerReleasedEpic, dividerMovedEpic);

View File

@ -1,213 +0,0 @@
import _ from 'lodash';
import {
composeReducers,
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import * as utils from './utils.js';
import windowEpic from './window-epic.js';
import dividerEpic from './divider-epic.js';
import ns from '../ns.json';
export const epics = [
windowEpic,
dividerEpic
];
export const types = createTypes([
'panesMapUpdated',
'panesMounted',
'panesUpdated',
'panesWillMount',
'panesWillUnmount',
'updateSize',
'dividerClicked',
'dividerMoved',
'mouseReleased',
'windowResized'
], ns);
export const panesMapUpdated = createAction(
types.panesMapUpdated,
null,
(type, panesMap) => ({ trigger: type, panesMap })
);
export const panesMounted = createAction(types.panesMounted);
export const panesUpdated = createAction(types.panesUpdated);
export const panesWillMount = createAction(types.panesWillMount);
export const panesWillUnmount = createAction(types.panesWillUnmount);
export const dividerClicked = createAction(types.dividerClicked);
export const dividerMoved = createAction(types.dividerMoved);
export const mouseReleased = createAction(types.mouseReleased);
export const windowResized = createAction(types.windowResized);
const defaultState = {
width: 800,
panes: [],
panesByName: {},
panesMap: {},
pressedDivider: null
};
export const getNS = state => state[ns];
export const panesSelector = state => getNS(state).panes;
export const panesByNameSelector = state => getNS(state).panesByName;
export const pressedDividerSelector =
state => getNS(state).pressedDivider;
export const widthSelector = state => getNS(state).width;
export const panesMapSelector = state => getNS(state).panesMap;
export default function createPanesAspects({ createPanesMap }) {
createPanesMap = utils.normalizePanesMapCreator(createPanesMap);
function middleware({ getState }) {
return next => action => {
let finalAction = action;
const panesMap = panesMapSelector(getState());
if (utils.isPanesAction(action, panesMap)) {
finalAction = {
...action,
meta: {
...action.meta,
isPaneAction: true,
paneName: panesMap[action.type]
}
};
}
const result = next(finalAction);
const nextPanesMap = createPanesMap(getState(), action);
if (nextPanesMap) {
utils.checkForTypeKeys(nextPanesMap);
next(panesMapUpdated(action.type, nextPanesMap));
}
return result;
};
}
const reducer = composeReducers(
ns,
handleActions(
() => ({
[types.dividerClicked]: (state, { payload: name }) => ({
...state,
pressedDivider: name
}),
[types.dividerMoved]: (state, { payload: clientX }) => {
const {
panes,
panesByName,
pressedDivider: paneName,
width
} = state;
const dividerBuffer = (200 / width) * 100;
const paneIndex =
_.findIndex(state.panes, ({ name }) => paneName === name);
const currentPane = panesByName[paneName];
const rightPane = utils.getPane(panesByName, panes, paneIndex + 1);
const leftPane = utils.getPane(panesByName, panes, paneIndex - 1);
const rightBound = utils.getRightBound(rightPane, dividerBuffer);
const leftBound = utils.getLeftBound(leftPane, dividerBuffer);
const newPosition = _.clamp(
(clientX / width) * 100,
leftBound,
rightBound
);
return {
...state,
panesByName: {
...state.panesByName,
[currentPane.name]: {
...currentPane,
dividerLeft: newPosition
}
}
};
},
[types.mouseReleased]: state => ({ ...state, pressedDivider: null }),
[types.windowResized]: (state, { payload: { width } }) => ({
...state,
width
}),
// used to clear bin buttons
[types.panesWillUnmount]: state => ({
...state,
panes: [],
panesByName: {},
pressedDivider: null
})
}),
defaultState
),
function metaReducer(state = defaultState, action) {
if (action.meta && action.meta.panesMap) {
const panesMap = action.meta.panesMap;
const panes = _.map(panesMap, (name, type) => ({ name, type }));
const numOfPanes = Object.keys(panes).length;
if (_.isEqual(state.panes, panes)) {
return state;
}
return {
...state,
panesMap,
panes,
panesByName: panes.reduce((panes, { name }, index) => {
const dividerLeft = utils.getDividerLeft(numOfPanes, index);
panes[name] = {
name,
dividerLeft,
isHidden: false
};
return panes;
}, {})
};
}
if (action.meta && action.meta.isPaneAction) {
const name = action.meta.paneName;
const oldPane = state.panesByName[name];
const pane = {
...oldPane,
isHidden: !oldPane.isHidden
};
const panesByName = {
...state.panesByName,
[name]: pane
};
const numOfPanes = state.panes.reduce((sum, { name }) => {
return panesByName[name].isHidden ? sum : sum + 1;
}, 0);
let numOfHidden = 0;
return {
...state,
panesByName: state.panes.reduce(
(panesByName, { name }, index) => {
if (!panesByName[name].isHidden) {
const dividerLeft = utils.getDividerLeft(
numOfPanes,
index - numOfHidden
);
panesByName[name] = {
...panesByName[name],
dividerLeft
};
} else {
numOfHidden = numOfHidden + 1;
}
return panesByName;
},
panesByName
)
};
}
return state;
}
);
return {
reducer,
middleware
};
}

View File

@ -1,62 +0,0 @@
import _ from 'lodash';
import invariant from 'invariant';
export function isPanesAction({ type } = {}, panesMap) {
return !!panesMap[type];
}
export function getDividerLeft(numOfPanes, index) {
let dividerLeft = null;
if (numOfPanes === 2 && numOfPanes !== index + 1) {
dividerLeft = 33;
} else if (numOfPanes > 1 && numOfPanes !== index + 1) {
dividerLeft = (100 / numOfPanes) * (index + 1);
}
return dividerLeft;
}
export function checkForTypeKeys(panesMap) {
_.forEach(panesMap, (_, actionType) => {
invariant(
actionType !== 'undefined',
`action type for ${panesMap[actionType]} is undefined`
);
});
return panesMap;
}
export const getPane = (panesByName, panes, index) => _.get(
panesByName,
getPaneName(panes, index),
null
);
export const getPaneName = (panes, index) => _.get(
panes,
[index, 'name'],
''
);
export const getRightBound = (pane, buffer) =>
((pane && !pane.isHidden && pane.dividerLeft) || 100) - buffer;
export const getLeftBound = (pane, buffer) =>
((pane && !pane.isHidden && pane.dividerLeft) || 0) + buffer;
export function normalizePanesMapCreator(createPanesMap) {
invariant(
_.isFunction(createPanesMap),
'createPanesMap should be a function but got %s',
createPanesMap
);
const panesMap = createPanesMap({}, { type: '@@panes/test' });
if (typeof panesMap === 'function') {
return normalizePanesMapCreator(panesMap);
}
invariant(
!panesMap,
'panesMap test should return undefined or null on test action but got %s',
panesMap
);
return createPanesMap;
}

View File

@ -1,19 +0,0 @@
import { Observable } from 'rx';
import { ofType } from 'redux-epic';
import {
types,
windowResized
} from './';
export default function windowEpic(actions, _, { window }) {
return actions::ofType(types.panesMounted)
.switchMap(() => {
return Observable.fromEvent(window, 'resize', () => windowResized({
width: window.innerWidth
}))
.startWith(windowResized({
width: window.innerWidth
}));
});
}

View File

@ -5,18 +5,16 @@ import { createSelector } from 'reselect';
import toUrl from './to-url.js';
import createHandler from './handle-press.js';
import { routesMapSelector, langSelector } from './redux';
import { routesMapSelector } from './redux';
const mapStateToProps = createSelector(
langSelector,
routesMapSelector,
(lang, routesMap) => ({ lang, routesMap })
routesMap => ({ routesMap })
);
const propTypes = {
children: PropTypes.node,
dispatch: PropTypes.func,
lang: PropTypes.string,
onClick: PropTypes.func,
redirect: PropTypes.bool,
replace: PropTypes.bool,
@ -31,7 +29,6 @@ export const Link = (
{
children,
dispatch,
lang,
onClick,
redirect,
replace,
@ -42,7 +39,7 @@ export const Link = (
to
}
) => {
const url = toUrl(to, routesMap, lang);
const url = toUrl(to, routesMap);
const handler = createHandler(
url,
routesMap,

View File

@ -1,28 +0,0 @@
import { langSelector } from './';
// This enhancers sole purpose is to add the lang prop to route actions so that
// they do not need to be explicitly 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.payload || !action.payload.lang)
) {
action = {
...action,
payload: {
...action.payload,
lang: langSelector(store.getState()) || 'en'
}
};
}
return store.dispatch(action);
}
};
};
}

View File

@ -3,7 +3,6 @@ import { selectLocationState } from 'redux-first-router';
export const paramsSelector = state => selectLocationState(state).payload || {};
export const locationTypeSelector =
state => selectLocationState(state).type || '';
export const langSelector = state => paramsSelector(state).lang || 'en';
export const routesMapSelector = state =>
selectLocationState(state).routesMap || {};
export const pathnameSelector = state => selectLocationState(state).pathname;

View File

@ -1,11 +1,8 @@
import { actionToPath, getOptions } from 'redux-first-router';
import { addLang } from '../utils/lang.js';
export default (to, routesMap, lang = 'en') => {
export default (to, routesMap) => {
if (to && typeof to === 'string') {
return addLang(to, lang);
return to;
}
if (typeof to === 'object') {
@ -17,8 +14,7 @@ export default (to, routesMap, lang = 'en') => {
{
...action,
payload: {
...payload,
lang: payload.lang || lang
...payload
}
},
routesMap,

View File

@ -5,15 +5,8 @@ import { createSelector } from 'reselect';
import { NotificationStack } from 'react-notification';
import { removeToast } from './redux';
import {
submitChallenge,
clickOnReset
} from '../routes/Challenges/redux';
const registeredActions = {
submitChallenge,
clickOnReset
};
const registeredActions = {};
const mapStateToProps = state => ({ toasts: state.toasts });
// we use styles here to overwrite those built into the library
// but there are some styles applied using

View File

@ -7,12 +7,8 @@ import { combineReducers } from 'berkeleys-redux-utils';
import { createEpic } from 'redux-epic';
import appReducer from './reducer.js';
import routesMap from './routes-map.js';
import createPanesMap from './create-panes-map.js';
import createPanesAspects from './Panes/redux';
import addLangToRoutesEnhancer from './Router/redux/add-lang-enhancer.js';
import epics from './epics';
import { onBeforeChange } from './utils/redux-first-router.js';
import servicesCreator from '../utils/services-creator';
const debug = createDebugger('fcc:app:createApp');
@ -50,21 +46,14 @@ export default function createApp({
reducer: routesReducer,
middleware: routesMiddleware,
enhancer: routesEnhancer
} = connectRoutes(history, routesMap, { onBeforeChange });
} = connectRoutes(history, routesMap);
routesReducer.toString = () => 'location';
const {
reducer: panesReducer,
middleware: panesMiddleware
} = createPanesAspects({ createPanesMap });
const enhancer = compose(
addLangToRoutesEnhancer(routesMap),
routesEnhancer,
applyMiddleware(
routesMiddleware,
panesMiddleware,
epicMiddleware,
...sideMiddlewares
),
@ -75,7 +64,6 @@ export default function createApp({
const reducer = combineReducers(
appReducer,
panesReducer,
routesReducer
);
@ -95,7 +83,6 @@ export default function createApp({
debug('hot reloading reducers');
store.replaceReducer(combineReducers(
require('./reducer.js').default,
panesReducer,
routesReducer
));
});

View File

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

View File

@ -1,7 +1,6 @@
import { findIndex, property, merge, union } from 'lodash';
import { findIndex, property, merge } from 'lodash';
import uuid from 'uuid/v4';
import {
combineActions,
composeReducers,
createAction,
createTypes,
@ -9,8 +8,7 @@ import {
} from 'berkeleys-redux-utils';
import { themes } from '../../utils/themes';
import { usernameSelector, types as app } from '../redux';
import { types as challenges } from '../routes/Challenges/redux';
import { usernameSelector } from '../redux';
import { types as map } from '../Map/redux';
import legacyProjects from '../../utils/legacyProjectData';
@ -204,36 +202,8 @@ export default composeReducers(
},
handleActions(
() => ({
[
combineActions(
app.fetchNewBlock.complete,
map.fetchMapUi.complete
)
]: (state, { payload: { entities } }) => merge({}, state, entities),
[app.fetchNewBlock.complete]: (
state,
{ payload: { entities: { block } } }
) => ({
...state,
fullBlocks: union(state.fullBlocks, [ Object.keys(block)[0] ])
}),
[types.resetFullBlocks]: state => ({ ...state, fullBlocks: [] }),
[
challenges.submitChallenge.complete
]: (state, { payload: { username, points, challengeInfo } }) => ({
...state,
user: {
...state.user,
[username]: {
...state.user[username],
points,
challengeMap: {
...state.user[username].challengeMap,
[challengeInfo.id]: challengeInfo
}
}
}
}),
[map.fetchMapUi.complete]: (state, { payload: { entities } }) =>
merge({}, state, entities),
[types.addPortfolioItem]: (state, { payload: username }) => ({
...state,
user: {
@ -324,22 +294,6 @@ export default composeReducers(
languageTag
}
}
}),
[types.updateUserCurrentChallenge]:
(
state,
{
payload: { username, currentChallengeId }
}
) => ({
...state,
user: {
...state.user,
[username]: {
...state.user[username],
currentChallengeId
}
}
})
}),
defaultState

View File

@ -1,17 +1,13 @@
import { epics as app } from './redux';
import { epics as challenge } from './routes/Challenges/redux';
import { epics as flash } from './Flash/redux';
import { epics as map } from './Map/redux';
import { epics as nav } from './Nav/redux';
import { epics as panes } from './Panes/redux';
import { epics as settings } from './routes/Settings/redux';
export default [
...app,
...challenge,
...flash,
...map,
...nav,
...panes,
...settings
];

View File

@ -1,53 +0,0 @@
import _ from 'lodash';
import {
addNS,
createTypes
} from 'berkeleys-redux-utils';
import { createPoly, setContent } from '../../utils/polyvinyl.js';
const ns = 'files';
export const types = createTypes([
'updateFile',
'createFiles'
], ns);
export const updateFileMetaCreator = (key, content)=> ({
file: { type: types.updateFile, payload: { key, content } }
});
export const createFilesMetaCreator = payload => ({
file: { type: types.createFiles, payload }
});
export const filesSelector = state => state[ns];
export const createFileSelector = keySelector => (state, props) => {
const files = filesSelector(state);
return files[keySelector(state, props)] || {};
};
const getFileAction = _.property('meta.file.type');
const getFilePayload = _.property('meta.file.payload');
export default addNS(
ns,
function reducer(state = {}, action) {
if (getFileAction(action)) {
if (getFileAction(action) === types.updateFile) {
const { key, content } = getFilePayload(action);
return {
...state,
[key]: setContent(content, state[key])
};
}
if (getFileAction(action) === types.createFiles) {
const files = getFilePayload(action);
return _.reduce(files, (files, file) => {
files[file.key] = createPoly(file);
return files;
}, {});
}
}
return state;
}
);

View File

@ -1,6 +1,4 @@
&{ @import "./app.less"; }
&{ @import "./Map/map.less"; }
&{ @import "./Nav/nav.less"; }
&{ @import "./Flash/flash.less"; }
&{ @import "./routes/index.less"; }
&{ @import "./Panes/panes.less"; }

View File

@ -2,14 +2,14 @@ import { combineReducers } from 'berkeleys-redux-utils';
import app from './redux';
import entities from './entities';
import form from './redux-form-reducer';
import { reducer as form } from 'redux-form';
import map from './Map/redux';
import nav from './Nav/redux';
import routes from './routes/redux';
import toasts from './Toasts/redux';
import files from './files';
import flash from './Flash/redux';
form.toString = () => 'form';
export default combineReducers(
app,
@ -18,7 +18,6 @@ export default combineReducers(
nav,
routes,
toasts,
files,
flash,
form
);

View File

@ -1,37 +0,0 @@
import { composeReducers } from 'berkeleys-redux-utils';
import { reducer as formReducer } from 'redux-form';
import {
projectNormalizer,
types as challenge
} from './routes/Challenges/redux';
const normailizedFormReducer = formReducer.normalize({ ...projectNormalizer });
const pluggedInFormReducer = formReducer.plugin({
NewFrontEndProject: (state, action) => {
if (action.type === challenge.moveToNextChallenge) {
return {
...state,
solution: {}
};
}
return state;
},
NewBackEndProject: (state, action) => {
if (action.type === challenge.moveToNextChallenge) {
return {
...state,
solution: {},
githubLink: {}
};
}
return state;
}
});
export default composeReducers(
'form',
normailizedFormReducer,
pluggedInFormReducer
);

View File

@ -1,118 +0,0 @@
import { Observable } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import _ from 'lodash';
import debug from 'debug';
import {
types,
createErrorObservable,
delayedRedirect,
fetchChallengeCompleted,
fetchNewBlockComplete,
challengeSelector,
nextChallengeSelector
} from './';
import {
isChallengeLoaded,
fullBlocksSelector
} from '../entities';
import { shapeChallenges } from './utils';
import { types as challenge } from '../routes/Challenges/redux';
import { langSelector, paramsSelector } from '../Router/redux';
const isDev = debug.enabled('fcc:*');
function fetchChallengeEpic(actions, { getState }, { services }) {
return actions::ofType(challenge.onRouteChallenges)
.filter(({ payload }) => !isChallengeLoaded(getState(), payload))
.flatMapLatest(({ payload: params }) => {
const options = {
service: 'challenge',
params
};
return services.readService$(options)
.retry(3)
.map(({ entities, ...rest }) => ({
entities: shapeChallenges(entities, isDev),
...rest
}))
.flatMap(({ entities, result, redirect } = {}) => {
const actions = [
fetchChallengeCompleted({
entities,
currentChallenge: result.challenge,
challenge: entities.challenge[result.challenge],
result
}),
redirect ? delayedRedirect(redirect) : null
];
return Observable.from(actions).filter(Boolean);
})
.catch(createErrorObservable);
});
}
export function fetchChallengesForBlockEpic(
actions,
{ getState },
{ services }
) {
const onAppMount = actions::ofType(types.appMounted)
.map(() => {
const {
block = 'basic-html-and-html5'
} = challengeSelector(getState());
return block;
});
const onNewChallenge = actions::ofType(challenge.moveToNextChallenge)
.map(() => {
const {
isNewBlock,
isNewSuperBlock,
nextChallenge
} = nextChallengeSelector(getState());
const isNewBlockRequired = isNewBlock || isNewSuperBlock && nextChallenge;
return isNewBlockRequired ? nextChallenge.block : null;
});
const onBlockSelect = actions::ofType(types.fetchNewBlock.start)
.map(({ payload }) => payload);
return Observable.merge(onAppMount, onNewChallenge, onBlockSelect)
.filter(block => {
const fullBlocks = fullBlocksSelector(getState());
return block && !fullBlocks.includes(block);
})
.flatMapLatest(blockName => {
const lang = langSelector(getState());
const options = {
params: { lang, blockName },
service: 'challenge'
};
return services.readService$(options)
.retry(3)
.map(newBlockData => {
const { dashedName } = paramsSelector(getState());
const { entities: { challenge } } = newBlockData;
const currentChallengeInNewBlock = _.pickBy(
challenge,
newChallenge => newChallenge.dashedName === dashedName
);
return fetchNewBlockComplete({
...newBlockData,
meta: {
challenge: currentChallengeInNewBlock
}
});
})
.catch(createErrorObservable);
});
}
export default combineEpics(
fetchChallengeEpic,
fetchChallengesForBlockEpic
);

View File

@ -8,41 +8,25 @@ import {
handleActions
} from 'berkeleys-redux-utils';
import { createSelector } from 'reselect';
import debug from 'debug';
import fetchUserEpic from './fetch-user-epic.js';
import updateMyCurrentChallengeEpic from './update-my-challenge-epic.js';
import fetchChallengesEpic from './fetch-challenges-epic.js';
import nightModeEpic from './night-mode-epic.js';
import {
updateThemeMetacreator,
entitiesSelector,
fullBlocksSelector
entitiesSelector
} from '../entities';
import { utils } from '../Flash/redux';
import { paramsSelector } from '../Router/redux';
import { types as challenges } from '../routes/Challenges/redux';
import { types as map } from '../Map/redux';
import {
createCurrentChallengeMeta,
challengeToFilesMetaCreator,
getFirstChallengeOfNextBlock,
getFirstChallengeOfNextSuperBlock,
getNextChallenge
} from '../routes/Challenges/utils';
import ns from '../ns.json';
import { themes, invertTheme } from '../../utils/themes.js';
const isDev = debug.enabled('fcc:*');
export const epics = [
fetchChallengesEpic,
fetchUserEpic,
nightModeEpic,
updateMyCurrentChallengeEpic
nightModeEpic
];
export const types = createTypes([
@ -52,9 +36,6 @@ export const types = createTypes([
'analytics',
'updateTitle',
createAsyncTypes('fetchChallenge'),
createAsyncTypes('fetchChallenges'),
createAsyncTypes('fetchNewBlock'),
createAsyncTypes('fetchOtherUser'),
createAsyncTypes('fetchUser'),
'showSignIn',
@ -107,29 +88,6 @@ export function createEventMetaCreator({
export const onRouteHome = createAction(types.onRouteHome);
export const appMounted = createAction(types.appMounted);
export const fetchChallenge = createAction(
'' + types.fetchChallenge,
(dashedName, block) => ({ dashedName, block })
);
export const fetchChallengeCompleted = createAction(
types.fetchChallenge.complete,
null,
meta => ({
...meta,
...challengeToFilesMetaCreator(meta.challenge)
})
);
export const fetchChallenges = createAction('' + types.fetchChallenges);
export const fetchChallengesCompleted = createAction(
types.fetchChallenges.complete
);
export const fetchNewBlock = createAction(types.fetchNewBlock.start);
export const fetchNewBlockComplete = createAction(
types.fetchNewBlock.complete,
({ entities }) => ({ entities }),
({ meta: { challenge } }) => ({ ...createCurrentChallengeMeta(challenge) })
);
// updateTitle(title: String) => Action
export const updateTitle = createAction(types.updateTitle);
@ -202,8 +160,6 @@ const defaultState = {
isSignInAttempted: false,
user: '',
csrfToken: '',
// eventually this should be only in the user object
currentChallenge: '',
superBlocks: []
};
@ -211,8 +167,6 @@ export const getNS = state => state[ns];
export const csrfSelector = state => getNS(state).csrfToken;
export const titleSelector = state => getNS(state).title;
export const currentChallengeSelector = state => getNS(state).currentChallenge;
export const superBlocksSelector = state => getNS(state).superBlocks;
export const signInLoadingSelector = state => !getNS(state).isSignInAttempted;
export const usernameSelector = state => getNS(state).user || '';
@ -235,92 +189,6 @@ export const themeSelector = flow(
export const isSignedInSelector = state => !!userSelector(state).username;
export const challengeSelector = state => {
const challengeName = currentChallengeSelector(state);
const challengeMap = entitiesSelector(state).challenge || {};
return challengeMap[challengeName] || firstChallengeSelector(state);
};
export const isCurrentBlockCompleteSelector = state => {
const { block } = paramsSelector(state);
const fullBlocks = fullBlocksSelector(state);
return fullBlocks.includes(block);
};
export const previousSolutionSelector = state => {
const { id } = challengeSelector(state);
const { challengeMap = {} } = userSelector(state);
return challengeMap[id];
};
export const firstChallengeSelector = createSelector(
entitiesSelector,
superBlocksSelector,
(
{
challenge: challengeMap,
block: blockMap,
superBlock: superBlockMap
},
superBlocks
) => {
if (
!challengeMap ||
!blockMap ||
!superBlockMap ||
!superBlocks
) {
return {};
}
try {
return challengeMap[
blockMap[
superBlockMap[
superBlocks[0]
].blocks[0]
].challenges[0]
];
} catch (err) {
console.error(err);
return {};
}
}
);
export const nextChallengeSelector = state => {
let nextChallenge = {};
let isNewBlock = false;
let isNewSuperBlock = false;
const challenge = currentChallengeSelector(state);
const superBlocks = superBlocksSelector(state);
const entities = entitiesSelector(state);
nextChallenge = getNextChallenge(challenge, entities, { isDev });
// block completed.
if (!nextChallenge) {
isNewBlock = true;
nextChallenge = getFirstChallengeOfNextBlock(
challenge,
entities,
{ isDev }
);
}
// superBlock completed
if (!nextChallenge) {
isNewSuperBlock = true;
nextChallenge = getFirstChallengeOfNextSuperBlock(
challenge,
entities,
superBlocks,
{ isDev }
);
}
return {
nextChallenge,
isNewBlock,
isNewSuperBlock
};
};
export default handleActions(
() => ({
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
@ -332,28 +200,16 @@ export default handleActions(
...state,
user
}),
[combineActions(
types.fetchChallenge.complete,
map.fetchMapUi.complete
)]: (state, { payload }) => ({
[map.fetchMapUi.complete]: (state, { payload }) => ({
...state,
superBlocks: payload.result.superBlocks
}),
[challenges.onRouteChallenges]: (state, { payload: { dashedName } }) => ({
...state,
currentChallenge: dashedName
}),
[
combineActions(types.showSignIn, types.fetchUser.complete)
]: state => ({
...state,
isSignInAttempted: true
}),
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
...state,
points
}),
[types.delayedRedirect]: (state, { payload }) => ({
...state,
delayedRedirect: payload

View File

@ -1,51 +0,0 @@
import { Observable } from 'rx';
import { ofType } from 'redux-epic';
import debug from 'debug';
import {
types,
createErrorObservable,
challengeSelector,
csrfSelector,
userSelector
} from './';
import { updateUserCurrentChallenge } from '../entities';
import { postJSON$ } from '../../utils/ajax-stream';
import { types as challenges } from '../routes/Challenges/redux';
const log = debug('fcc:app:redux:up-my-challenge-epic');
export default function updateMyCurrentChallengeEpic(actions, { getState }) {
const updateChallenge = actions::ofType(types.appMounted)
.flatMapLatest(() => actions::ofType(challenges.onRouteChallenges))
.map(() => {
const state = getState();
// username is never defined SSR
const { username } = userSelector(state);
const { id } = challengeSelector(state);
const csrf = csrfSelector(state);
return {
username,
csrf,
currentChallengeId: id
};
})
.filter(({ username }) => !!username)
.distinctUntilChanged(x => x.currentChallengeId);
const optimistic = updateChallenge.map(updateUserCurrentChallenge);
const ajaxUpdate = updateChallenge
.debounce(250)
.flatMapLatest(({ csrf, currentChallengeId }) => {
return postJSON$(
'/update-my-current-challenge',
{
currentChallengeId,
_csrf: csrf
}
)
.map(({ message }) => log(message))
.ignoreElements()
.catch(createErrorObservable);
});
return Observable.merge(optimistic, ajaxUpdate);
}

View File

@ -1,52 +1,7 @@
import flowRight from 'lodash/flowRight';
import { createNameIdMap } from '../../utils/map.js';
import { partial } from 'lodash';
export function filterComingSoonBetaChallenge(
isDev = false,
{ isComingSoon, isBeta }
) {
return !(isComingSoon || isBeta) ||
isDev;
}
export function filterComingSoonBetaFromEntities(
{ challenge: challengeMap, block: blockMap = {}, ...rest },
isDev = false
) {
const filter = partial(filterComingSoonBetaChallenge, isDev);
return {
...rest,
block: Object.keys(blockMap)
.map(dashedName => {
const block = blockMap[dashedName];
const filteredChallenges = block.challenges
.map(dashedName => challengeMap[dashedName])
.filter(filter)
.map(challenge => challenge.dashedName);
return {
...block,
challenges: [ ...filteredChallenges ]
};
})
.reduce((blockMap, block) => {
blockMap[block.dashedName] = block;
return blockMap;
}, {}),
challenge: Object.keys(challengeMap)
.map(dashedName => challengeMap[dashedName])
.filter(filter)
.reduce((challengeMap, challenge) => {
challengeMap[challenge.dashedName] = challenge;
return challengeMap;
}, {})
};
}
export const shapeChallenges = flowRight(
filterComingSoonBetaFromEntities,
entities => ({
...entities,
...createNameIdMap(entities)

View File

@ -1,152 +0,0 @@
import test from 'tape';
import {
filterComingSoonBetaChallenge,
filterComingSoonBetaFromEntities
} from './utils.js';
test.test('filterComingSoonBetaChallenge', t => {
t.plan(4);
t.test('should return true when not coming-soon/beta', t => {
let isDev;
t.ok(filterComingSoonBetaChallenge(isDev, {}));
t.ok(filterComingSoonBetaChallenge(true, {}));
t.end();
});
t.test('should return false when isComingSoon', t => {
let isDev;
t.notOk(filterComingSoonBetaChallenge(isDev, { isComingSoon: true }));
t.end();
});
t.test('should return false when isBeta', t => {
let isDev;
t.notOk(filterComingSoonBetaChallenge(isDev, { isBeta: true }));
t.end();
});
t.test('should always return true when in dev', t => {
let isDev = true;
t.ok(filterComingSoonBetaChallenge(isDev, { isBeta: true }));
t.ok(filterComingSoonBetaChallenge(isDev, { isComingSoon: true }));
t.ok(filterComingSoonBetaChallenge(
isDev,
{ isBeta: true, isCompleted: true }
));
t.end();
});
});
test.test('filterComingSoonBetaFromEntities', t => {
t.plan(2);
t.test('should filter isBeta|coming-soon by default', t => {
t.plan(4);
const normalChallenge = { dashedName: 'normal-challenge' };
const entities = {
challenge: {
'coming-soon': {
isComingSoon: true
},
'is-beta': {
isBeta: true
},
[normalChallenge.dashedName]: normalChallenge
},
block: {
'coming-soon': {
dashedName: 'coming-soon',
challenges: ['coming-soon']
},
'is-beta': {
dashedName: 'is-beta',
challenges: ['is-beta']
},
normal: {
dashedName: 'normal',
challenges: [normalChallenge.dashedName]
}
}
};
const actual = filterComingSoonBetaFromEntities(entities);
t.isEqual(
Object.keys(actual.challenge).length,
1,
'did not filter the correct amount of challenges'
);
t.isEqual(
actual.challenge[normalChallenge.dashedName],
normalChallenge,
'did not return the correct challenge'
);
const challengesFromBlocks = [];
Object.keys(actual.block)
.forEach(block => {
const challenges = actual.block[block].challenges;
challenges.forEach(challenge => challengesFromBlocks.push(challenge));
});
t.isEqual(
challengesFromBlocks.length,
1,
'did not filter the correct amount of challenges from blocks'
);
t.isEqual(
challengesFromBlocks[0],
normalChallenge.dashedName,
'did not return the correct challenge from blocks'
);
});
t.test('should not filter isBeta|coming-soon when isDev', t => {
t.plan(2);
const normalChallenge = { dashedName: 'normal-challenge' };
const entities = {
challenge: {
'coming-soon': {
dashedName: 'coming-soon',
isComingSoon: true
},
'is-beta': {
dashedName: 'is-beta',
isBeta: true
},
'is-both': {
dashedName: 'is-both',
isBeta: true
},
[normalChallenge.dashedName]: normalChallenge
},
block: {
'coming-soon': {
dashedName: 'coming-soon',
challenges: ['coming-soon']
},
'is-beta': {
dashedName: 'is-beta',
challenges: ['is-beta']
},
'is-both': {
dashedName: 'is-both',
challenges: ['is-both']
},
normal: {
dashedName: 'normal',
challenges: [normalChallenge.dashedName]
}
}
};
const actual = filterComingSoonBetaFromEntities(entities, true);
t.isEqual(
Object.keys(actual.challenge).length,
4,
'filtered challenges'
);
let challengesFromBlocksCount = 0;
Object.keys(actual.block)
.forEach(block => {
challengesFromBlocksCount += actual.block[block].challenges.length;
});
t.isEqual(
challengesFromBlocksCount,
4,
'filtered challenges from blocks'
);
});
});

View File

@ -2,18 +2,16 @@ 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;
newRoute = route;
} else {
newRoute = { ...route, path: base + route.path };
newRoute = { ...route, path: route.path };
}
routes[type] = newRoute;
return routes;
}, {}),
[types.routeOnHome]: base
[types.routeOnHome]: '/'
};

View File

@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Col, Row } from 'react-bootstrap';
import ns from './ns.json';
import { isCurrentBlockCompleteSelector } from '../../redux';
import { SkeletonSprite } from '../../helperComponents';
const mapStateToProps = createSelector(
isCurrentBlockCompleteSelector,
blockComplete => ({
showLoading: !blockComplete
})
);
const propTypes = {
children: PropTypes.array,
showLoading: PropTypes.bool
};
function ChallengeDescription({ children, showLoading }) {
return (
<Row>
<Col
className={ `${ns}-instructions` }
xs={ 12 }
>
{
showLoading ?
children
.map((_, i) => (
<div
key={ '' + i + 'description' }
style={{ height: '36px', margin: '9px 0px' }}
>
<SkeletonSprite />
</div>
)) :
children
}
</Col>
</Row>
);
}
ChallengeDescription.displayName = 'ChallengeDescription';
ChallengeDescription.propTypes = propTypes;
export default connect(mapStateToProps)(ChallengeDescription);

View File

@ -1,52 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ns from './ns.json';
import { isCurrentBlockCompleteSelector } from '../../redux';
import { SkeletonSprite } from '../../helperComponents';
const mapStateToProps = createSelector(
isCurrentBlockCompleteSelector,
blockComplete => ({
showLoading: !blockComplete
})
);
const propTypes = {
children: PropTypes.string,
isCompleted: PropTypes.bool,
showLoading: PropTypes.bool
};
function ChallengeTitle({ children, isCompleted, showLoading }) {
let icon = null;
if (showLoading) {
return (
<h4 style={{ height: '35px', marginBottom: '9px' }}>
<SkeletonSprite />
<hr />
</h4>
);
}
if (isCompleted) {
icon = (
<i
className='ion-checkmark-circled text-primary'
title='Completed'
/>
);
}
return (
<h4 className={ `text-center ${ns}-title` }>
{ children || 'Happy Coding!' }
{ icon }
<hr />
</h4>
);
}
ChallengeTitle.displayName = 'ChallengeTitle';
ChallengeTitle.propTypes = propTypes;
export default connect(mapStateToProps)(ChallengeTitle);

View File

@ -1,23 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import CompletionModal from './Completion-Modal.jsx';
import AppChildContainer from '../../Child-Container.jsx';
const propTypes = {
children: PropTypes.node
};
function ChildContainer(props) {
const { children, ...restProps } = props;
return (
<AppChildContainer { ...restProps }>
{ children }
<CompletionModal />
</AppChildContainer>
);
}
ChildContainer.propTypes = propTypes;
export default ChildContainer;

View File

@ -1,42 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { SkeletonSprite } from '../../helperComponents';
const propTypes = {
content: PropTypes.string
};
export default class CodeMirrorSkeleton extends PureComponent {
render() {
const {
content
} = this.props;
const editorLines = (content || '').split('\n');
return (
<div className='ReactCodeMirror'>
<div className='CodeMirror cm-s-monokai CodeMirror-wrap'>
<div className='CodeMirror-scrollbar-filler' />
<div className='CodeMirror-gutter-filler' />
<div className='CodeMirror-scroll'>
<div
className='CodeMirror-sizer'
style={
{
height: (editorLines.length * 18) + 'px',
overflow: 'hidden'
}
}
>
<SkeletonSprite />
</div>
</div>
</div>
</div>
);
}
}
CodeMirrorSkeleton.displayName = 'CodeMirrorSkeleton';
CodeMirrorSkeleton.propTypes = propTypes;

View File

@ -1,116 +0,0 @@
import noop from 'lodash/noop';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Button, Modal } from 'react-bootstrap';
import FontAwesome from 'react-fontawesome';
import ns from './ns.json';
import {
closeChallengeModal,
submitChallenge,
checkForNextBlock,
challengeModalSelector,
successMessageSelector
} from './redux';
const mapStateToProps = createSelector(
challengeModalSelector,
successMessageSelector,
(isOpen, message) => ({
isOpen,
message
})
);
const mapDispatchToProps = function(dispatch) {
const dispatchers = {
close: () => dispatch(closeChallengeModal()),
handleKeypress: (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
dispatch(submitChallenge());
}
},
submitChallenge: () => {
dispatch(submitChallenge());
},
checkForNextBlock: () => dispatch(checkForNextBlock())
};
return () => dispatchers;
};
const propTypes = {
checkForNextBlock: PropTypes.func.isRequired,
close: PropTypes.func.isRequired,
handleKeypress: PropTypes.func.isRequired,
isOpen: PropTypes.bool,
message: PropTypes.string,
submitChallenge: PropTypes.func.isRequired
};
export class CompletionModal extends PureComponent {
componentDidUpdate() {
if (this.props.isOpen) {
this.props.checkForNextBlock();
}
}
render() {
const {
close,
isOpen,
submitChallenge,
handleKeypress,
message
} = this.props;
return (
<Modal
animation={ false }
dialogClassName={ `${ns}-success-modal` }
keyboard={ true }
onHide={ close }
onKeyDown={ isOpen ? handleKeypress : noop }
show={ isOpen }
>
<Modal.Header
className={ `${ns}-list-header` }
closeButton={ true }
>
<Modal.Title>{ message }</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className='text-center'>
<div className='row'>
<div>
<FontAwesome
className='completion-icon text-primary'
name='check-circle'
/>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
onClick={ submitChallenge }
>
Submit and go to next challenge (Ctrl + Enter)
</Button>
</Modal.Footer>
</Modal>
);
}
}
CompletionModal.displayName = 'CompletionModal';
CompletionModal.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(CompletionModal);

View File

@ -1,75 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button, Modal } from 'react-bootstrap';
import ns from './ns.json';
import {
createQuestion,
closeHelpModal,
helpModalSelector
} from './redux';
import { RSA } from '../../../utils/constantStrings.json';
const mapStateToProps = state => ({ isOpen: helpModalSelector(state) });
const mapDispatchToProps = { createQuestion, closeHelpModal };
const propTypes = {
closeHelpModal: PropTypes.func.isRequired,
createQuestion: PropTypes.func.isRequired,
isOpen: PropTypes.bool
};
export class HelpModal extends PureComponent {
render() {
const {
isOpen,
closeHelpModal,
createQuestion
} = this.props;
return (
<Modal
show={ isOpen }
>
<Modal.Header className={ `${ns}-list-header` }>
Ask for help?
<span
className='close closing-x'
onClick={ closeHelpModal }
>
×
</span>
</Modal.Header>
<Modal.Body className='text-center'>
<h3 className={`${ns}-help-modal-heading`}>
If you've already tried the&nbsp;
<a href={ RSA } target='_blank' title='Read, search, ask'>
Read-Search-Ask</a>&nbsp; method, then you can ask for help
on the freeCodeCamp forum.
</h3>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
onClick={ createQuestion }
>
Create a help post on the forum
</Button>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
onClick={ closeHelpModal }
>
Cancel
</Button>
</Modal.Body>
</Modal>
);
}
}
HelpModal.displayName = 'HelpModal';
HelpModal.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(HelpModal);

View File

@ -1,46 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import NoSSR from 'react-no-ssr';
import Codemirror from 'react-codemirror';
import ns from './ns.json';
import CodeMirrorSkeleton from './Code-Mirror-Skeleton.jsx';
import { themeSelector } from '../../redux';
const defaultOptions = {
lineNumbers: false,
lineWrapping: true,
mode: 'javascript',
readOnly: 'nocursor'
};
const mapStateToProps = state => ({ theme: themeSelector(state) });
const propTypes = {
defaultOutput: PropTypes.string,
output: PropTypes.string,
theme: PropTypes.string
};
export class Output extends PureComponent {
render() {
const { output, defaultOutput } = this.props;
const cmTheme = this.props.theme === 'default' ? 'default' : 'dracula';
return (
<div className={ `${ns}-log` }>
<NoSSR onSSR={ <CodeMirrorSkeleton content={ output } /> }>
<Codemirror
options={{ ...defaultOptions, theme: cmTheme }}
value={ output || defaultOutput }
/>
</NoSSR>
</div>
);
}
}
Output.displayName = 'Output';
Output.propTypes = propTypes;
export default connect(mapStateToProps)(Output);

View File

@ -1,20 +0,0 @@
import React from 'react';
import ns from './ns.json';
const mainId = 'fcc-main-frame';
const Preview = () => {
return (
<div className={ `${ns}-preview` }>
<iframe
className={ `${ns}-preview-frame` }
id={ mainId }
/>
</div>
);
};
Preview.displayName = 'Preview';
export default Preview;

View File

@ -1,127 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { challengeMetaSelector, updateSuccessMessage } from './redux';
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 Modern from './views/Modern';
import NotFound from '../../NotFound';
import { fullBlocksSelector } from '../../entities';
import {
fetchChallenge,
challengeSelector,
updateTitle
} from '../../redux';
import { makeToast } from '../../Toasts/redux';
import { paramsSelector } from '../../Router/redux';
import { randomCompliment } from '../../utils/get-words';
const views = {
backend: BackEnd,
classic: Classic,
modern: Modern,
project: Project,
quiz: Quiz,
step: Step,
invalid: NotFound
};
const mapDispatchToProps = {
fetchChallenge,
makeToast,
updateTitle,
updateSuccessMessage
};
const mapStateToProps = createSelector(
challengeSelector,
challengeMetaSelector,
paramsSelector,
fullBlocksSelector,
(
{ dashedName, isTranslated },
{ viewType, title },
params,
blocks
) => ({
blocks,
challenge: dashedName,
isTranslated,
params,
title,
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
}),
showLoading: PropTypes.bool,
title: PropTypes.string,
updateSuccessMessage: PropTypes.func.isRequired,
updateTitle: PropTypes.func.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() {
this.props.updateTitle(this.props.title);
this.props.updateSuccessMessage(randomCompliment());
if (this.isNotTranslated(this.props)) {
this.makeTranslateToast();
}
}
componentWillReceiveProps(nextProps) {
if (this.props.title !== nextProps.title) {
this.props.updateTitle(nextProps.title);
}
const { params: { dashedName } } = nextProps;
if (this.props.params.dashedName !== dashedName) {
this.props.updateSuccessMessage(randomCompliment());
if (this.isNotTranslated(nextProps)) {
this.makeTranslateToast();
}
}
}
render() {
const { viewType } = this.props;
const View = views[viewType] || Classic;
return <View />;
}
}
Show.displayName = 'Show(ChallengeView)';
Show.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(Show);

View File

@ -1,182 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import ReactDom from 'react-dom';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';
import ns from './ns.json';
import HelpModal from './Help-Modal.jsx';
import ToolPanel from './Tool-Panel.jsx';
import ChallengeTitle from './Challenge-Title.jsx';
import ChallengeDescription from './Challenge-Description.jsx';
import TestSuite from './Test-Suite.jsx';
import Output from './Output.jsx';
import {
openHelpModal,
updateHint,
executeChallenge,
unlockUntrustedCode,
challengeMetaSelector,
testsSelector,
outputSelector,
hintIndexSelector,
codeLockedSelector,
guideURLSelector
} from './redux';
import { descriptionRegex } from './utils';
import { challengeSelector } from '../../redux';
import { makeToast } from '../../Toasts/redux';
const mapDispatchToProps = {
makeToast,
executeChallenge,
updateHint,
openHelpModal,
unlockUntrustedCode
};
const mapStateToProps = createSelector(
challengeSelector,
challengeMetaSelector,
testsSelector,
outputSelector,
hintIndexSelector,
codeLockedSelector,
guideURLSelector,
(
{ description },
{ title },
tests,
output,
hintIndex,
isCodeLocked,
guideUrl
) => ({
title,
guideUrl,
description,
tests,
output,
isCodeLocked
})
);
const propTypes = {
description: PropTypes.arrayOf(PropTypes.string),
executeChallenge: PropTypes.func,
guideUrl: PropTypes.string,
hint: PropTypes.string,
isCodeLocked: PropTypes.bool,
makeToast: PropTypes.func,
openHelpModal: PropTypes.func,
output: PropTypes.string,
tests: PropTypes.arrayOf(PropTypes.object),
title: PropTypes.string,
unlockUntrustedCode: PropTypes.func,
updateHint: PropTypes.func
};
export class SidePanel extends PureComponent {
constructor(props) {
super(props);
this.bindTopDiv = this.bindTopDiv.bind(this);
}
componentWillUpdate(nextProps) {
const { title } = this.props;
if (title !== nextProps.title) {
const node = ReactDom.findDOMNode(this.descriptionTop);
setTimeout(() => { node.scrollIntoView({ behavior: 'smooth'}); }, 0);
}
}
bindTopDiv(node) {
this.descriptionTop = node;
}
renderDescription(description = [ 'Happy Coding!' ]) {
return description.map((line, index) => {
if (descriptionRegex.test(line)) {
return (
<div
dangerouslySetInnerHTML={{ __html: line }}
key={ line.slice(-6) + index }
/>
);
}
return (
<p
className='wrappable'
dangerouslySetInnerHTML= {{ __html: line }}
key={ line.slice(-6) + index }
/>
);
});
}
render() {
const {
title,
description,
tests = [],
output,
hint,
executeChallenge,
updateHint,
makeToast,
openHelpModal,
isCodeLocked,
unlockUntrustedCode,
guideUrl
} = this.props;
return (
<div
className={ `${ns}-instructions-panel` }
ref='panel'
role='complementary'
>
<div ref={ this.bindTopDiv } />
<div>
<ChallengeTitle>
{ title }
</ChallengeTitle>
<ChallengeDescription>
{ this.renderDescription(description) }
</ChallengeDescription>
</div>
<ToolPanel
executeChallenge={ executeChallenge }
guideUrl={ guideUrl }
hint={ hint }
isCodeLocked={ isCodeLocked }
makeToast={ makeToast }
openHelpModal={ openHelpModal }
unlockUntrustedCode={ unlockUntrustedCode }
updateHint={ updateHint }
/>
<HelpModal />
<Output
defaultOutput={
`/**
* Your output will go here.
* Any console.log() statements
* will appear in here as well.
*/`
}
output={ output }
/>
<br />
<TestSuite tests={ tests } />
</div>
);
}
}
SidePanel.displayName = 'SidePanel';
SidePanel.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(SidePanel);

View File

@ -1,46 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { HelpBlock, FormGroup, FormControl } from 'react-bootstrap';
import { getValidationState, DOMOnlyProps } from '../../utils/form';
const propTypes = {
placeholder: PropTypes.string,
solution: PropTypes.object
};
export default function SolutionInput({ solution, placeholder }) {
const validationState = getValidationState(solution);
return (
<FormGroup
controlId='solution'
validationState={
(validationState && validationState.includes('warning')) ?
'warning' :
validationState
}
>
<FormControl
name='solution'
placeholder={ placeholder }
type='url'
{ ...DOMOnlyProps(solution) }
/>
{
validationState === 'error' &&
<HelpBlock>Make sure you provide a proper URL.</HelpBlock>
}
{
validationState === 'glitch-warning' &&
<HelpBlock>
Make sure you have entered a shareable URL
(e.g. "https://green-camper.glitch.me", not
"https://glitch.com/#!/edit/green-camper".)
</HelpBlock>
}
</FormGroup>
);
}
SolutionInput.displayName = 'SolutionInput';
SolutionInput.propTypes = propTypes;

View File

@ -1,80 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Col, Row } from 'react-bootstrap';
import ns from './ns.json';
const propTypes = {
tests: PropTypes.arrayOf(PropTypes.object)
};
function getAccessibleText(err, pass, text) {
let accessibleText = 'Waiting';
// Determine test status (i.e. icon)
if (err) {
accessibleText = 'Error';
} else if (pass) {
accessibleText = 'Pass';
}
// Append the text itself
return accessibleText + ' - ' + text;
}
export default class TestSuite extends PureComponent {
renderTests(tests = []) {
// err && pass > invalid state
// err && !pass > failed tests
// !err && pass > passed tests
// !err && !pass > in-progress
return tests.map(({ err, pass = false, text = '' }, index)=> {
const iconClass = classnames({
'big-icon': true,
'ion-close-circled error-icon': err && !pass,
'ion-checkmark-circled success-icon': !err && pass,
'ion-refresh refresh-icon': !err && !pass
});
return (
<Row
aria-label={ getAccessibleText(err, pass, text) }
key={ text.slice(-6) + index }
tabIndex='0'
>
<Col
className='text-center'
xs={ 2 }
>
<i
aria-hidden='true'
className={ iconClass }
/>
</Col>
<Col
aria-hidden='true'
className='test-output'
dangerouslySetInnerHTML={{ __html: text }}
xs={ 10 }
/>
</Row>
);
});
}
render() {
const { tests } = this.props;
return (
<div
className={ `${ns}-test-suite` }
style={{ marginTop: '10px' }}
>
{ this.renderTests(tests) }
<div className='big-spacer' />
</div>
);
}
}
TestSuite.displayName = 'TestSuite';
TestSuite.propTypes = propTypes;

View File

@ -1,179 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Button, Tooltip, OverlayTrigger } from 'react-bootstrap';
import { isCurrentBlockCompleteSelector } from '../../redux';
const mapStateToProps = createSelector(
isCurrentBlockCompleteSelector,
blockComplete => ({
isDisabled: !blockComplete
})
);
const unlockWarning = (
<Tooltip id='tooltip'>
<h4>
<strong>Careful!</strong> Only run code you trust.
</h4>
</Tooltip>
);
const propTypes = {
executeChallenge: PropTypes.func.isRequired,
guideUrl: PropTypes.string,
hint: PropTypes.string,
isCodeLocked: PropTypes.bool,
isDisabled: PropTypes.bool,
makeToast: PropTypes.func.isRequired,
openHelpModal: PropTypes.func.isRequired,
unlockUntrustedCode: PropTypes.func.isRequired,
updateHint: PropTypes.func.isRequired
};
class ToolPanel extends PureComponent {
constructor(...props) {
super(...props);
this.makeHint = this.makeHint.bind(this);
this.makeReset = this.makeReset.bind(this);
}
makeHint() {
this.props.makeToast({
message: this.props.hint,
timeout: 4000
});
this.props.updateHint();
}
makeReset() {
this.props.makeToast({
message: 'This will restore your code editor to its original state.',
action: 'clear my code',
actionCreator: 'clickOnReset',
timeout: 4000
});
}
renderHint(hint, makeHint, isDisabled) {
if (!hint) {
return null;
}
return (
<Button
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ makeHint }
>
Hint
</Button>
);
}
renderExecute(
isCodeLocked,
executeChallenge,
unlockUntrustedCode,
isDisabled
) {
if (isCodeLocked) {
return (
<OverlayTrigger
overlay={ unlockWarning }
placement='right'
>
<Button
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ unlockUntrustedCode }
>
I trust this code. Unlock it.
</Button>
</OverlayTrigger>
);
}
return (
<Button
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ executeChallenge }
>
Run tests (ctrl + enter)
</Button>
);
}
render() {
const {
executeChallenge,
guideUrl,
hint,
isCodeLocked,
isDisabled,
openHelpModal,
unlockUntrustedCode
} = this.props;
return (
<div>
{ this.renderHint(hint, this.makeHintm, isDisabled) }
{
this.renderExecute(
isCodeLocked,
executeChallenge,
unlockUntrustedCode,
isDisabled
)
}
<div className='button-spacer' />
<Button
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ this.makeReset }
>
Reset your code
</Button>
<div className='button-spacer' />
{
guideUrl &&
<div>
<Button
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
href={ guideUrl }
target='_blank'
>
Get a hint
</Button>
<div className='button-spacer' />
</div>
}
<Button
block={ true }
bsStyle='primary'
className='btn-big'
disabled={ isDisabled }
onClick={ openHelpModal }
>
Ask for help on the forum
</Button>
<div className='button-spacer' />
</div>
);
}
}
ToolPanel.displayName = 'ToolPanel';
ToolPanel.propTypes = propTypes;
export default connect(mapStateToProps)(ToolPanel);

View File

@ -1,201 +0,0 @@
// should be the same as the filename and ./ns.json
@ns: challenges;
// challenge panes are bound to the pane size which in turn is
// bound to the total height minus navbar height
.max-element-height() {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
.@{ns}-title {
margin-top: 0;
i {
margin-left: 5px;
line-height: 20px;
}
}
.@{ns}-grayed-out-test-output {
color: @gray-light;
}
.@{ns}-test-suite {
margin-top: 10px;
& .row {
margin: 14px 0 0 0;
}
.big-icon {
font-size: 30px;
vertical-align: top;
}
.error-icon {
color: @brand-danger;
top: 50%;
}
.success-icon {
color: @brand-primary;
}
.refresh-icon {
color: @icon-gray;
}
}
.@{ns}-instructions-panel {
padding: 0 14px 0 14px;
}
.@{ns}-instructions {
margin-bottom: 5px;
h4 {
margin-bottom: 0;
}
blockquote {
font-size: 90%;
font-family: @font-family-monospace !important;
color: @code-color;
background-color: #fffbe5;
border-radius: @border-radius-base;
border: 1px solid @pre-border-color;
white-space: pre;
padding: 5px 10px;
margin-bottom: 10px;
margin-top: -5px;
overflow: auto;
}
dfn {
font-family: @font-family-monospace;
color: @code-color;
background-color: @code-bg;
border-radius: @border-radius-base;
padding: 1px 5px;
}
& a, #MDN-links a {
color: #31708f;
}
& a::after, #MDN-links a::after {
font-size: 70%;
font-family: FontAwesome;
content: " \f08e";
}
ol {
font-size: 16px;
}
}
.@{ns}-log {
border: 1px solid @brand-primary;
margin: 24px 0 0 0;
}
.night {
.@{ns}-instructions blockquote {
background-color: #242424;
border-color: #515151;
color: #ABABAB
}
.@{ns}-instructions dfn {
background-color: #242424;
color: #02a902;
}
.@{ns}-editor .CodeMirror {
background-color:@night-body-bg;
color:#ABABAB;
&-gutters {
background-color:@night-body-bg;
color:#ABABAB;
}
.cm-bracket, .cm-tag {
color:#5CAFD6;
}
.cm-property, .cm-string {
color:#B5753A;
}
.cm-keyword, .cm-attribute {
color:#9BBBDC;
}
&-line {
caret-color:#ABABAB;
}
}
.@{ns}-log {
border: none;
}
.refresh-icon {
color: @icon-light-gray;
}
.@{ns}-preview {
background-color: #FCFCFC;
}
}
.@{ns}-test-output {
font-size: 15px;
font-family: "Ubuntu Mono";
margin-top: 8px;
line-height:20px;
word-wrap: break-word;
}
.@{ns}-success-modal {
display: flex;
flex-direction: column;
justify-content: center;
height: 50vh;
.modal-title {
color: @gray-lighter;
}
.modal-header {
background-color: @brand-primary;
margin-bottom: 0;
.close {
color: #eee;
font-size: 4rem;
opacity: 0.6;
transition: all 300ms ease-out;
margin-top: 0;
padding-left: 0;
&:hover {
opacity: 1;
}
}
}
.modal-body {
padding: 35px;
display: flex;
flex-direction: column;
justify-content: center;
.fa {
margin-right: 0;
}
}
}
.@{ns}-preview {
.max-element-height();
width: 100%;
overflow-y: hidden;
}
.@{ns}-preview-frame {
.max-element-height();
border: none;
width: 100%;
}
.@{ns}-help-modal-heading > a {
font-size: inherit;
}
&{ @import "./views/index.less"; }

View File

@ -1,65 +0,0 @@
import _ from 'lodash';
import { isLocationAction, redirect } from 'redux-first-router';
import { types, challengeMetaSelector } from './redux';
import { mapStateToPanes as backendPanesMap } from './views/backend';
import { mapStateToPanes as classicPanesMap } from './views/classic';
import { mapStateToPanes as stepPanesMap } from './views/step';
import { mapStateToPanes as projectPanesMap } from './views/project';
import { mapStateToPanes as quizPanesMap } from './views/quiz';
import { mapStateToPanes as modernPanesMap } from './views/Modern';
import { types as app } from '../../redux';
import { locationTypeSelector } from '../../Router/redux';
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() {
const viewMap = {
[backendPanesMap]: backendPanesMap,
[classicPanesMap]: classicPanesMap,
[stepPanesMap]: stepPanesMap,
[projectPanesMap]: projectPanesMap,
[quizPanesMap]: quizPanesMap,
[modernPanesMap]: modernPanesMap
};
return (state, action) => {
// if a location action has dispatched then we must update the panesmap
if (isLocationAction(action)) {
let finalPanesMap = {};
// if we are on this route,
// then we must figure out the currect view we are on
// this depends on the type of challenge
if (action.type === types.onRouteChallenges) {
// location matches a panes route
const meta = challengeMetaSelector(state);
// if challenge data has not been fetched yet (as in the case of SSR)
// then we will get a pojo factory
const mapStateToPanes = viewMap[meta.viewType] || _.stubObject;
finalPanesMap = mapStateToPanes(state);
}
return finalPanesMap;
}
// This should normally happen during SSR
// here we are ensured that the challenge data has been fetched
// now we can select the appropriate panes map factory
if (
action.type === app.fetchChallenge.complete &&
locationTypeSelector(state) === types.onRouteChallenges
) {
const meta = challengeMetaSelector(state);
const mapStateToPanes = viewMap[meta.viewType] || _.stubObject;
return mapStateToPanes(state);
}
return null;
};
}
export { default } from './Show.jsx';

View File

@ -1 +0,0 @@
"challenges"

View File

@ -1,118 +0,0 @@
import _ from 'lodash';
import { Observable } from 'rx';
import cond from 'lodash/cond';
import flow from 'lodash/flow';
import identity from 'lodash/identity';
import matchesProperty from 'lodash/matchesProperty';
import partial from 'lodash/partial';
import stubTrue from 'lodash/stubTrue';
import {
fetchScript,
fetchLink
} from '../utils/fetch-and-cache.js';
import {
compileHeadTail,
setExt,
transformContents
} from '../../../../utils/polyvinyl';
const htmlCatch = '\n<!--fcc-->\n';
const jsCatch = '\n;/*fcc*/\n';
const loopProtector = `
window.loopProtect = parent.loopProtect;
window.__err = null;
window.loopProtect.hit = function(line) {
window.__err = new Error(
'Potential infinite loop at line ' +
line +
'. To disable loop protection, write:' +
' // noprotect as the first' +
' line. Beware that if you do have an infinite loop in your code' +
' this will crash your browser.'
);
};
`;
const defaultTemplate = ({ source }) => `
<body style='margin:8px;'>
<!-- fcc-start-source -->
${source}
<!-- fcc-end-source -->
</body>
`;
const wrapInScript = partial(transformContents, (content) => (
`${htmlCatch}<script>${loopProtector}${content}${jsCatch}</script>`
));
const wrapInStyle = partial(transformContents, (content) => (
`${htmlCatch}<style>${content}</style>`
));
const setExtToHTML = partial(setExt, 'html');
const padContentWithJsCatch = partial(compileHeadTail, jsCatch);
const padContentWithHTMLCatch = partial(compileHeadTail, htmlCatch);
export const jsToHtml = cond([
[
matchesProperty('ext', 'js'),
flow(padContentWithJsCatch, wrapInScript, setExtToHTML)
],
[ stubTrue, identity ]
]);
export const cssToHtml = cond([
[
matchesProperty('ext', 'css'),
flow(padContentWithHTMLCatch, wrapInStyle, setExtToHTML)
],
[ stubTrue, identity ]
]);
// FileStream::concactHtml(
// required: [ ...Object ],
// template: String
// ) => Observable[{ build: String, sources: Dictionary }]
export function concactHtml(required, template) {
const createBody = template ? _.template(template) : defaultTemplate;
const source = this.shareReplay();
const sourceMap = source
.flatMap(files => files.reduce((sources, file) => {
sources[file.name] = file.source || file.contents;
return sources;
}, {}));
const head = Observable.from(required)
.flatMap(required => {
if (required.src) {
return fetchScript(required);
}
if (required.link) {
return fetchLink(required);
}
return Observable.just('');
})
.reduce((head, required) => head + required, '')
.map(head => `<head>${head}</head>`);
const body = source
.flatMap(file => file.reduce((body, file) => {
return body + file.contents + file.tail + htmlCatch;
}, ''))
.map(source => ({ source }))
.map(createBody);
return Observable
.combineLatest(
head,
body,
fetchScript({
src: '/js/frame-runner.js',
crossDomain: false,
cacheBreaker: true
}),
sourceMap,
(head, body, frameRunner, sources) => ({
build: head + body + frameRunner,
sources
})
);
}

View File

@ -1,124 +0,0 @@
import { Observable } from 'rx';
import cond from 'lodash/cond';
import identity from 'lodash/identity';
import stubTrue from 'lodash/stubTrue';
import conforms from 'lodash/conforms';
import castToObservable from '../../../utils/cast-to-observable.js';
const HTML$JSReg = /html|js/;
const testHTMLJS = conforms({ ext: (ext) => HTML$JSReg.test(ext) });
// const testJS = matchesProperty('ext', 'js');
const passToNext = [ stubTrue, identity ];
// Detect if a JS multi-line comment is left open
const throwIfOpenComments = cond([
[
testHTMLJS,
function _checkForComments({ contents }) {
const openingComments = contents.match(/\/\*/gi);
const closingComments = contents.match(/\*\//gi);
if (
openingComments &&
(!closingComments || openingComments.length > closingComments.length)
) {
throw new SyntaxError('Unfinished multi-line comment');
}
}
],
passToNext
]);
// Nested dollar sign calls breaks browsers
const nestedJQCallReg = /\$\s*?\(\s*?\$\s*?\)/gi;
const throwIfNestedJquery = cond([
[
testHTMLJS,
function({ contents }) {
if (nestedJQCallReg.test(contents)) {
throw new SyntaxError('Nested jQuery calls breaks browsers');
}
}
],
passToNext
]);
const functionReg = /function/g;
const functionCallReg = /function\s*?\(|function\s+\w+\s*?\(/gi;
// lonely function keywords breaks browsers
const ThrowIfUnfinishedFunction = cond([
[
testHTMLJS,
function({ contents }) {
if (
functionReg.test(contents) &&
!functionCallReg.test(contents)
) {
throw new SyntaxError(
'Unsafe or unfinished function declaration'
);
}
}
],
passToNext
]);
// console call stops tests scripts from running
const unsafeConsoleCallReg = /if\s\(null\)\sconsole\.log\(1\);/gi;
const throwIfUnsafeConsoleCall = cond([
[
testHTMLJS,
function({ contents }) {
if (unsafeConsoleCallReg.test(contents)) {
throw new SyntaxError(
'`if (null) console.log(1)` detected. This will break tests'
);
}
}
],
passToNext
]);
// Code with the URL hyperdev.com should not be allowed to run,
const goMixReg = /glitch\.(com|me)/gi;
const throwIfGomixDetected = cond([
[
testHTMLJS,
function({ contents }) {
if (goMixReg.test(contents)) {
throw new Error('Glitch.com or Glitch.me should not be in the code');
}
}
],
passToNext
]);
const validators = [
throwIfOpenComments,
throwIfGomixDetected,
throwIfNestedJquery,
ThrowIfUnfinishedFunction,
throwIfUnsafeConsoleCall
];
export default function validate(file) {
return validators.reduce((obs, validator) => obs.flatMap(file => {
try {
return castToObservable(validator(file));
} catch (err) {
return Observable.throw(err);
}
}), Observable.of(file))
// if no error has occurred map to the original file
.map(() => file)
// if err add it to the file
// and return file
.catch(err => {
file.error = err;
return Observable.just(file);
});
}

View File

@ -1,162 +0,0 @@
import {
attempt,
cond,
flow,
identity,
isError,
matchesProperty,
overEvery,
overSome,
partial,
stubTrue
} from 'lodash';
import * as babel from 'babel-core';
import presetEs2015 from 'babel-preset-es2015';
import presetReact from 'babel-preset-react';
import { Observable } from 'rx';
import * as vinyl from '../../../../utils/polyvinyl.js';
import castToObservable from '../../../utils/cast-to-observable.js';
const babelOptions = { presets: [ presetEs2015, presetReact ] };
const babelTransformCode = code => babel.transform(code, babelOptions).code;
function loopProtectHit(line) {
var err = 'Exiting potential infinite loop at line ' +
line +
'. To disable loop protection, write: \n\/\/ noprotect\nas the first ' +
'line. Beware that if you do have an infinite loop in your code, ' +
'this will crash your browser.';
throw new Error(err);
}
// const sourceReg =
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
const console$logReg = /(?:\b)console(\.log\S+)/g;
const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const isJS = matchesProperty('ext', 'js');
const testHTMLJS = overSome(isJS, matchesProperty('ext', 'html'));
export const testJS$JSX = overSome(isJS, matchesProperty('ext', 'jsx'));
// work around the absence of multi-flile editing
// this can be replaced with `matchesProperty('ext', 'sass')`
// when the time comes
const testSASS = file => (/type='text\/sass'/i).test(file.contents);
// This can be done in the transformer when we have multi-file editing
const browserSassCompiler = `
<script>
var styleTags = [ ...document.querySelectorAll('style') ];
[].slice.call(styleTags, 1).forEach(
function compileSass(tag) {
var scss = tag.innerHTML;
Sass.compile(scss, function(result) {
tag.type = 'text/css';
tag.innerHTML = result.text;
});
}
)
</script>
`;
// if shouldProxyConsole then we change instances of console log
// to `window.__console.log`
// this let's us tap into logging into the console.
// currently we only do this to the main window and not the test window
export const proxyLoggerTransformer = partial(
vinyl.transformHeadTailAndContents,
source => (
source.replace(console$logReg, (match, methodCall) => {
return 'window.__console' + methodCall;
})),
);
const addLoopProtect = partial(
vinyl.transformContents,
contents => {
/* eslint-disable import/no-unresolved */
const loopProtect = require('loop-protect');
/* eslint-enable import/no-unresolved */
loopProtect.hit = loopProtectHit;
return loopProtect(contents);
}
);
export const addLoopProtectHtmlJsJsx = cond([
[
overEvery(
testHTMLJS,
partial(
vinyl.testContents,
contents => contents.toLowerCase().includes('<script>')
)
),
addLoopProtect
],
[ testJS$JSX, addLoopProtect ],
[ stubTrue, identity ]
]);
export const replaceNBSP = cond([
[
testHTMLJS,
partial(
vinyl.transformContents,
contents => contents.replace(NBSPReg, ' ')
)
],
[ stubTrue, identity ]
]);
function tryTransform(wrap = identity) {
return function transformWrappedPoly(source) {
const result = attempt(wrap, source);
if (isError(result)) {
const friendlyError = `${result}`
.match(/[\w\W]+?\n/)[0]
.replace(' unknown:', '');
throw new Error(friendlyError);
}
return result;
};
}
export const babelTransformer = cond([
[
testJS$JSX,
flow(
partial(
vinyl.transformHeadTailAndContents,
tryTransform(babelTransformCode)
),
partial(vinyl.setExt, 'js')
)
],
[ stubTrue, identity ]
]);
export const sassTransformer = cond([
[
testSASS,
partial(
vinyl.appendToTail,
browserSassCompiler
)
],
[ stubTrue, identity ]
]);
export const _transformers = [
addLoopProtectHtmlJsJsx,
replaceNBSP,
babelTransformer,
sassTransformer
];
export function applyTransformers(file, transformers = _transformers) {
return transformers.reduce(
(obs, transformer) => {
return obs.flatMap(file => castToObservable(transformer(file)));
},
Observable.of(file)
);
}

View File

@ -1,80 +0,0 @@
import _ from 'lodash';
import { Observable } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import {
types,
challengeUpdated,
onRouteChallenges,
onRouteCurrentChallenge
} from './';
import {
createErrorObservable,
challengeSelector,
nextChallengeSelector
} from '../../../redux';
import { langSelector } from '../../../Router/redux';
import { makeToast } from '../../../Toasts/redux';
// When we change challenge, update the current challenge
// UI data.
export function challengeUpdatedEpic(actions, { getState }) {
return actions::ofType(types.onRouteChallenges)
// prevent subsequent onRouteChallenges to cause UI to refresh
.distinctUntilChanged(({ payload: { dashedName }}) => dashedName)
.map(() => challengeSelector(getState()))
// if the challenge isn't loaded in the current state,
// this will be an empty object
// We wait instead for the fetchChallenge.complete to complete the UI state
.filter(({ dashedName }) => !!dashedName)
// send the challenge to update UI and trigger main iframe to update
// use unary to prevent index from being passed to func
.map(_.unary(challengeUpdated));
}
// used to reset users code on request
export function resetChallengeEpic(actions, { getState }) {
return actions::ofType(types.clickOnReset)
.map(_.flow(getState, challengeSelector, challengeUpdated));
}
export function nextChallengeEpic(actions, { getState }) {
return actions::ofType(types.moveToNextChallenge)
.flatMap(() => {
const state = getState();
const lang = langSelector(state);
const { nextChallenge } = nextChallengeSelector(state);
if (!nextChallenge) {
return createErrorObservable(
new Error('Next Challenge could not be found')
);
}
if (nextChallenge.isLocked) {
return Observable.of(
makeToast({
message: 'The next challenge has not been unlocked. ' +
'Please revisit the required (*) challenges ' +
'that have not been passed yet. ',
timeout: 15000
}),
onRouteCurrentChallenge()
);
}
return Observable.of(
// 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.' })
);
});
}
export default combineEpics(
challengeUpdatedEpic,
nextChallengeEpic,
resetChallengeEpic
);

View File

@ -1,196 +0,0 @@
import { Observable } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import store from 'store';
import {
types,
storedCodeFound,
noStoredCodeFound,
previousSolutionFound,
keySelector,
codeLockedSelector
} from './';
import { removeCodeUri, getCodeUri } from '../utils/code-uri.js';
import {
types as app,
challengeSelector,
previousSolutionSelector
} from '../../../redux';
import { filesSelector } from '../../../files';
import { makeToast } from '../../../Toasts/redux';
import { setContent, isPoly } from '../../../../utils/polyvinyl.js';
const legacyPrefixes = [
'Bonfire: ',
'Waypoint: ',
'Zipline: ',
'Basejump: ',
'Checkpoint: '
];
const legacyPostfix = 'Val';
function getCode(id) {
if (store.has(id)) {
return store.get(id);
}
return null;
}
function getLegacyCode(legacy) {
const key = legacy + legacyPostfix;
let code = null;
if (store.has(key)) {
code = '' + store.get(key);
store.remove(key);
return code;
}
return legacyPrefixes.reduce((code, prefix) => {
if (code) {
return code;
}
return store.get(prefix + key);
}, null);
}
function legacyToFile(code, files, key) {
if (isPoly(files)) {
return { [key]: setContent(code, files[key]) };
}
return false;
}
function clearCodeEpic(actions, { getState }) {
return actions::ofType(
types.submitChallenge.complete,
types.clickOnReset
)
.do(() => {
const { id } = challengeSelector(getState());
store.remove(id);
})
.ignoreElements();
}
function saveCodeEpic(actions, { getState }) {
return actions::ofType(types.executeChallenge)
// do not save challenge if code is locked
.filter(() => !codeLockedSelector(getState()))
.do(() => {
const { id } = challengeSelector(getState());
const files = filesSelector(getState());
store.set(id, files);
})
.ignoreElements();
}
function loadCodeEpic(actions, { getState }, { window, location }) {
return Observable.merge(
actions::ofType(app.appMounted),
actions::ofType(types.onRouteChallenges)
.distinctUntilChanged(({ payload: { dashedName } }) => dashedName)
)
// make sure we are not SSR
.filter(() => !!window)
.flatMap(() => {
let finalFiles;
const state = getState();
const challenge = challengeSelector(state);
const key = keySelector(state);
const files = filesSelector(state);
const {
id,
name: legacyKey
} = challenge;
const codeUriFound = getCodeUri(
location,
window.decodeURIComponent
);
if (codeUriFound) {
finalFiles = legacyToFile(codeUriFound, files, key);
removeCodeUri(location, window.history);
return Observable.of(
makeToast({
message: 'I found code in the URI. Loading now.'
}),
storedCodeFound(challenge, finalFiles)
);
}
const codeFound = getCode(id);
if (codeFound && isPoly(codeFound)) {
finalFiles = codeFound;
} else {
const legacyCode = getLegacyCode(legacyKey);
if (legacyCode) {
finalFiles = legacyToFile(legacyCode, files, key);
}
}
if (finalFiles) {
return Observable.of(
makeToast({
message: 'I found some saved work. Loading now.'
}),
storedCodeFound(challenge, finalFiles)
);
}
if (previousSolutionSelector(getState())) {
const userChallenge = previousSolutionSelector(getState());
if (userChallenge.files) {
finalFiles = userChallenge.files;
} else if (userChallenge.solution) {
finalFiles = legacyToFile(userChallenge.solution, files, key);
}
if (finalFiles) {
return Observable.of(
makeToast({
message: 'I found a previous solved solution. Loading now.'
}),
previousSolutionFound(challenge, finalFiles)
);
}
}
return Observable.of(noStoredCodeFound());
});
}
function findPreviousSolutionEpic(actions, { getState }) {
return Observable.combineLatest(
actions::ofType(types.noStoredCodeFound),
actions::ofType(app.fetchUser.complete)
)
.map(() => previousSolutionSelector(getState()))
.filter(Boolean)
.flatMap(userChallenge => {
const challenge = challengeSelector(getState());
let finalFiles;
if (userChallenge.files) {
finalFiles = userChallenge.files;
} else if (userChallenge.solution) {
const files = filesSelector(getState());
const key = keySelector(getState());
finalFiles = legacyToFile(userChallenge.solution, files, key);
}
if (finalFiles) {
return Observable.of(
makeToast({
message: 'I found a previous solved solution. Loading now.'
}),
previousSolutionFound(challenge, finalFiles)
);
}
return Observable.empty();
});
}
export default combineEpics(
saveCodeEpic,
loadCodeEpic,
clearCodeEpic,
findPreviousSolutionEpic
);

View File

@ -1,175 +0,0 @@
import { Observable } from 'rx';
import { ofType } from 'redux-epic';
import {
backendFormValuesSelector,
frontendProjectFormValuesSelector,
backendProjectFormValuesSelector,
challengeMetaSelector,
moveToNextChallenge,
submitChallengeComplete,
testsSelector,
types,
closeChallengeModal
} from './';
import {
challengeSelector,
createErrorObservable,
csrfSelector,
userSelector
} from '../../../redux';
import { filesSelector } from '../../../files';
import { backEndProject } from '../../../utils/challengeTypes.js';
import { makeToast } from '../../../Toasts/redux';
import { postJSON$ } from '../../../../utils/ajax-stream.js';
function postChallenge(url, username, _csrf, challengeInfo) {
return Observable.if(
() => !!username,
Observable.defer(() => {
const body = { ...challengeInfo, _csrf };
const saveChallenge = postJSON$(url, body)
.retry(3)
.map(({ points, lastUpdated, completedDate }) =>
submitChallengeComplete(
username,
points,
{ ...challengeInfo, lastUpdated, completedDate }
)
)
.catch(createErrorObservable);
const challengeCompleted = Observable.of(moveToNextChallenge());
return Observable.merge(saveChallenge, challengeCompleted)
.startWith({ type: types.submitChallenge.start });
}),
Observable.of(moveToNextChallenge())
);
}
function submitModern(type, state) {
const tests = testsSelector(state);
if (tests.length > 0 && tests.every(test => test.pass && !test.err)) {
if (type === types.checkChallenge) {
return Observable.empty();
}
if (type === types.submitChallenge.toString()) {
const { id } = challengeSelector(state);
const files = filesSelector(state);
const { username } = userSelector(state);
const csrfToken = csrfSelector(state);
return postChallenge(
'/modern-challenge-completed',
username,
csrfToken,
{ id, files }
);
}
}
return Observable.just(
makeToast({ message: 'Keep trying.' })
);
}
function submitProject(type, state) {
if (type === types.checkChallenge) {
return Observable.empty();
}
const {
solution: frontEndSolution = ''
} = frontendProjectFormValuesSelector(state);
const {
solution: backendSolution = '',
githubLink = ''
} = backendProjectFormValuesSelector(state);
const solution = frontEndSolution ? frontEndSolution : backendSolution;
const { id, challengeType } = challengeSelector(state);
const { username } = userSelector(state);
const csrfToken = csrfSelector(state);
const challengeInfo = { id, challengeType, solution };
if (challengeType === backEndProject) {
challengeInfo.githubLink = githubLink;
}
return Observable.merge(
postChallenge(
'/project-completed',
username,
csrfToken,
challengeInfo
),
Observable.of(
closeChallengeModal()
)
);
}
function submitSimpleChallenge(type, state) {
const { id } = challengeSelector(state);
const { username } = userSelector(state);
const csrfToken = csrfSelector(state);
const challengeInfo = { id };
return postChallenge(
'/challenge-completed',
username,
csrfToken,
challengeInfo
);
}
function submitBackendChallenge(type, state) {
const tests = testsSelector(state);
if (
type === types.checkChallenge &&
tests.length > 0 &&
tests.every(test => test.pass && !test.err)
) {
/*
return Observable.of(
makeToast({
message: `${randomCompliment()} Go to your next challenge.`,
action: 'Submit',
actionCreator: 'submitChallenge',
timeout: 10000
})
);
*/
return Observable.empty();
}
if (type === types.submitChallenge.toString()) {
const { id } = challengeSelector(state);
const { username } = userSelector(state);
const csrfToken = csrfSelector(state);
const { solution } = backendFormValuesSelector(state);
const challengeInfo = { id, solution };
return postChallenge(
'/backend-challenge-completed',
username,
csrfToken,
challengeInfo
);
}
return Observable.just(
makeToast({ message: 'Keep trying.' })
);
}
const submitters = {
tests: submitModern,
backend: submitBackendChallenge,
step: submitSimpleChallenge,
video: submitSimpleChallenge,
quiz: submitSimpleChallenge,
'project.frontEnd': submitProject,
'project.backEnd': submitProject
};
export default function completionEpic(actions, { getState }) {
return actions::ofType(types.checkChallenge, types.submitChallenge.toString())
.flatMap(({ type, payload }) => {
const state = getState();
const { submitType } = challengeMetaSelector(state);
const submitter = submitters[submitType] || (() => Observable.empty());
return submitter(type, state, payload);
});
}

View File

@ -1,125 +0,0 @@
import _ from 'lodash';
import { Observable, Subject } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import {
types,
initOutput,
updateOutput,
updateTests,
checkChallenge,
codeLockedSelector,
showPreviewSelector,
testsSelector,
disableJSOnError
} from './';
import {
buildFromFiles,
buildBackendChallenge
} from '../utils/build.js';
import {
runTestsInTestFrame,
createTestFramer,
createMainFramer
} from '../utils/frame.js';
import {
createErrorObservable,
challengeSelector,
doActionOnError
} from '../../../redux';
import { backend } from '../../../utils/challengeTypes';
const executeDebounceTimeout = 750;
export function updateMainEpic(actions, { getState }, { document }) {
return Observable.of(document)
// if document is not defined then none of this epic will run
// this prevents issues during SSR
.filter(Boolean)
.flatMapLatest(() => {
const proxyLogger = new Subject();
const frameMain = createMainFramer(document, getState, proxyLogger);
const buildAndFrameMain = actions::ofType(
types.unlockUntrustedCode,
types.modernEditorUpdated,
types.classicEditorUpdated,
types.executeChallenge,
types.challengeUpdated
)
.debounce(executeDebounceTimeout)
// if isCodeLocked do not run challenges
.filter(() => (
!codeLockedSelector(getState()) &&
showPreviewSelector(getState())
))
.flatMapLatest(() => buildFromFiles(getState(), true)
.map(frameMain)
.ignoreElements()
.catch(doActionOnError(() => disableJSOnError()))
);
return Observable.merge(buildAndFrameMain, proxyLogger.map(updateOutput));
});
}
export function executeChallengeEpic(actions, { getState }, { document }) {
return Observable.of(document)
// if document is not defined then none of this epic will run
// this prevents issues during SSR
.filter(Boolean)
.flatMapLatest(() => {
const frameReady = new Subject();
const frameTests = createTestFramer(document, getState, frameReady);
const challengeResults = frameReady
.pluck('checkChallengePayload')
.map(checkChallengePayload => ({
checkChallengePayload,
tests: testsSelector(getState())
}))
.flatMap(({ checkChallengePayload, tests }) => {
const postTests = Observable.of(
updateOutput('// tests completed'),
checkChallenge(checkChallengePayload)
).delay(250);
// run the tests within the test iframe
return runTestsInTestFrame(document, tests)
.flatMap(tests => {
return Observable.from(tests)
.map(({ message }) => message)
// make sure that the test message is a non empty string
.filter(_.overEvery(_.isString, Boolean))
.map(updateOutput)
.concat(Observable.of(updateTests(tests)));
})
.concat(postTests);
});
const buildAndFrameChallenge = actions::ofType(types.executeChallenge)
.debounce(executeDebounceTimeout)
// if isCodeLocked do not run challenges
.filter(() => !codeLockedSelector(getState()))
.flatMapLatest(() => {
const state = getState();
const { challengeType } = challengeSelector(state);
if (challengeType === backend) {
return buildBackendChallenge(state)
.do(frameTests)
.ignoreElements()
.startWith(initOutput('// running test'))
.catch(createErrorObservable);
}
return buildFromFiles(state, false)
.do(frameTests)
.ignoreElements()
.startWith(initOutput('// running test'))
.catch(createErrorObservable);
});
return Observable.merge(
buildAndFrameChallenge,
challengeResults
);
});
}
export default combineEpics(executeChallengeEpic, updateMainEpic);

View File

@ -1,351 +0,0 @@
import _ from 'lodash';
import {
combineActions,
combineReducers,
createAction,
createAsyncTypes,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import { createSelector } from 'reselect';
import noop from 'lodash/noop';
import { getValues } from 'redux-form';
import modalEpic from './modal-epic';
import completionEpic from './completion-epic.js';
import challengeEpic from './challenge-epic.js';
import executeChallengeEpic from './execute-challenge-epic.js';
import codeStorageEpic from './code-storage-epic.js';
import ns from '../ns.json';
import stepReducer, { epics as stepEpics } from '../views/step/redux';
import quizReducer from '../views/quiz/redux';
import projectReducer from '../views/project/redux';
import {
createTests,
loggerToStr,
submitTypes,
viewTypes,
getFileKey,
challengeToFilesMetaCreator
} from '../utils';
import {
types as app,
challengeSelector
} from '../../../redux';
import { html, modern } from '../../../utils/challengeTypes.js';
import blockNameify from '../../../utils/blockNameify.js';
import { updateFileMetaCreator } from '../../../files';
// this is not great but is ok until we move to a different form type
export projectNormalizer from '../views/project/redux';
export const epics = [
modalEpic,
challengeEpic,
codeStorageEpic,
completionEpic,
executeChallengeEpic,
...stepEpics
];
export const types = createTypes([
'onRouteChallengeRoot',
'onRouteChallenges',
'onRouteCurrentChallenge',
// challenges
// |- classic
'classicEditorUpdated',
'challengeUpdated',
'clickOnReset',
'updateHint',
'unlockUntrustedCode',
'closeChallengeModal',
'openChallengeModal',
'updateSuccessMessage',
// |- modern
'modernEditorUpdated',
// rechallenge
'executeChallenge',
'updateOutput',
'initOutput',
'updateTests',
'checkChallenge',
createAsyncTypes('submitChallenge'),
'moveToNextChallenge',
'disableJSOnError',
'checkForNextBlock',
// help
'openHelpModal',
'closeHelpModal',
'createQuestion',
// panes
'toggleClassicEditor',
'toggleMain',
'toggleMap',
'togglePreview',
'toggleSidePanel',
'toggleStep',
'toggleModernEditor',
// code storage
'storedCodeFound',
'noStoredCodeFound',
'previousSolutionFound'
], ns);
// routes
export const onRouteChallenges = createAction(types.onRouteChallenges);
export const onRouteCurrentChallenge =
createAction(types.onRouteCurrentChallenge);
// classic
export const classicEditorUpdated = createAction(
types.classicEditorUpdated,
null,
updateFileMetaCreator
);
// modern
export const modernEditorUpdated = createAction(
types.modernEditorUpdated,
null,
updateFileMetaCreator
);
// challenges
export const closeChallengeModal = createAction(types.closeChallengeModal);
export const openChallengeModal = createAction(types.openChallengeModal);
export const updateHint = createAction(types.updateHint);
export const unlockUntrustedCode = createAction(
types.unlockUntrustedCode,
_.noop
);
export const updateSuccessMessage = createAction(types.updateSuccessMessage);
export const challengeUpdated = createAction(
types.challengeUpdated,
challenge => ({ challenge }),
challengeToFilesMetaCreator
);
export const clickOnReset = createAction(types.clickOnReset);
// rechallenge
export const executeChallenge = createAction(
types.executeChallenge,
noop,
);
export const updateTests = createAction(types.updateTests);
export const initOutput = createAction(types.initOutput, loggerToStr);
export const updateOutput = createAction(types.updateOutput, loggerToStr);
export const checkChallenge = createAction(types.checkChallenge);
export const submitChallenge = createAction(types.submitChallenge);
export const submitChallengeComplete = createAction(
types.submitChallenge.complete,
(username, points, challengeInfo) => ({ username, points, challengeInfo })
);
export const moveToNextChallenge = createAction(types.moveToNextChallenge);
export const checkForNextBlock = createAction(types.checkForNextBlock);
export const disableJSOnError = createAction(types.disableJSOnError);
// help
export const openHelpModal = createAction(types.openHelpModal);
export const closeHelpModal = createAction(types.closeHelpModal);
export const createQuestion = createAction(types.createQuestion);
// code storage
export const storedCodeFound = createAction(
types.storedCodeFound,
null,
challengeToFilesMetaCreator,
);
export const noStoredCodeFound = createAction(types.noStoredCodeFound);
export const previousSolutionFound = createAction(
types.previousSolutionFound,
null,
challengeToFilesMetaCreator
);
const initialUiState = {
output: null,
isChallengeModalOpen: false,
isBugOpen: false,
isHelpOpen: false,
successMessage: 'Happy Coding!'
};
const initialState = {
isCodeLocked: false,
isJSEnabled: true,
id: '',
challenge: '',
helpChatRoom: 'Help',
// old code storage key
legacyKey: '',
// map
superBlocks: [],
// misc
...initialUiState
};
export const getNS = state => state[ns];
export const keySelector = state => getNS(state).key;
export const testsSelector = state => getNS(state).tests;
export const outputSelector = state => getNS(state).output;
export const successMessageSelector = state => getNS(state).successMessage;
export const hintIndexSelector = state => getNS(state).hintIndex;
export const codeLockedSelector = state => getNS(state).isCodeLocked;
export const isCodeLockedSelector = state => getNS(state).isCodeLocked;
export const isJSEnabledSelector = state => getNS(state).isJSEnabled;
export const chatRoomSelector = state => getNS(state).helpChatRoom;
export const challengeModalSelector =
state => getNS(state).isChallengeModalOpen;
export const helpModalSelector = state => getNS(state).isHelpOpen;
export const guideURLSelector = state =>
`https://guide.freecodecamp.org/certificates/${getNS(state).challenge}`;
export const challengeRequiredSelector = state =>
challengeSelector(state).required || [];
export const challengeMetaSelector = createSelector(
// use closure to get around circular deps
(...args) => challengeSelector(...args),
challenge => {
if (!challenge.id) {
const viewType = 'invalid';
return {
viewType
};
}
const challengeType = challenge && challenge.challengeType;
const type = challenge && challenge.type;
const viewType = viewTypes[type] || viewTypes[challengeType] || 'classic';
const blockName = blockNameify(challenge.block);
const title = blockName && challenge.title ?
`${blockName}: ${challenge.title}` :
challenge.title;
return {
type,
title,
viewType,
submitType:
submitTypes[challengeType] ||
submitTypes[challenge && challenge.type] ||
'tests',
showPreview: (
challengeType === html ||
challengeType === modern
),
mode: challenge && challengeType === html ?
'text/html' :
'javascript'
};
}
);
export const showPreviewSelector = state =>
!!challengeMetaSelector(state).showPreview;
export const challengeTypeSelector = state =>
challengeMetaSelector(state).type || '';
export const challengeTemplateSelector = state =>
challengeSelector(state).template || null;
export const backendFormValuesSelector = state =>
getValues(state.form.BackEndChallenge);
export const frontendProjectFormValuesSelector = state =>
getValues(state.form.NewFrontEndProject) || {};
export const backendProjectFormValuesSelector = state =>
getValues(state.form.NewBackEndProject) || {};
export default combineReducers(
handleActions(
() => ({
[
combineActions(
types.challengeUpdated,
app.fetchChallenge.complete
)
]: (state, { payload: { challenge } }) => {
return {
...state,
...initialUiState,
id: challenge.id,
challenge: challenge.dashedName,
key: getFileKey(challenge),
tests: createTests(challenge),
helpChatRoom: challenge.helpRoom || 'Help'
};
},
[types.updateTests]: (state, { payload: tests }) => ({
...state,
tests,
isChallengeModalOpen: (
tests.length > 0 &&
tests.every(test => test.pass && !test.err)
)
}),
[types.openChallengeModal]: state => ({
...state,
isChallengeModalOpen: true
}),
[types.closeChallengeModal]: state => ({
...state,
isChallengeModalOpen: false
}),
[types.updateSuccessMessage]: (state, { payload }) => ({
...state,
successMessage: payload
}),
[types.storedCodeFound]: state => ({
...state,
isJSEnabled: false,
isCodeLocked: true
}),
[types.unlockUntrustedCode]: state => ({
...state,
isCodeLocked: false
}),
[types.executeChallenge]: state => ({
...state,
isJSEnabled: true,
tests: state.tests.map(test => ({ ...test, err: false, pass: false }))
}),
[
combineActions(
types.classicEditorUpdated,
types.disableJSOnError,
types.modernEditorUpdated
)
]: state => ({
...state,
isJSEnabled: false
}),
// classic/modern
[types.initOutput]: (state, { payload: output }) => ({
...state,
output
}),
[types.updateOutput]: (state, { payload: output }) => ({
...state,
output: (state.output || '') + output
}),
[types.openHelpModal]: state => ({ ...state, isHelpOpen: true }),
[types.closeHelpModal]: state => ({ ...state, isHelpOpen: false })
}),
initialState,
ns
),
stepReducer,
quizReducer,
projectReducer
);

View File

@ -1,64 +0,0 @@
import { ofType } from 'redux-epic';
import {
types,
closeHelpModal
} from '../redux';
import { filesSelector } from '../../../files';
import { currentChallengeSelector } from '../../../redux';
function filesToMarkdown(files = {}) {
const moreThenOneFile = Object.keys(files).length > 1;
return Object.keys(files).reduce((fileString, key) => {
const file = files[key];
if (!file) {
return fileString;
}
const fileName = moreThenOneFile ? `\\ file: ${file.contents}` : '';
const fileType = file.ext;
return fileString +
'\`\`\`' +
fileType +
'\n' +
fileName +
'\n' +
file.contents +
'\n' +
'\`\`\`\n\n';
}, '\n');
}
export function createQuestionEpic(actions, { getState }, { window }) {
return actions::ofType(types.createQuestion).map(() => {
const state = getState();
const files = filesSelector(state);
const challengeName = currentChallengeSelector(state);
const {
navigator: { userAgent },
location: { href }
} = window;
const textMessage = [
'**Tell us what\'s happening:**\n\n\n\n',
'**Your code so far**\n',
filesToMarkdown(files),
'**Your browser information:**\n\n',
'User Agent is: <code>',
userAgent,
'</code>.\n\n',
'**Link to the challenge:**\n',
href
].join('');
window.open(
'https://forum.freecodecamp.org/new-topic'
+ '?category=help'
+ '&title=' + window.encodeURIComponent(challengeName)
+ '&body=' + window.encodeURIComponent(textMessage),
'_blank'
);
return closeHelpModal();
});
}
export default createQuestionEpic;

View File

@ -1,85 +0,0 @@
import { Observable } from 'rx';
import identity from 'lodash/identity';
import { fetchScript } from './fetch-and-cache.js';
import throwers from '../rechallenge/throwers';
import {
backendFormValuesSelector,
challengeTemplateSelector,
challengeRequiredSelector,
isJSEnabledSelector
} from '../redux';
import {
applyTransformers,
proxyLoggerTransformer,
testJS$JSX
} from '../rechallenge/transformers';
import {
cssToHtml,
jsToHtml,
concactHtml
} from '../rechallenge/builders.js';
import { filesSelector } from '../../../files';
import {
createFileStream,
pipe
} from '../../../../utils/polyvinyl.js';
const jQuery = {
src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'
};
const frameRunner = {
src: '/js/frame-runner.js',
crossDomain: false,
cacheBreaker: true
};
const globalRequires = [
{
link: 'https://cdnjs.cloudflare.com/' +
'ajax/libs/normalize/4.2.0/normalize.min.css'
},
jQuery
];
function filterJSIfDisabled(state) {
const isJSEnabled = isJSEnabledSelector(state);
return file => {
if (testJS$JSX(file) && !isJSEnabled) {
return null;
}
return file;
};
}
export function buildFromFiles(state, shouldProxyConsole) {
const files = filesSelector(state);
const required = challengeRequiredSelector(state);
const finalRequires = [...globalRequires, ...required ];
const requiredFiles = Object.keys(files)
.map(key => files[key])
.filter(filterJSIfDisabled(state))
.filter(Boolean);
return createFileStream(requiredFiles)
::pipe(throwers)
::pipe(applyTransformers)
::pipe(shouldProxyConsole ? proxyLoggerTransformer : identity)
::pipe(jsToHtml)
::pipe(cssToHtml)
::concactHtml(finalRequires, challengeTemplateSelector(state));
}
export function buildBackendChallenge(state) {
const { solution: url } = backendFormValuesSelector(state);
return Observable.combineLatest(
fetchScript(frameRunner),
fetchScript(jQuery)
)
.map(([ frameRunner, jQuery ]) => ({
build: jQuery + frameRunner,
sources: { url },
checkChallengePayload: { solution: url }
}));
}

View File

@ -1,79 +0,0 @@
import _ from 'lodash';
import { decodeFcc } from '../../../../utils/encode-decode';
const queryRegex = /^(\?|#\?)/;
export function legacyIsInQuery(query, decode) {
let decoded;
try {
decoded = decode(query);
} catch (err) {
return false;
}
if (!decoded || typeof decoded.split !== 'function') {
return false;
}
return decoded
.replace(queryRegex, '')
.split('&')
.reduce(function(found, param) {
var key = param.split('=')[0];
if (key === 'solution') {
return true;
}
return found;
}, false);
}
export function getKeyInQuery(query, keyToFind = '') {
return query
.split('&')
.reduce((oldValue, param) => {
const key = param.split('=')[0];
const value = param
.split('=')
.slice(1)
.join('=');
if (key === keyToFind) {
return value;
}
return oldValue;
}, null);
}
export function getLegacySolutionFromQuery(query = '', decode) {
return _.flow(
getKeyInQuery,
decode,
decodeFcc
)(query, 'solution');
}
export function getCodeUri(location, decodeURIComponent) {
let query;
if (
location.search &&
legacyIsInQuery(location.search, decodeURIComponent)
) {
query = location.search.replace(/^\?/, '');
} else {
return null;
}
return getLegacySolutionFromQuery(query, decodeURIComponent);
}
export function removeCodeUri(location, history) {
if (
typeof location.href.split !== 'function' ||
typeof history.replaceState !== 'function'
) {
return false;
}
history.replaceState(
history.state,
null,
location.href.split('?')[0]
);
return true;
}

View File

@ -1,74 +0,0 @@
import { Observable } from 'rx';
import { ajax$ } from '../../../../utils/ajax-stream';
// value used to break browser ajax caching
const cacheBreakerValue = Math.random();
export function _fetchScript(
{
src,
cacheBreaker = false,
crossDomain = true
} = {},
) {
if (!src) {
throw new Error('No source provided for script');
}
if (this.cache.has(src)) {
return this.cache.get(src);
}
const url = cacheBreaker ?
`${src}?cacheBreaker=${cacheBreakerValue}` :
src;
const script = ajax$({ url, crossDomain })
.doOnNext(res => {
if (res.status !== 200) {
throw new Error('Request errror: ' + res.status);
}
})
.map(({ response }) => response)
.map(script => `<script>${script}</script>`)
.shareReplay();
this.cache.set(src, script);
return script;
}
export const fetchScript = _fetchScript.bind({ cache: new Map() });
export function _fetchLink(
{
link: href,
raw = false,
crossDomain = true
} = {},
) {
if (!href) {
return Observable.throw(new Error('No source provided for link'));
}
if (this.cache.has(href)) {
return this.cache.get(href);
}
// css files with `url(...` may not work in style tags
// so we put them in raw links
if (raw) {
const link = Observable.just(`<link href=${href} rel='stylesheet' />`)
.shareReplay();
this.cache.set(href, link);
return link;
}
const link = ajax$({ url: href, crossDomain })
.doOnNext(res => {
if (res.status !== 200) {
throw new Error('Request error: ' + res.status);
}
})
.map(({ response }) => response)
.map(script => `<style>${script}</style>`)
.catch(() => Observable.just(''))
.shareReplay();
this.cache.set(href, link);
return link;
}
export const fetchLink = _fetchLink.bind({ cache: new Map() });

View File

@ -1,145 +0,0 @@
import _ from 'lodash';
import Rx, { Observable } from 'rx';
import { ShallowWrapper, ReactWrapper } from 'enzyme';
import Adapter15 from 'enzyme-adapter-react-15';
import { isJSEnabledSelector } from '../redux';
// we use two different frames to make them all essentially pure functions
// main iframe is responsible rendering the preview and is where we proxy the
// console.log
const mainId = 'fcc-main-frame';
// the test frame is responsible for running the assert tests
const testId = 'fcc-test-frame';
// base tag here will force relative links
// within iframe to point to '/' instead of
// append to the current challenge url
// if an error occurs during initialization
// the __err prop will be set
// This is then picked up in client/frame-runner.js during
// runTestsInTestFrame below
const createHeader = (id = mainId) => `
<base href='/' target='_blank'/>
<script>
window.__frameId = '${id}';
window.onerror = function(msg, url, ln, col, err) {
window.__err = err;
return true;
};
</script>
`;
export const runTestsInTestFrame = (document, tests) => Observable.defer(() => {
const { contentDocument: frame } = document.getElementById(testId);
return frame.__runTests(tests);
});
const createFrame = (document, getState, id) => ctx => {
const isJSEnabled = isJSEnabledSelector(getState());
const frame = document.createElement('iframe');
frame.id = id;
if (!isJSEnabled) {
frame.sandbox = 'allow-same-origin';
}
return {
...ctx,
element: frame
};
};
const hiddenFrameClassname = 'hide-test-frame';
const mountFrame = document => ({ element, ...rest })=> {
const oldFrame = document.getElementById(element.id);
if (oldFrame) {
element.className = oldFrame.className || hiddenFrameClassname;
oldFrame.parentNode.replaceChild(element, oldFrame);
} else {
element.className = hiddenFrameClassname;
document.body.appendChild(element);
}
return {
...rest,
element,
document: element.contentDocument,
window: element.contentWindow
};
};
const addDepsToDocument = ctx => {
ctx.document.Rx = Rx;
// using require here prevents nodejs issues as loop-protect
// is added to the window object by webpack and not available to
// us server side.
/* eslint-disable import/no-unresolved */
ctx.document.loopProtect = require('loop-protect');
/* eslint-enable import/no-unresolved */
return ctx;
};
const buildProxyConsole = proxyLogger => ctx => {
const oldLog = ctx.window.console.log.bind(ctx.window.console);
ctx.window.__console = {};
ctx.window.__console.log = function proxyConsole(...args) {
proxyLogger.onNext(args);
return oldLog(...args);
};
return ctx;
};
const writeTestDepsToDocument = frameReady => ctx => {
const {
document: tests,
sources,
checkChallengePayload
} = ctx;
// add enzyme
// TODO: do programatically
// TODO: webpack lazyload this
tests.Enzyme = {
shallow: (node, options) => new ShallowWrapper(node, null, {
...options,
adapter: new Adapter15()
}),
mount: (node, options) => new ReactWrapper(node, null, {
...options,
adapter: new Adapter15()
})
};
// default for classic challenges
// should not be used for modern
tests.__source = (sources && 'index' in sources) ? sources['index'] : '';
// provide the file name and get the original source
tests.__getUserInput = fileName => _.toString(sources[fileName]);
tests.__checkChallengePayload = checkChallengePayload;
tests.__frameReady = frameReady;
return ctx;
};
function writeToFrame(content, frame) {
frame.open();
frame.write(content);
frame.close();
return frame;
}
const writeContentToFrame = ctx => {
writeToFrame(createHeader(ctx.element.id) + ctx.build, ctx.document);
return ctx;
};
export const createMainFramer = (document, getState, proxyLogger) => _.flow(
createFrame(document, getState, mainId),
mountFrame(document),
addDepsToDocument,
buildProxyConsole(proxyLogger),
writeContentToFrame,
);
export const createTestFramer = (document, getState, frameReady) => _.flow(
createFrame(document, getState, testId),
mountFrame(document),
addDepsToDocument,
writeTestDepsToDocument(frameReady),
writeContentToFrame,
);

View File

@ -1,348 +0,0 @@
import _ from 'lodash';
import * as challengeTypes from '../../../utils/challengeTypes.js';
import { createPoly, updateFileFromSpec } from '../../../../utils/polyvinyl.js';
import { decodeScriptTags } from '../../../../utils/encode-decode.js';
import { createFilesMetaCreator } from '../../../files';
// turn challengeType to file ext
const pathsMap = {
[ challengeTypes.html ]: 'html',
[ challengeTypes.js ]: 'js',
[ challengeTypes.bonfire ]: 'js'
};
// determine the component to view for each challenge
export const viewTypes = {
[ challengeTypes.html ]: 'classic',
[ challengeTypes.js ]: 'classic',
[ challengeTypes.bonfire ]: 'classic',
[ challengeTypes.frontEndProject ]: 'project',
[ challengeTypes.backEndProject ]: 'project',
[ challengeTypes.modern ]: 'modern',
[ challengeTypes.step ]: 'step',
[ challengeTypes.quiz ]: 'quiz',
[ challengeTypes.backend ]: 'backend'
};
// determine the type of submit function to use for the challenge on completion
export const submitTypes = {
[ challengeTypes.html ]: 'tests',
[ challengeTypes.js ]: 'tests',
[ challengeTypes.bonfire ]: 'tests',
// requires just a single url
// like codepen.com/my-project
[ challengeTypes.frontEndProject ]: 'project.frontEnd',
// requires two urls
// a hosted URL where the app is running live
// project code url like GitHub
[ challengeTypes.backEndProject ]: 'project.backEnd',
[ challengeTypes.step ]: 'step',
[ challengeTypes.quiz ]: 'quiz',
[ challengeTypes.backend ]: 'backend',
[ challengeTypes.modern ]: 'tests'
};
// determines if a line in a challenge description
// has html that should be rendered
export const descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
export function arrayToString(seedData = ['']) {
seedData = Array.isArray(seedData) ? seedData : [seedData];
return seedData.reduce((seed, line) => '' + seed + line + '\n', '\n');
}
export function buildSeed({ challengeSeed = [] } = {}) {
return _.flow(
arrayToString,
decodeScriptTags
)(challengeSeed);
}
export function getFileKey({ challengeType }) {
return 'index' + (pathsMap[challengeType] || 'html');
}
export function getPreFile({ challengeType }) {
return {
name: 'index',
ext: pathsMap[challengeType] || 'html',
key: getFileKey({ challengeType })
};
}
export function challengeToFiles(challenge, files) {
const { challengeType } = challenge;
const previousWork = !!files;
files = files || challenge.files || {};
if (challengeType === challengeTypes.modern) {
return _.reduce(challenge.files, (_files, fileSpec) => {
const file = _.get(files, fileSpec.key);
_files[fileSpec.key] = updateFileFromSpec(fileSpec, file);
return _files;
}, {});
}
if (
challengeType !== challengeTypes.html &&
challengeType !== challengeTypes.js &&
challengeType !== challengeTypes.bonfire
) {
return {};
}
// classic challenge to modern format
const preFile = getPreFile(challenge);
const contents = previousWork ?
// get previous contents
_.property([ preFile.key, 'contents' ])(files) :
// otherwise start fresh
buildSeed(challenge);
return {
[preFile.key]: createPoly({
...files[preFile.key],
...preFile,
contents,
// make sure head/tail are always fresh from fCC
head: arrayToString(challenge.head),
tail: arrayToString(challenge.tail)
})
};
}
export const challengeToFilesMetaCreator =
_.flow(challengeToFiles, createFilesMetaCreator);
// ({ dashedName: { Challenge } }) => ({ meta: Files }) || {}
export function createCurrentChallengeMeta(challenge) {
if (_.isEmpty(challenge)) {
return {};
}
return challengeToFilesMetaCreator(_.values(challenge)[0]);
}
export function createTests({ tests = [] }) {
return tests
.map(test => {
if (typeof test === 'string') {
return {
text: ('' + test).split('message: ')
.pop().replace(/(\'\);(\s*\};)?)/g, ''),
testString: test
};
}
return test;
});
}
function logReplacer(value) {
if (Array.isArray(value)) {
const replaced = value.map(logReplacer);
return '[' + replaced.join(', ') + ']';
}
if (typeof value === 'string' && !(/^\/\//).test(value)) {
return '"' + value + '"';
}
if (typeof value === 'number' && isNaN(value)) {
return value.toString();
}
if (typeof value === 'undefined') {
return 'undefined';
}
if (value === null) {
return 'null';
}
if (typeof value === 'function') {
return value.name;
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return value;
}
export function loggerToStr(args) {
args = Array.isArray(args) ? args : [args];
return args
.map(logReplacer)
.reduce((str, arg) => str + arg + '\n', '');
}
export function getNextChallenge(
current,
entities,
{
isDev = false,
skip = 0
} = {}
) {
const { challenge: challengeMap, block: blockMap } = entities;
// find current challenge
// find current block
// find next challenge in block
const currentChallenge = challengeMap[current];
if (!currentChallenge) {
return null;
}
const block = blockMap[currentChallenge.block];
const index = block.challenges.indexOf(currentChallenge.dashedName);
// use next challenge name to find challenge in challenge map
const nextChallenge = challengeMap[
// grab next challenge name in current block
// skip is used to skip isComingSoon challenges
block.challenges[ index + 1 + skip ]
];
if (
!isDev &&
nextChallenge &&
(nextChallenge.isComingSoon || nextChallenge.isBeta)
) {
// if we find a next challenge and it is a coming soon
// recur with plus one to skip this challenge
return getNextChallenge(current, entities, { isDev, skip: skip + 1 });
}
return nextChallenge;
}
export function getFirstChallengeOfNextBlock(
current,
entities,
{
isDev = false,
skip = 0
} = {}
) {
const {
challenge: challengeMap,
block: blockMap,
superBlock: SuperBlockMap
} = entities;
const currentChallenge = challengeMap[current];
if (!currentChallenge) {
return null;
}
const block = blockMap[currentChallenge.block];
if (!block) {
return null;
}
const superBlock = SuperBlockMap[block.superBlock];
if (!superBlock) {
return null;
}
// find index of current block
const index = superBlock.blocks.indexOf(block.dashedName);
// find next block name
// and pull block object from block map
const newBlock = blockMap[
superBlock.blocks[ index + 1 + skip ]
];
if (!newBlock) {
return null;
}
// grab first challenge from next block
const nextChallenge = challengeMap[newBlock.challenges[0]];
if (isDev || !nextChallenge || !nextChallenge.isComingSoon) {
return nextChallenge;
}
// if first challenge is coming soon, find next challenge here
const nextChallenge2 = getNextChallenge(
nextChallenge.dashedName,
entities,
{ isDev }
);
if (nextChallenge2) {
return nextChallenge2;
}
// whole block is coming soon
// skip this block
return getFirstChallengeOfNextBlock(
current,
entities,
{ isDev, skip: skip + 1 }
);
}
export function getFirstChallengeOfNextSuperBlock(
current,
entities,
superBlocks,
{
isDev = false,
skip = 0
} = {}
) {
const {
challenge: challengeMap,
block: blockMap,
superBlock: SuperBlockMap
} = entities;
const currentChallenge = challengeMap[current];
if (!currentChallenge) {
return null;
}
const block = blockMap[currentChallenge.block];
if (!block) {
return null;
}
const superBlock = SuperBlockMap[block.superBlock];
if (!superBlock) {
return null;
}
const index = superBlocks.indexOf(superBlock.dashedName);
const newSuperBlock = SuperBlockMap[superBlocks[ index + 1 + skip]];
if (!newSuperBlock) {
return null;
}
const newBlock = blockMap[
newSuperBlock.blocks[ 0 ]
];
if (!newBlock) {
return null;
}
const nextChallenge = challengeMap[newBlock.challenges[0]];
if (isDev || !nextChallenge || !nextChallenge.isComingSoon) {
return nextChallenge;
}
// coming soon challenge, grab next
// non coming soon challenge in same block instead
const nextChallengeInBlock = getNextChallenge(
nextChallenge.dashedName,
entities,
{ isDev }
);
if (nextChallengeInBlock) {
return nextChallengeInBlock;
}
// whole block is coming soon
// grab first challenge in next block in newSuperBlock instead
const challengeInNextBlock = getFirstChallengeOfNextBlock(
nextChallenge.dashedName,
entities,
{ isDev }
);
if (challengeInNextBlock) {
return challengeInNextBlock;
}
// whole super block is coming soon
// skip this super block
return getFirstChallengeOfNextSuperBlock(
current,
entities,
superBlocks,
{ isDev, skip: skip + 1 }
);
}
export function getCurrentBlockName(current, entities) {
const { challenge: challengeMap } = entities;
const challenge = challengeMap[current];
return challenge.block;
}
export function getCurrentSuperBlockName(current, entities) {
const { challenge: challengeMap, block: blockMap } = entities;
const challenge = challengeMap[current];
const block = blockMap[challenge.block];
return block.superBlock;
}

View File

@ -1,848 +0,0 @@
import test from 'tape';
import {
getNextChallenge,
getFirstChallengeOfNextBlock,
getFirstChallengeOfNextSuperBlock
} from './';
test('getNextChallenge', t => {
t.plan(7);
t.test('should return falsey when current challenge is not found', t => {
t.plan(1);
const entities = {
challenge: {},
block: {}
};
t.notOk(
getNextChallenge('non-existent-challenge', entities),
'getNextChallenge did not return falsey when challenge is not found'
);
});
t.test('should return falsey when last challenge in block', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const nextChallenge = {
dashedName: 'next-challenge',
block: 'current-block'
};
const shouldBeNext = getNextChallenge(
'next-challenge',
{
challenge: {
'current-challenge': currentChallenge,
'next-challenge': nextChallenge
},
block: {
'current-block': {
challenges: [
'current-challenge',
'next-challenge'
]
}
}
}
);
t.false(
shouldBeNext,
'getNextChallenge should return null or undefined'
);
});
t.test('should return next challenge when it exists', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const nextChallenge = {
dashedName: 'next-challenge',
block: 'current-block'
};
const shouldBeNext = getNextChallenge(
'current-challenge',
{
challenge: {
'current-challenge': currentChallenge,
'next-challenge': nextChallenge
},
block: {
'current-block': {
challenges: [
'current-challenge',
'next-challenge'
]
}
}
}
);
t.isEqual(shouldBeNext, nextChallenge);
});
t.test('should skip isComingSoon challenge', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const comingSoon = {
dashedName: 'coming-soon',
isComingSoon: true,
block: 'current-block'
};
const nextChallenge = {
dashedName: 'next-challenge',
block: 'current-block'
};
const shouldBeNext = getNextChallenge(
'current-challenge',
{
challenge: {
'current-challenge': currentChallenge,
'next-challenge': nextChallenge,
'coming-soon': comingSoon,
'coming-soon2': comingSoon
},
block: {
'current-block': {
challenges: [
'current-challenge',
'coming-soon',
'coming-soon2',
'next-challenge'
]
}
}
}
);
t.isEqual(shouldBeNext, nextChallenge);
});
t.test('should not skip isComingSoon challenge in dev', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const comingSoon = {
dashedName: 'coming-soon',
isComingSoon: true,
block: 'current-block'
};
const nextChallenge = {
dashedName: 'next-challenge',
block: 'current-block'
};
const entities = {
challenge: {
'current-challenge': currentChallenge,
'next-challenge': nextChallenge,
'coming-soon': comingSoon
},
block: {
'current-block': {
challenges: [
'current-challenge',
'coming-soon',
'next-challenge'
]
}
}
};
t.isEqual(
getNextChallenge('current-challenge', entities, { isDev: true }),
comingSoon
);
});
t.test('should skip isBeta challenge', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const beta = {
dashedName: 'beta-challenge',
isBeta: true,
block: 'current-block'
};
const nextChallenge = {
dashedName: 'next-challenge',
block: 'current-block'
};
const shouldBeNext = getNextChallenge(
'current-challenge',
{
challenge: {
'current-challenge': currentChallenge,
'next-challenge': nextChallenge,
'beta-challenge': beta,
'beta-challenge2': beta
},
block: {
'current-block': {
challenges: [
'current-challenge',
'beta-challenge',
'beta-challenge2',
'next-challenge'
]
}
}
}
);
t.isEqual(shouldBeNext, nextChallenge);
});
t.test('should not skip isBeta challenge if in dev', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const beta = {
dashedName: 'beta-challenge',
isBeta: true,
block: 'current-block'
};
const nextChallenge = {
dashedName: 'next-challenge',
block: 'current-block'
};
const entities = {
challenge: {
'current-challenge': currentChallenge,
'next-challenge': nextChallenge,
'beta-challenge': beta
},
block: {
'current-block': {
challenges: [
'current-challenge',
'beta-challenge',
'next-challenge'
]
}
}
};
t.isEqual(
getNextChallenge('current-challenge', entities, { isDev: true }),
beta
);
});
});
test('getFirstChallengeOfNextBlock', t => {
t.plan(8);
t.test('should return falsey when current challenge is not found', t => {
t.plan(1);
const entities = {
challenge: {},
block: {}
};
t.notOk(
getFirstChallengeOfNextBlock('non-existent-challenge', entities),
`
gitFirstChallengeOfNextBlock returned true value for non-existent
challenge
`
);
});
t.test('should return falsey when current block is not found', t => {
t.plan(1);
const entities = {
challenge: {
'current-challenge': {
block: 'non-existent-block'
}
},
block: {}
};
t.notOk(
getFirstChallengeOfNextBlock('current-challenge', entities),
`
getFirstChallengeOfNextBlock did not returned true value block
did non exist
`
);
});
t.test('should return falsey if no current superBlock found', t => {
t.plan(1);
const entities = {
challenge: { 'current-challenge': { block: 'current-block' } },
block: {
'current-block': {
dashedName: 'current-block',
superBlock: 'current-super-block'
}
},
superBlock: {}
};
t.notOk(
getFirstChallengeOfNextBlock('current-challenge', entities),
`
getFirstChallengeOfNextBlock returned a true value
when superBlock is undefined
`
);
});
t.test('should return falsey when no next block found', t => {
t.plan(1);
const entities = {
challenge: { 'current-challenge': { block: 'current-block' } },
block: {
'current-block': {
dashedName: 'current-block',
superBlock: 'current-super-block'
}
},
superBlock: {
'current-super-block': {
blocks: [
'current-block',
'non-exitent-block'
]
}
}
};
t.notOk(
getFirstChallengeOfNextBlock('current-challenge', entities),
`
getFirstChallengeOfNextBlock returned a value when next block
does not exist
`
);
});
t.test('should return first challenge of next block', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const entities = {
challenge: {
[currentChallenge.dashedName]: currentChallenge,
[firstChallenge.dashedName]: firstChallenge
},
block: {
'current-block': {
dashedName: 'current-block',
superBlock: 'current-super-block'
},
'next-block': {
dashedName: 'next-block',
superBlock: 'current-super-block',
challenges: [ 'first-challenge' ]
}
},
superBlock: {
'current-super-block': {
dashedName: 'current-super-block',
blocks: [ 'current-block', 'next-block' ]
}
}
};
t.equal(
getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities),
firstChallenge,
'getFirstChallengeOfNextBlock did not return the correct challenge'
);
});
t.test('should skip coming soon challenge of next block', t => {
t.plan(2);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const comingSoon = {
dashedName: 'coming-soon',
block: 'next-block',
isComingSoon: true
};
const comingSoon2 = {
dashedName: 'coming-soon2',
block: 'next-block',
isComingSoon: true
};
const entities = {
challenge: {
[currentChallenge.dashedName]: currentChallenge,
[firstChallenge.dashedName]: firstChallenge,
'coming-soon': comingSoon,
'coming-soon2': comingSoon2
},
block: {
'current-block': {
dashedName: 'current-block',
superBlock: 'current-super-block'
},
'next-block': {
dashedName: 'next-block',
superBlock: 'current-super-block',
challenges: [
'coming-soon',
'coming-soon2',
'first-challenge'
]
}
},
superBlock: {
'current-super-block': {
dashedName: 'current-super-block',
blocks: [ 'current-block', 'next-block' ]
}
}
};
t.notEqual(
getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities),
comingSoon,
'getFirstChallengeOfNextBlock returned isComingSoon challenge'
);
t.equal(
getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities),
firstChallenge,
'getFirstChallengeOfNextBlock did not return the correct challenge'
);
});
t.test('should not skip coming soon in dev mode', t => {
t.plan(1);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const comingSoon = {
dashedName: 'coming-soon',
block: 'next-block',
isComingSoon: true
};
const entities = {
challenge: {
[currentChallenge.dashedName]: currentChallenge,
[firstChallenge.dashedName]: firstChallenge,
'coming-soon': comingSoon
},
block: {
'current-block': {
dashedName: 'current-block',
superBlock: 'current-super-block'
},
'next-block': {
dashedName: 'next-block',
superBlock: 'current-super-block',
challenges: [
'coming-soon',
'first-challenge'
]
}
},
superBlock: {
'current-super-block': {
dashedName: 'current-super-block',
blocks: [ 'current-block', 'next-block' ]
}
}
};
t.equal(
getFirstChallengeOfNextBlock(
currentChallenge.dashedName,
entities,
{ isDev: true }
),
comingSoon,
'getFirstChallengeOfNextBlock returned isComingSoon challenge'
);
});
t.test('should skip block if all challenges are coming soon', t => {
t.plan(2);
const currentChallenge = {
dashedName: 'current-challenge',
block: 'current-block'
};
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const comingSoon = {
dashedName: 'coming-soon',
block: 'coming-soon-block',
isComingSoon: true
};
const comingSoon2 = {
dashedName: 'coming-soon2',
block: 'coming-soon-block',
isComingSoon: true
};
const entities = {
challenge: {
[currentChallenge.dashedName]: currentChallenge,
[firstChallenge.dashedName]: firstChallenge,
[comingSoon.dashedName]: comingSoon,
[comingSoon2.dashedName]: comingSoon2
},
block: {
'current-block': {
dashedName: 'current-block',
superBlock: 'current-super-block'
},
'coming-soon-block': {
dashedName: 'coming-soon-block',
superBlock: 'current-super-block',
challenges: [
'coming-soon',
'coming-soon2'
]
},
'next-block': {
dashedName: 'next-block',
superBlock: 'current-super-block',
challenges: [
'first-challenge'
]
}
},
superBlock: {
'current-super-block': {
dashedName: 'current-super-block',
blocks: [
'current-block',
'coming-soon-block',
'next-block'
]
}
}
};
t.notEqual(
getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities),
comingSoon,
'getFirstChallengeOfNextBlock returned isComingSoon challenge'
);
t.equal(
getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities),
firstChallenge,
'getFirstChallengeOfNextBlock did not return the correct challenge'
);
});
});
test('getFirstChallengeOfNextBlock', t => {
t.plan(10);
t.test('should return falsey if current challenge not found', t => {
t.plan(1);
const entities = {
challenge: {}
};
t.notOk(
getFirstChallengeOfNextSuperBlock('current-challenge', entities),
);
});
t.test('should return falsey if current block not found', t => {
t.plan(1);
const entities = {
challenge: { 'current-challenge': { block: 'current-block' } },
block: {}
};
t.notOk(
getFirstChallengeOfNextSuperBlock('current-challenge', entities)
);
});
t.test('should return falsey if current superBlock is not found', t => {
t.plan(1);
const entities = {
challenge: { 'current-challenge': { block: 'current-block' } },
block: { 'current-block': { superBlock: 'current-super-block' } },
superBlock: {}
};
t.notOk(
getFirstChallengeOfNextSuperBlock('current-challenge', entities)
);
});
t.test('should return falsey when last superBlock', t => {
t.plan(1);
const entities = {
challenge: { 'current-challenge': { block: 'current-block' } },
block: { 'current-block': { superBlock: 'current-super-block' } },
superBlock: {
'current-super-block': { dashedName: 'current-super-block' }
}
};
const superBlocks = [ 'current-super-block' ];
t.notOk(getFirstChallengeOfNextSuperBlock(
'current-challenge',
entities,
superBlocks
));
});
t.test('should return falsey when last block of new superblock', t => {
t.plan(1);
const entities = {
challenge: { 'current-challenge': { block: 'current-block' } },
block: {
'current-block': {
superBlock: 'current-super-block'
}
},
superBlock: {
'current-super-block': { dashedName: 'current-super-block' },
'next-super-block': {
dashedName: 'next-super-block',
blocks: [
'first-block'
]
}
}
};
const superBlocks = [ 'current-super-block', 'next-super-block' ];
t.notOk(getFirstChallengeOfNextSuperBlock(
'current-challenge',
entities,
superBlocks
));
});
t.test('should return first challenge of next superBlock', t => {
t.plan(1);
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const entities = {
challenge: {
'current-challenge': { block: 'current-block' },
[firstChallenge.dashedName]: firstChallenge
},
block: {
'current-block': { superBlock: 'current-super-block' },
'next-block': {
superBlock: 'next-super-block',
challenges: [ 'first-challenge' ]
}
},
superBlock: {
'current-super-block': { dashedName: 'current-super-block' },
'next-super-block': {
dashedName: 'next-super-block',
blocks: [ 'next-block' ]
}
}
};
const superBlocks = [ 'current-super-block', 'next-super-block' ];
t.isEqual(
getFirstChallengeOfNextSuperBlock(
'current-challenge',
entities,
superBlocks
),
firstChallenge
);
});
t.test('should skip coming soon challenge', t => {
t.plan(1);
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const entities = {
challenge: {
'current-challenge': { block: 'current-block' },
[firstChallenge.dashedName]: firstChallenge,
'coming-soon': {
dashedName: 'coming-soon',
block: 'next-block',
isComingSoon: true
}
},
block: {
'current-block': { superBlock: 'current-super-block' },
'next-block': {
dashedName: 'next-block',
superBlock: 'next-super-block',
challenges: [ 'coming-soon', 'first-challenge' ]
}
},
superBlock: {
'current-super-block': { dashedName: 'current-super-block' },
'next-super-block': {
dashedName: 'next-super-block',
blocks: [ 'next-block' ]
}
}
};
const superBlocks = [
'current-super-block',
'next-super-block'
];
t.isEqual(
getFirstChallengeOfNextSuperBlock(
'current-challenge',
entities,
superBlocks
),
firstChallenge
);
});
t.test('should not skip coming soon in dev mode', t => {
t.plan(1);
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const comingSoon = {
dashedName: 'coming-soon',
block: 'next-block',
isComingSoon: true
};
const entities = {
challenge: {
'current-challenge': { block: 'current-block' },
[firstChallenge.dashedName]: firstChallenge,
'coming-soon': comingSoon
},
block: {
'current-block': { superBlock: 'current-super-block' },
'next-block': {
dashedName: 'next-block',
superBlock: 'next-super-block',
challenges: [ 'coming-soon', 'first-challenge' ]
}
},
superBlock: {
'current-super-block': { dashedName: 'current-super-block' },
'next-super-block': {
dashedName: 'next-super-block',
blocks: [ 'next-block' ]
}
}
};
const superBlocks = [
'current-super-block',
'next-super-block'
];
t.isEqual(
getFirstChallengeOfNextSuperBlock(
'current-challenge',
entities,
superBlocks,
{ isDev: true }
),
comingSoon
);
});
t.test('should skip coming soon block', t => {
t.plan(1);
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const entities = {
challenge: {
'current-challenge': { block: 'current-block' },
[firstChallenge.dashedName]: firstChallenge,
'coming-soon': {
dashedName: 'coming-soon',
block: 'coming-soon-block',
isComingSoon: true
}
},
block: {
'current-block': { superBlock: 'current-super-block' },
'coming-soon-block': {
dashedName: 'coming-soon-block',
superBlock: 'next-super-block',
challenges: [
'coming-soon'
]
},
'next-block': {
dashedName: 'next-block',
superBlock: 'next-super-block',
challenges: [ 'first-challenge' ]
}
},
superBlock: {
'current-super-block': { dashedName: 'current-super-block' },
'next-super-block': {
dashedName: 'next-super-block',
blocks: [ 'coming-soon-block', 'next-block' ]
}
}
};
const superBlocks = [
'current-super-block',
'next-super-block'
];
t.isEqual(
getFirstChallengeOfNextSuperBlock(
'current-challenge',
entities,
superBlocks
),
firstChallenge
);
});
t.test('should skip coming soon super block', t => {
t.plan(1);
const firstChallenge = {
dashedName: 'first-challenge',
block: 'next-block'
};
const entities = {
challenge: {
'current-challenge': { block: 'current-block' },
[firstChallenge.dashedName]: firstChallenge,
'coming-soon': {
dashedName: 'coming-soon',
block: 'coming-soon-block',
isComingSoon: true
}
},
block: {
'current-block': { superBlock: 'current-super-block' },
'coming-soon-block': {
dashedName: 'coming-soon-block',
superBlock: 'coming-soon-super-block',
challenges: [
'coming-soon'
]
},
'next-block': {
superBlock: 'next-super-block',
dashedName: 'next-block',
challenges: [ 'first-challenge' ]
}
},
superBlock: {
'current-super-block': { dashedName: 'current-super-block' },
'coming-soon-super-block': {
dashedName: 'coming-soon-super-block',
blocks: [ 'coming-soon-block' ]
},
'next-super-block': {
dashedName: 'next-super-block',
blocks: [ 'next-block' ]
}
}
};
const superBlocks = [
'current-super-block',
'coming-soon-super-block',
'next-super-block'
];
t.isEqual(
getFirstChallengeOfNextSuperBlock(
'current-challenge',
entities,
superBlocks
),
firstChallenge
);
});
});

View File

@ -1,155 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import Codemirror from 'react-codemirror';
import NoSSR from 'react-no-ssr';
import MouseTrap from 'mousetrap';
import ns from './ns.json';
import CodeMirrorSkeleton from '../../Code-Mirror-Skeleton.jsx';
import {
executeChallenge,
modernEditorUpdated,
challengeMetaSelector
} from '../../redux';
import { themeSelector } from '../../../../redux';
import { createFileSelector } from '../../../../files';
const envProps = typeof window !== 'undefined' ? Object.keys(window) : [];
const options = {
lint: {
esversion: 6,
predef: envProps
},
lineNumbers: true,
mode: 'javascript',
runnable: true,
matchBrackets: true,
autoCloseBrackets: true,
scrollbarStyle: 'null',
lineWrapping: true,
gutters: [ 'CodeMirror-lint-markers' ]
};
const mapStateToProps = createSelector(
createFileSelector((_, { fileKey }) => fileKey || ''),
challengeMetaSelector,
themeSelector,
(
file,
{ mode },
theme
) => ({
content: file.contents || '// Happy Coding!',
file: file,
mode: file.ext || mode || 'javascript',
theme
})
);
const mapDispatchToProps = {
executeChallenge,
modernEditorUpdated
};
const propTypes = {
content: PropTypes.string,
executeChallenge: PropTypes.func.isRequired,
fileKey: PropTypes.string,
mode: PropTypes.string,
modernEditorUpdated: PropTypes.func.isRequired,
theme: PropTypes.string
};
export class Editor extends PureComponent {
createOptions = createSelector(
state => state.executeChallenge,
state => state.mode,
state => state.cmTheme,
(executeChallenge, mode, cmTheme) => ({
...options,
theme: cmTheme,
mode,
// JSHint only works with javascript
// we will need to switch to eslint to make this work with jsx
lint: mode === 'javascript' ? options.lint : false,
extraKeys: {
Esc() {
document.activeElement.blur();
},
Tab(cm) {
if (cm.somethingSelected()) {
return cm.indentSelection('add');
}
const spaces = Array(cm.getOption('indentUnit') + 1).join(' ');
return cm.replaceSelection(spaces);
},
'Shift-Tab': function(cm) {
return cm.indentSelection('subtract');
},
'Ctrl-Enter': function() {
executeChallenge();
return false;
},
'Cmd-Enter': function() {
executeChallenge();
return false;
},
'Ctrl-/': function(cm) {
cm.toggleComment();
},
'Cmd-/': function(cm) {
cm.toggleComment();
}
}
})
);
componentDidMount() {
MouseTrap.bind('e', () => {
this.refs.editor.focus();
}, 'keyup');
}
componentWillUnmount() {
MouseTrap.unbind('e', 'keyup');
}
render() {
const {
content,
modernEditorUpdated,
executeChallenge,
fileKey,
mode
} = this.props;
const cmTheme = this.props.theme === 'default' ? 'default' : 'dracula';
return (
<div
className={ `${ns}-editor` }
role='main'
>
<NoSSR onSSR={ <CodeMirrorSkeleton content={ content } /> }>
<Codemirror
onChange={ content => modernEditorUpdated(fileKey, content) }
options={ this.createOptions({ executeChallenge, mode, cmTheme }) }
ref='editor'
value={ content }
/>
</NoSSR>
</div>
);
}
}
Editor.displayName = 'Editor';
Editor.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Editor);

View File

@ -1,111 +0,0 @@
import _ from 'lodash';
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { createSelector } from 'reselect';
import { addNS } from 'berkeleys-redux-utils';
import ns from './ns.json';
import Editor from './Editor.jsx';
import ChildContainer from '../../Child-Container.jsx';
import { showPreviewSelector, types } from '../../redux';
import SidePanel from '../../Side-Panel.jsx';
import Preview from '../../Preview.jsx';
import _Map from '../../../../Map';
import Panes from '../../../../Panes';
import { filesSelector } from '../../../../files';
const createModernEditorToggleType = fileKey =>
types.toggleModernEditor + `(${fileKey})`;
const getFirstFileKey = _.flow(_.values, _.first, _.property('key'));
const propTypes = {
nameToFileKey: PropTypes.object
};
const mapStateToProps = createSelector(
filesSelector,
files => {
if (Object.keys(files).length === 1) {
return { nameToFileKey: { Editor: getFirstFileKey(files) }};
}
return {
nameToFileKey: _.reduce(files, (map, file) => {
map[file.name] = file.key;
return map;
}, {})
};
}
);
const mapDispatchToProps = null;
export const mapStateToPanes = addNS(
ns,
createSelector(
filesSelector,
showPreviewSelector,
(files, showPreview) => {
// create panes map here
// must include map
// side panel
// editors are created based on state
// so pane component can have multiple panes based on state
const panesMap = {
[types.toggleMap]: 'Map',
[types.toggleSidePanel]: 'Lesson'
};
// If there is more than one file show file name
if (Object.keys(files).length > 1) {
_.forEach(files, (file) => {
panesMap[createModernEditorToggleType(file.fileKey)] = file.name;
});
} else {
const key = getFirstFileKey(files);
panesMap[createModernEditorToggleType(key)] = 'Editor';
}
if (showPreview) {
panesMap[types.togglePreview] = 'Preview';
}
return panesMap;
}
)
);
const nameToComponent = {
Map: _Map,
Lesson: SidePanel,
Preview: Preview
};
export function ShowModern({ nameToFileKey }) {
return (
<ChildContainer isFullWidth={ true }>
<Panes
render={ name => {
const Comp = nameToComponent[name];
if (Comp) {
return <Comp />;
}
if (nameToFileKey[name]) {
return <Editor fileKey={ nameToFileKey[name] } />;
}
return <span>Could not find Component for { name }</span>;
}}
/>
</ChildContainer>
);
}
ShowModern.displayName = 'ShowModern';
ShowModern.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(ShowModern);

View File

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

View File

@ -1 +0,0 @@
"modern"

View File

@ -1,184 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { createSelector } from 'reselect';
import { reduxForm } from 'redux-form';
import {
Button,
Col,
Row
} from 'react-bootstrap';
import ChallengeTitle from '../../Challenge-Title.jsx';
import ChallengeDescription from '../../Challenge-Description.jsx';
import SolutionInput from '../../Solution-Input.jsx';
import TestSuite from '../../Test-Suite.jsx';
import Output from '../../Output.jsx';
import {
executeChallenge,
testsSelector,
outputSelector
} from '../../redux';
import { descriptionRegex } from '../../utils';
import {
createFormValidator,
isValidURL,
makeRequired
} from '../../../../utils/form.js';
import { challengeSelector } from '../../../../redux';
// provided by redux form
const reduxFormPropTypes = {
fields: PropTypes.object,
handleSubmit: PropTypes.func.isRequired,
resetForm: PropTypes.func.isRequired,
submitting: PropTypes.bool
};
const propTypes = {
description: PropTypes.arrayOf(PropTypes.string),
executeChallenge: PropTypes.func.isRequired,
id: PropTypes.string,
output: PropTypes.string,
tests: PropTypes.array,
title: PropTypes.string,
...reduxFormPropTypes
};
const fields = [ 'solution' ];
const fieldValidators = {
solution: makeRequired(isValidURL)
};
const mapStateToProps = createSelector(
challengeSelector,
outputSelector,
testsSelector,
(
{
id,
title,
description
},
output,
tests
) => ({
id,
title,
tests,
description,
output
})
);
const mapDispatchToActions = {
executeChallenge
};
export class BackEnd extends PureComponent {
renderDescription(description) {
if (!Array.isArray(description)) {
return null;
}
return description.map((line, index) => {
if (descriptionRegex.test(line)) {
return (
<div
dangerouslySetInnerHTML={{ __html: line }}
key={ line.slice(-6) + index }
/>
);
}
return (
<p
className='wrappable'
dangerouslySetInnerHTML= {{ __html: line }}
key={ line.slice(-6) + index }
/>
);
});
}
render() {
const {
description,
executeChallenge,
output,
tests,
title,
// provided by redux-form
fields: { solution },
handleSubmit,
submitting
} = this.props;
const buttonCopy = submitting ?
'Submit and go to my next challenge' :
"I've completed this challenge";
return (
<Row>
<Col
xs={ 6 }
xsOffset={ 3 }
>
<Row>
<ChallengeTitle>
{ title }
</ChallengeTitle>
<ChallengeDescription>
{ this.renderDescription(description) }
</ChallengeDescription>
</Row>
<Row>
<form
name='BackEndChallenge'
onSubmit={ handleSubmit(executeChallenge) }
>
<SolutionInput
placeholder='https://your-app.com'
solution={ solution }
/>
<Button
block={ true }
bsStyle='primary'
className='btn-big'
onClick={ submitting ? null : null }
type={ submitting ? null : 'submit' }
>
{ buttonCopy } (ctrl + enter)
</Button>
</form>
</Row>
<Row>
<br/>
<Output
defaultOutput={
`/**
* Test output will go here
*/`
}
output={ output }
/>
</Row>
<Row>
<TestSuite tests={ tests } />
</Row>
</Col>
</Row>
);
}
}
BackEnd.displayName = 'BackEnd';
BackEnd.propTypes = propTypes;
export default reduxForm(
{
form: 'BackEndChallenge',
fields,
validate: createFormValidator(fieldValidators)
},
mapStateToProps,
mapDispatchToActions
)(BackEnd);

View File

@ -1,39 +0,0 @@
import React from 'react';
import { addNS } from 'berkeleys-redux-utils';
import ChildContainer from '../../Child-Container.jsx';
import BackEnd from './Back-End.jsx';
import { types } from '../../redux';
import Panes from '../../../../Panes';
import _Map from '../../../../Map';
const propTypes = {};
export const mapStateToPanes = addNS(
'backend',
() => ({
[types.toggleMap]: 'Map',
[types.toggleMain]: 'Main'
})
);
const nameToComponent = {
Map: _Map,
Main: BackEnd
};
const renderPane = name => {
const Comp = nameToComponent[name];
return Comp ? <Comp /> : <span>Pane { name } not found</span>;
};
export default function ShowBackEnd() {
return (
<ChildContainer isFullWidth={ true }>
<Panes render={ renderPane } />
</ChildContainer>
);
}
ShowBackEnd.displayName = 'ShowBackEnd';
ShowBackEnd.propTypes = propTypes;

View File

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

View File

@ -1,162 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import Codemirror from 'react-codemirror';
import NoSSR from 'react-no-ssr';
import MouseTrap from 'mousetrap';
import ns from './ns.json';
import CodeMirrorSkeleton from '../../Code-Mirror-Skeleton.jsx';
import {
executeChallenge,
classicEditorUpdated,
challengeMetaSelector,
keySelector
} from '../../redux';
import { themeSelector, challengeSelector } from '../../../../redux';
import { filesSelector } from '../../../../files';
const envProps = typeof window !== 'undefined' ? Object.keys(window) : [];
const options = {
lint: {
esversion: 6,
predef: envProps
},
lineNumbers: true,
mode: 'javascript',
runnable: true,
matchBrackets: true,
autoCloseBrackets: true,
scrollbarStyle: 'null',
lineWrapping: true,
gutters: [ 'CodeMirror-lint-markers' ]
};
const mapStateToProps = createSelector(
filesSelector,
challengeMetaSelector,
challengeSelector,
keySelector,
themeSelector,
(
files = {},
{ mode = 'javascript' },
{ id },
key,
theme
) => ({
content: files[key] && files[key].contents || '// Happy Coding!',
file: files[key],
fileKey: key,
id,
mode,
theme
})
);
const mapDispatchToProps = {
executeChallenge,
classicEditorUpdated
};
const propTypes = {
classicEditorUpdated: PropTypes.func.isRequired,
content: PropTypes.string,
executeChallenge: PropTypes.func.isRequired,
fileKey: PropTypes.string.isRequired,
id: PropTypes.string,
mode: PropTypes.string,
theme: PropTypes.string
};
export class Editor extends PureComponent {
createOptions = createSelector(
state => state.executeChallenge,
state => state.mode,
state => state.cmTheme,
(executeChallenge, mode, cmTheme) => ({
...options,
theme: cmTheme,
mode,
extraKeys: {
Esc() {
document.activeElement.blur();
},
Tab(cm) {
if (cm.somethingSelected()) {
return cm.indentSelection('add');
}
const spaces = Array(cm.getOption('indentUnit') + 1).join(' ');
return cm.replaceSelection(spaces);
},
'Shift-Tab': function(cm) {
return cm.indentSelection('subtract');
},
'Ctrl-Enter': function() {
executeChallenge();
return false;
},
'Cmd-Enter': function() {
executeChallenge();
return false;
},
'Ctrl-/': function(cm) {
cm.toggleComment();
},
'Cmd-/': function(cm) {
cm.toggleComment();
}
}
})
);
componentDidMount() {
MouseTrap.bind('e', () => {
this.refs.editor.focus();
}, 'keyup');
}
componentWillUnmount() {
MouseTrap.unbind('e', 'keyup');
}
render() {
const {
content,
executeChallenge,
fileKey,
id,
classicEditorUpdated,
mode
} = this.props;
const cmTheme = this.props.theme === 'default' ? 'default' : 'dracula';
return (
<div
className={ `${ns}-editor` }
role='main'
>
<NoSSR onSSR={ <CodeMirrorSkeleton content={ content } /> }>
<Codemirror
key={ id }
onChange={ change => classicEditorUpdated(fileKey, change) }
options={ this.createOptions({ executeChallenge, mode, cmTheme }) }
ref='editor'
value={ content }
/>
</NoSSR>
</div>
);
}
}
Editor.displayName = 'Editor';
Editor.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Editor);

View File

@ -1,51 +0,0 @@
import React from 'react';
import { addNS } from 'berkeleys-redux-utils';
import Editor from './Editor.jsx';
import ChildContainer from '../../Child-Container.jsx';
import { types, showPreviewSelector } from '../../redux';
import Preview from '../../Preview.jsx';
import SidePanel from '../../Side-Panel.jsx';
import Panes from '../../../../Panes';
import _Map from '../../../../Map';
const propTypes = {};
export const mapStateToPanes = addNS(
'classic',
state => {
const panesMap = {
[types.toggleMap]: 'Map',
[types.toggleSidePanel]: 'Lesson',
[types.toggleClassicEditor]: 'Editor'
};
if (showPreviewSelector(state)) {
panesMap[types.togglePreview] = 'Preview';
}
return panesMap;
}
);
const nameToComponent = {
Map: _Map,
Lesson: SidePanel,
Editor: Editor,
Preview: Preview
};
const renderPane = name => {
const Comp = nameToComponent[name];
return Comp ? <Comp /> : <span>Pane for { name } not found</span>;
};
export default function ShowClassic() {
return (
<ChildContainer isFullWidth={ true }>
<Panes render={ renderPane }/>
</ChildContainer>
);
}
ShowClassic.displayName = 'ShowClassic';
ShowClassic.propTypes = propTypes;

View File

@ -1,21 +0,0 @@
// should match filename and ./ns.json
@ns: classic;
// challenge panes are bound to the pane size which in turn is
// bound to the total height minus navbar height
.max-element-height() {
overflow-x: hidden;
overflow-y: auto;
}
.@{ns}-instructions-panel {
.max-element-height();
padding-bottom: 10px;
padding-left: 5px;
padding-right: 5px;
}
.@{ns}-editor {
.max-element-height();
width: 100%;
}

View File

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

View File

@ -1 +0,0 @@
"classic"

View File

@ -1,3 +0,0 @@
&{ @import "./classic/classic.less"; }
&{ @import "./step/step.less"; }
&{ @import "./quiz/quiz.less"; }

View File

@ -1,158 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { reduxForm } from 'redux-form';
import {
Button,
FormGroup,
FormControl
} from 'react-bootstrap';
import { showProjectSubmit } from './redux';
import SolutionInput from '../../Solution-Input.jsx';
import { openChallengeModal } from '../../redux';
import {
isValidURL,
makeRequired,
createFormValidator,
getValidationState
} from '../../../../utils/form';
const propTypes = {
fields: PropTypes.object,
handleSubmit: PropTypes.func,
isSignedIn: PropTypes.bool,
isSubmitting: PropTypes.bool,
openChallengeModal: PropTypes.func.isRequired,
resetForm: PropTypes.func,
showProjectSubmit: PropTypes.func,
submitChallenge: PropTypes.func
};
const bindableActions = {
openChallengeModal,
showProjectSubmit
};
const frontEndFields = [ 'solution' ];
const backEndFields = [
'solution',
'githubLink'
];
const fieldValidators = {
solution: makeRequired(isValidURL)
};
const backEndFieldValidators = {
...fieldValidators,
githubLink: makeRequired(isValidURL)
};
export function _FrontEndForm({
fields,
handleSubmit,
openChallengeModal,
isSubmitting,
showProjectSubmit
}) {
const buttonCopy = isSubmitting ?
'Submit and go to my next challenge' :
"I've completed this challenge";
return (
<form
name='NewFrontEndProject'
onSubmit={ handleSubmit(openChallengeModal) }
>
{
isSubmitting ?
<SolutionInput
placeholder='https://codepen/your-project'
{ ...fields }
/> :
null
}
<Button
block={ true }
bsStyle='primary'
className='btn-big'
onClick={ isSubmitting ? null : showProjectSubmit }
type={ isSubmitting ? 'submit' : null }
>
{ buttonCopy } (ctrl + enter)
</Button>
</form>
);
}
_FrontEndForm.propTypes = propTypes;
export const FrontEndForm = reduxForm(
{
form: 'NewFrontEndProject',
fields: frontEndFields,
validate: createFormValidator(fieldValidators)
},
null,
bindableActions
)(_FrontEndForm);
export function _BackEndForm({
fields: { solution, githubLink },
handleSubmit,
openChallengeModal,
isSubmitting,
showProjectSubmit
}) {
const buttonCopy = isSubmitting ?
'Submit and go to my next challenge' :
"I've completed this challenge";
return (
<form
name='NewBackEndProject'
onSubmit={ handleSubmit(openChallengeModal) }
>
{
isSubmitting ?
<SolutionInput
placeholder='https://your-app.com'
solution={ solution }
/> :
null
}
{ isSubmitting ?
<FormGroup
controlId='githubLink'
validationState={ getValidationState(githubLink) }
>
<FormControl
name='githubLink'
placeholder='https://github.com/your-username/your-project'
type='url'
{ ...githubLink }
/>
</FormGroup> :
null
}
<Button
block={ true }
bsStyle='primary'
className='btn-big'
onClick={ isSubmitting ? null : showProjectSubmit }
type={ isSubmitting ? 'submit' : null }
>
{ buttonCopy } (ctrl + enter)
</Button>
</form>
);
}
_BackEndForm.propTypes = propTypes;
export const BackEndForm = reduxForm(
{
form: 'NewBackEndProject',
fields: backEndFields,
validate: createFormValidator(backEndFieldValidators)
},
null,
bindableActions
)(_BackEndForm);

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