Feat: Welcome Page (#17150)
This commit is contained in:
committed by
mrugesh mohapatra
parent
47bb4ca5e3
commit
156ea1af76
@ -4,20 +4,13 @@ import Rx from 'rx';
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { render } from 'redux-epic';
|
import { render } from 'redux-epic';
|
||||||
import createHistory from 'history/createBrowserHistory';
|
import createHistory from 'history/createBrowserHistory';
|
||||||
import useLangRoutes from './utils/use-lang-routes';
|
|
||||||
import sendPageAnalytics from './utils/send-page-analytics';
|
import sendPageAnalytics from './utils/send-page-analytics';
|
||||||
|
|
||||||
import { App, createApp, provideStore } from '../common/app';
|
import { App, createApp, provideStore } from '../common/app';
|
||||||
import { getLangFromPath } from '../common/app/utils/lang';
|
|
||||||
|
|
||||||
// client specific epics
|
// client specific epics
|
||||||
import epics from './epics';
|
import epics from './epics';
|
||||||
|
|
||||||
import {
|
|
||||||
isColdStored,
|
|
||||||
getColdStorage,
|
|
||||||
saveToColdStorage
|
|
||||||
} from './cold-reload';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
__OPBEAT__ORG_ID,
|
__OPBEAT__ORG_ID,
|
||||||
@ -47,7 +40,7 @@ const {
|
|||||||
document,
|
document,
|
||||||
ga,
|
ga,
|
||||||
__fcc__: {
|
__fcc__: {
|
||||||
data: ssrState = {},
|
data: defaultState = {},
|
||||||
csrf: {
|
csrf: {
|
||||||
token: csrfToken
|
token: csrfToken
|
||||||
} = {}
|
} = {}
|
||||||
@ -63,10 +56,6 @@ const epicOptions = {
|
|||||||
|
|
||||||
|
|
||||||
const DOMContainer = document.getElementById('fcc');
|
const DOMContainer = document.getElementById('fcc');
|
||||||
const defaultState = isColdStored() ?
|
|
||||||
getColdStorage() :
|
|
||||||
ssrState;
|
|
||||||
const primaryLang = getLangFromPath(location.pathname);
|
|
||||||
|
|
||||||
defaultState.app.csrfToken = csrfToken;
|
defaultState.app.csrfToken = csrfToken;
|
||||||
|
|
||||||
@ -76,7 +65,7 @@ const serviceOptions = {
|
|||||||
xhrTimeout: 15000
|
xhrTimeout: 15000
|
||||||
};
|
};
|
||||||
|
|
||||||
const history = useLangRoutes(createHistory, primaryLang)();
|
const history = createHistory();
|
||||||
sendPageAnalytics(history, ga);
|
sendPageAnalytics(history, ga);
|
||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
@ -88,14 +77,13 @@ createApp({
|
|||||||
enhancers: isDev && devToolsExtension && [ devToolsExtension() ],
|
enhancers: isDev && devToolsExtension && [ devToolsExtension() ],
|
||||||
middlewares: enableOpbeat && [ createOpbeatMiddleware() ]
|
middlewares: enableOpbeat && [ createOpbeatMiddleware() ]
|
||||||
})
|
})
|
||||||
.doOnNext(({ store }) => {
|
.doOnNext(() => {
|
||||||
if (module.hot && typeof module.hot.accept === 'function') {
|
if (module.hot && typeof module.hot.accept === 'function') {
|
||||||
module.hot.accept(() => {
|
module.hot.accept(() => {
|
||||||
// note(berks): not sure this ever runs anymore after adding
|
// note(berks): not sure this ever runs anymore after adding
|
||||||
// RHR?
|
// RHR?
|
||||||
log('saving state and refreshing.');
|
log('saving state and refreshing.');
|
||||||
log('ignore react ssr warning.');
|
log('ignore react ssr warning.');
|
||||||
saveToColdStorage(store.getState());
|
|
||||||
setTimeout(() => location.reload(), hotReloadTimeout);
|
setTimeout(() => location.reload(), hotReloadTimeout);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -152,6 +152,24 @@ h1, h2, h3, h4, h5, h6, p, li {
|
|||||||
width: 100px;
|
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 {
|
.completion-icon {
|
||||||
font-size: 150px;
|
font-size: 150px;
|
||||||
}
|
}
|
||||||
|
@ -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(); }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
@ -10,17 +10,19 @@ import {
|
|||||||
isSignedInSelector
|
isSignedInSelector
|
||||||
} from './redux';
|
} from './redux';
|
||||||
|
|
||||||
|
import { fetchMapUi } from './Map/redux';
|
||||||
|
|
||||||
import Flash from './Flash';
|
import Flash from './Flash';
|
||||||
import Nav from './Nav';
|
import Nav from './Nav';
|
||||||
import Toasts from './Toasts';
|
import Toasts from './Toasts';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { mainRouteSelector } from './routes/redux';
|
import { mainRouteSelector } from './routes/redux';
|
||||||
import Challenges from './routes/Challenges';
|
|
||||||
import Profile from './routes/Profile';
|
import Profile from './routes/Profile';
|
||||||
import Settings from './routes/Settings';
|
import Settings from './routes/Settings';
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
appMounted,
|
appMounted,
|
||||||
|
fetchMapUi,
|
||||||
fetchUser
|
fetchUser
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -37,6 +39,7 @@ const mapStateToProps = state => {
|
|||||||
const propTypes = {
|
const propTypes = {
|
||||||
appMounted: PropTypes.func.isRequired,
|
appMounted: PropTypes.func.isRequired,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
|
fetchMapUi: PropTypes.func.isRequired,
|
||||||
fetchUser: PropTypes.func,
|
fetchUser: PropTypes.func,
|
||||||
isSignedIn: PropTypes.bool,
|
isSignedIn: PropTypes.bool,
|
||||||
route: PropTypes.string,
|
route: PropTypes.string,
|
||||||
@ -44,7 +47,6 @@ const propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
challenges: Challenges,
|
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
settings: Settings
|
settings: Settings
|
||||||
};
|
};
|
||||||
@ -53,6 +55,7 @@ const routes = {
|
|||||||
export class FreeCodeCamp extends React.Component {
|
export class FreeCodeCamp extends React.Component {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.appMounted();
|
this.props.appMounted();
|
||||||
|
this.props.fetchMapUi();
|
||||||
if (!this.props.isSignedIn) {
|
if (!this.props.isSignedIn) {
|
||||||
this.props.fetchUser();
|
this.props.fetchUser();
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
@ -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;
|
|
@ -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'>
|
|
||||||
   
|
|
||||||
<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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -1 +0,0 @@
|
|||||||
export default from './Map.jsx';
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
"map"
|
|
@ -3,38 +3,34 @@ import debug from 'debug';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
types as appTypes,
|
types as appTypes,
|
||||||
createErrorObservable,
|
createErrorObservable
|
||||||
currentChallengeSelector
|
|
||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
import { types, fetchMapUiComplete } from './';
|
import { types, fetchMapUiComplete } from './';
|
||||||
import { langSelector } from '../../Router/redux';
|
|
||||||
import { shapeChallenges } from '../../redux/utils';
|
import { shapeChallenges } from '../../redux/utils';
|
||||||
|
|
||||||
const isDev = debug.enabled('fcc:*');
|
const isDev = debug.enabled('fcc:*');
|
||||||
|
|
||||||
export default function fetchMapUiEpic(
|
export default function fetchMapUiEpic(
|
||||||
actions,
|
actions,
|
||||||
{ getState },
|
_,
|
||||||
{ services }
|
{ services }
|
||||||
) {
|
) {
|
||||||
return actions::ofType(
|
return actions.do(console.log)::ofType(
|
||||||
appTypes.appMounted,
|
appTypes.appMounted,
|
||||||
types.fetchMapUi.start
|
types.fetchMapUi.start
|
||||||
)
|
)
|
||||||
.flatMapLatest(() => {
|
.flatMapLatest(() => {
|
||||||
const lang = langSelector(getState());
|
|
||||||
const options = {
|
const options = {
|
||||||
params: { lang },
|
|
||||||
service: 'map-ui'
|
service: 'map-ui'
|
||||||
};
|
};
|
||||||
return services.readService$(options)
|
return services.readService$(options)
|
||||||
.retry(3)
|
.retry(3)
|
||||||
|
.do(console.info)
|
||||||
.map(({ entities, ...res }) => ({
|
.map(({ entities, ...res }) => ({
|
||||||
entities: shapeChallenges(
|
entities: shapeChallenges(
|
||||||
entities,
|
entities,
|
||||||
isDev
|
isDev
|
||||||
),
|
),
|
||||||
initialNode: currentChallengeSelector(getState()),
|
|
||||||
...res
|
...res
|
||||||
}))
|
}))
|
||||||
.map(fetchMapUiComplete)
|
.map(fetchMapUiComplete)
|
||||||
|
@ -8,13 +8,14 @@ import { createSelector } from 'reselect';
|
|||||||
import { capitalize, noop } from 'lodash';
|
import { capitalize, noop } from 'lodash';
|
||||||
|
|
||||||
import * as utils from './utils.js';
|
import * as utils from './utils.js';
|
||||||
import ns from '../ns.json';
|
|
||||||
import {
|
import {
|
||||||
createEventMetaCreator
|
createEventMetaCreator
|
||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
|
|
||||||
import fetchMapUiEpic from './fetch-map-ui-epic';
|
import fetchMapUiEpic from './fetch-map-ui-epic';
|
||||||
|
|
||||||
|
const ns = 'map';
|
||||||
|
|
||||||
export const epics = [ fetchMapUiEpic ];
|
export const epics = [ fetchMapUiEpic ];
|
||||||
|
|
||||||
export const types = createTypes([
|
export const types = createTypes([
|
||||||
|
@ -2,11 +2,11 @@ import React from 'react';
|
|||||||
import Media from 'react-media';
|
import Media from 'react-media';
|
||||||
import { Col, Navbar, Row } from 'react-bootstrap';
|
import { Col, Navbar, Row } from 'react-bootstrap';
|
||||||
import FCCSearchBar from 'react-freecodecamp-search';
|
import FCCSearchBar from 'react-freecodecamp-search';
|
||||||
import { NavLogo, BinButtons, NavLinks } from './components';
|
import { NavLogo, NavLinks } from './components';
|
||||||
|
|
||||||
import propTypes from './navPropTypes';
|
import propTypes from './navPropTypes';
|
||||||
|
|
||||||
function LargeNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
|
function LargeNav({ clickOnLogo }) {
|
||||||
return (
|
return (
|
||||||
<Media
|
<Media
|
||||||
query='(min-width: 956px)'
|
query='(min-width: 956px)'
|
||||||
@ -19,15 +19,10 @@ function LargeNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
|
|||||||
<FCCSearchBar />
|
<FCCSearchBar />
|
||||||
</Navbar.Header>
|
</Navbar.Header>
|
||||||
</Col>
|
</Col>
|
||||||
<Col className='nav-component bins' sm={ 3 } xs={ 6 }>
|
<Col className='nav-component bins' sm={ 3 } xs={ 6 }/>
|
||||||
<BinButtons panes={ panes } />
|
|
||||||
</Col>
|
|
||||||
<Col className='nav-component nav-links' sm={ 4 } xs={ 0 }>
|
<Col className='nav-component nav-links' sm={ 4 } xs={ 0 }>
|
||||||
<Navbar.Collapse>
|
<Navbar.Collapse>
|
||||||
<NavLinks
|
<NavLinks />
|
||||||
clickOnMap={ clickOnMap }
|
|
||||||
shouldShowMapButton={ shouldShowMapButton }
|
|
||||||
/>
|
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -2,11 +2,11 @@ import React from 'react';
|
|||||||
import Media from 'react-media';
|
import Media from 'react-media';
|
||||||
import { Navbar, Row } from 'react-bootstrap';
|
import { Navbar, Row } from 'react-bootstrap';
|
||||||
import FCCSearchBar from 'react-freecodecamp-search';
|
import FCCSearchBar from 'react-freecodecamp-search';
|
||||||
import { NavLogo, BinButtons, NavLinks } from './components';
|
import { NavLogo, NavLinks } from './components';
|
||||||
|
|
||||||
import propTypes from './navPropTypes';
|
import propTypes from './navPropTypes';
|
||||||
|
|
||||||
function MediumNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
|
function MediumNav({ clickOnLogo }) {
|
||||||
return (
|
return (
|
||||||
<Media
|
<Media
|
||||||
query={{ maxWidth: 955, minWidth: 751 }}
|
query={{ maxWidth: 955, minWidth: 751 }}
|
||||||
@ -21,17 +21,12 @@ function MediumNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
|
|||||||
<NavLogo clickOnLogo={ clickOnLogo } />
|
<NavLogo clickOnLogo={ clickOnLogo } />
|
||||||
<FCCSearchBar />
|
<FCCSearchBar />
|
||||||
</div>
|
</div>
|
||||||
<div className='nav-component bins'>
|
<div className='nav-component bins'/>
|
||||||
<BinButtons panes={ panes } />
|
|
||||||
</div>
|
|
||||||
</Navbar.Header>
|
</Navbar.Header>
|
||||||
</Row>
|
</Row>
|
||||||
<Row className='collapse-row'>
|
<Row className='collapse-row'>
|
||||||
<Navbar.Collapse>
|
<Navbar.Collapse>
|
||||||
<NavLinks
|
<NavLinks />
|
||||||
clickOnMap={ clickOnMap }
|
|
||||||
shouldShowMapButton={ shouldShowMapButton }
|
|
||||||
/>
|
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,67 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { bindActionCreators } from 'redux';
|
import { bindActionCreators } from 'redux';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { Navbar } from 'react-bootstrap';
|
import { Navbar } from 'react-bootstrap';
|
||||||
|
|
||||||
import LargeNav from './LargeNav.jsx';
|
import LargeNav from './LargeNav.jsx';
|
||||||
import MediumNav from './MediumNav.jsx';
|
import MediumNav from './MediumNav.jsx';
|
||||||
import SmallNav from './SmallNav.jsx';
|
import SmallNav from './SmallNav.jsx';
|
||||||
import {
|
import {
|
||||||
clickOnLogo,
|
clickOnLogo
|
||||||
clickOnMap
|
|
||||||
} from './redux';
|
} from './redux';
|
||||||
import { panesSelector, panesByNameSelector } from '../Panes/redux';
|
|
||||||
import propTypes from './navPropTypes';
|
import propTypes from './navPropTypes';
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = () => ({});
|
||||||
panesSelector,
|
|
||||||
panesByNameSelector,
|
|
||||||
(panes, panesByName) => {
|
|
||||||
return {
|
|
||||||
panes: panes.map(({ name, type }) => {
|
|
||||||
return {
|
|
||||||
content: name,
|
|
||||||
action: type,
|
|
||||||
isHidden: panesByName[name].isHidden
|
|
||||||
};
|
|
||||||
}, {}),
|
|
||||||
shouldShowMapButton: panes.length === 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function mapDispatchToProps(dispatch) {
|
function mapDispatchToProps(dispatch) {
|
||||||
const dispatchers = bindActionCreators(
|
return bindActionCreators(
|
||||||
{
|
{
|
||||||
clickOnMap: e => {
|
clickOnLogo
|
||||||
e.preventDefault();
|
|
||||||
return clickOnMap();
|
|
||||||
},
|
|
||||||
clickOnLogo: e => {
|
|
||||||
e.preventDefault();
|
|
||||||
return clickOnLogo();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
dispatch
|
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 = [
|
const allNavs = [
|
||||||
@ -72,18 +30,12 @@ const allNavs = [
|
|||||||
|
|
||||||
function FCCNav(props) {
|
function FCCNav(props) {
|
||||||
const {
|
const {
|
||||||
panes,
|
clickOnLogo
|
||||||
clickOnLogo,
|
|
||||||
clickOnMap,
|
|
||||||
shouldShowMapButton
|
|
||||||
} = props;
|
} = props;
|
||||||
const withNavProps = Component => (
|
const withNavProps = Component => (
|
||||||
<Component
|
<Component
|
||||||
clickOnLogo={ clickOnLogo }
|
clickOnLogo={ clickOnLogo }
|
||||||
clickOnMap={ clickOnMap }
|
|
||||||
key={ Component.displayName }
|
key={ Component.displayName }
|
||||||
panes={ panes }
|
|
||||||
shouldShowMapButton={ shouldShowMapButton }
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@ -104,6 +56,5 @@ FCCNav.propTypes = propTypes;
|
|||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
mapDispatchToProps,
|
mapDispatchToProps
|
||||||
mergeProps
|
|
||||||
)(FCCNav);
|
)(FCCNav);
|
||||||
|
@ -2,11 +2,11 @@ import React from 'react';
|
|||||||
import Media from 'react-media';
|
import Media from 'react-media';
|
||||||
import { Navbar, Row } from 'react-bootstrap';
|
import { Navbar, Row } from 'react-bootstrap';
|
||||||
import FCCSearchBar from 'react-freecodecamp-search';
|
import FCCSearchBar from 'react-freecodecamp-search';
|
||||||
import { NavLogo, BinButtons, NavLinks } from './components';
|
import { NavLogo, NavLinks } from './components';
|
||||||
|
|
||||||
import propTypes from './navPropTypes';
|
import propTypes from './navPropTypes';
|
||||||
|
|
||||||
function SmallNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
|
function SmallNav({ clickOnLogo }) {
|
||||||
return (
|
return (
|
||||||
<Media
|
<Media
|
||||||
query='(max-width: 750px)'
|
query='(max-width: 750px)'
|
||||||
@ -20,17 +20,12 @@ function SmallNav({ clickOnLogo, clickOnMap, shouldShowMapButton, panes }) {
|
|||||||
<Navbar.Toggle />
|
<Navbar.Toggle />
|
||||||
<NavLogo clickOnLogo={ clickOnLogo } />
|
<NavLogo clickOnLogo={ clickOnLogo } />
|
||||||
</div>
|
</div>
|
||||||
<div className='nav-component bins'>
|
<div className='nav-component bins'/>
|
||||||
<BinButtons panes={ panes } />
|
|
||||||
</div>
|
|
||||||
</Navbar.Header>
|
</Navbar.Header>
|
||||||
</Row>
|
</Row>
|
||||||
<Row className='collapse-row'>
|
<Row className='collapse-row'>
|
||||||
<Navbar.Collapse>
|
<Navbar.Collapse>
|
||||||
<NavLinks
|
<NavLinks>
|
||||||
clickOnMap={ clickOnMap }
|
|
||||||
shouldShowMapButton={ shouldShowMapButton }
|
|
||||||
>
|
|
||||||
<FCCSearchBar />
|
<FCCSearchBar />
|
||||||
</NavLinks>
|
</NavLinks>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
|
@ -8,10 +8,8 @@ import { MenuItem, NavDropdown, NavItem, Nav } from 'react-bootstrap';
|
|||||||
|
|
||||||
import navLinks from '../links.json';
|
import navLinks from '../links.json';
|
||||||
import SignUp from './Sign-Up.jsx';
|
import SignUp from './Sign-Up.jsx';
|
||||||
import NoPropsPassThrough from '../../utils/No-Props-Passthrough.jsx';
|
|
||||||
import { Link } from '../../Router';
|
import { Link } from '../../Router';
|
||||||
|
|
||||||
import { onRouteCurrentChallenge } from '../../routes/Challenges/redux';
|
|
||||||
import {
|
import {
|
||||||
openDropdown,
|
openDropdown,
|
||||||
closeDropdown,
|
closeDropdown,
|
||||||
@ -58,14 +56,12 @@ const navLinkPropType = PropTypes.shape({
|
|||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
children: PropTypes.any,
|
children: PropTypes.any,
|
||||||
clickOnMap: PropTypes.func.isRequired,
|
|
||||||
closeDropdown: PropTypes.func.isRequired,
|
closeDropdown: PropTypes.func.isRequired,
|
||||||
isDropdownOpen: PropTypes.bool,
|
isDropdownOpen: PropTypes.bool,
|
||||||
isInNav: PropTypes.bool,
|
isInNav: PropTypes.bool,
|
||||||
isSignedIn: PropTypes.bool,
|
isSignedIn: PropTypes.bool,
|
||||||
navLinks: PropTypes.arrayOf(navLinkPropType),
|
navLinks: PropTypes.arrayOf(navLinkPropType),
|
||||||
openDropdown: PropTypes.func.isRequired,
|
openDropdown: PropTypes.func.isRequired,
|
||||||
shouldShowMapButton: PropTypes.bool,
|
|
||||||
showLoading: PropTypes.bool
|
showLoading: PropTypes.bool
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -125,8 +121,6 @@ class NavLinks extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
shouldShowMapButton,
|
|
||||||
clickOnMap,
|
|
||||||
showLoading,
|
showLoading,
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
navLinks,
|
navLinks,
|
||||||
@ -136,20 +130,6 @@ class NavLinks extends PureComponent {
|
|||||||
return (
|
return (
|
||||||
<Nav id='nav-links' navbar={ true } pullRight={ true }>
|
<Nav id='nav-links' navbar={ true } pullRight={ true }>
|
||||||
{ children }
|
{ children }
|
||||||
{
|
|
||||||
shouldShowMapButton ?
|
|
||||||
<NoPropsPassThrough>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
onClick={ clickOnMap }
|
|
||||||
to={ onRouteCurrentChallenge() }
|
|
||||||
>
|
|
||||||
Map
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</NoPropsPassThrough> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
navLinks.map(
|
navLinks.map(
|
||||||
this.renderLink.bind(this, isInNav)
|
this.renderLink.bind(this, isInNav)
|
||||||
|
@ -4,9 +4,7 @@ import { NavbarBrand } from 'react-bootstrap';
|
|||||||
import Media from 'react-media';
|
import Media from 'react-media';
|
||||||
|
|
||||||
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
|
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://s3.amazonaws.com/freecodecamp/FFCFire.png';
|
||||||
const fCCglyph = 'https://raw.githubusercontent.com/freeCodeCamp/assets/' +
|
|
||||||
'3b9cafc312802199ebba8b31fb1ed9b466a3efbb/assets/logos/FFCFire.png';
|
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
clickOnLogo: PropTypes.func.isRequired
|
clickOnLogo: PropTypes.func.isRequired
|
||||||
@ -16,7 +14,7 @@ function NavLogo({ clickOnLogo }) {
|
|||||||
return (
|
return (
|
||||||
<NavbarBrand>
|
<NavbarBrand>
|
||||||
<a
|
<a
|
||||||
href='/challenges/current-challenge'
|
href='/'
|
||||||
onClick={ clickOnLogo }
|
onClick={ clickOnLogo }
|
||||||
>
|
>
|
||||||
<Media query='(min-width: 735px)'>
|
<Media query='(min-width: 735px)'>
|
||||||
|
@ -1,43 +1,12 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"content": "Community",
|
"content": "Learn",
|
||||||
"isDropdown": true,
|
"link": "https://learn.freecodecamp.org",
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"content": "Chat",
|
|
||||||
"link": "https://gitter.im/freecodecamp/home",
|
|
||||||
"target": "_blank"
|
"target": "_blank"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"content": "Forum",
|
"content": "Forum",
|
||||||
"link": "https://forum.freecodecamp.org/",
|
"link": "https://forum.freecodecamp.org/",
|
||||||
"target": "_blank"
|
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
@ -1,8 +1,5 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
clickOnLogo: PropTypes.func.isRequired,
|
clickOnLogo: PropTypes.func.isRequired
|
||||||
clickOnMap: PropTypes.func.isRequired,
|
|
||||||
panes: PropTypes.array,
|
|
||||||
shouldShowMapButton: PropTypes.bool
|
|
||||||
};
|
};
|
||||||
|
@ -6,13 +6,10 @@ import {
|
|||||||
handleActions
|
handleActions
|
||||||
} from 'berkeleys-redux-utils';
|
} from 'berkeleys-redux-utils';
|
||||||
|
|
||||||
import loadCurrentChallengeEpic from './load-current-challenge-epic.js';
|
|
||||||
import ns from '../ns.json';
|
import ns from '../ns.json';
|
||||||
import { createEventMetaCreator } from '../../analytics/index';
|
import { createEventMetaCreator } from '../../analytics/index';
|
||||||
|
|
||||||
export const epics = [
|
export const epics = [];
|
||||||
loadCurrentChallengeEpic
|
|
||||||
];
|
|
||||||
|
|
||||||
export const types = createTypes([
|
export const types = createTypes([
|
||||||
'clickOnLogo',
|
'clickOnLogo',
|
||||||
|
@ -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);
|
|
||||||
}
|
|
@ -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);
|
|
@ -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);
|
|
@ -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'>“{quoteObj.quote}”</h2>
|
|
||||||
<h2 className='author-style'><i>—{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);
|
|
@ -1 +0,0 @@
|
|||||||
export default from './Panes.jsx';
|
|
@ -1 +0,0 @@
|
|||||||
"panes"
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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. Don’t 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)];
|
|
||||||
}
|
|
@ -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);
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
@ -5,18 +5,16 @@ import { createSelector } from 'reselect';
|
|||||||
|
|
||||||
import toUrl from './to-url.js';
|
import toUrl from './to-url.js';
|
||||||
import createHandler from './handle-press.js';
|
import createHandler from './handle-press.js';
|
||||||
import { routesMapSelector, langSelector } from './redux';
|
import { routesMapSelector } from './redux';
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = createSelector(
|
||||||
langSelector,
|
|
||||||
routesMapSelector,
|
routesMapSelector,
|
||||||
(lang, routesMap) => ({ lang, routesMap })
|
routesMap => ({ routesMap })
|
||||||
);
|
);
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
dispatch: PropTypes.func,
|
dispatch: PropTypes.func,
|
||||||
lang: PropTypes.string,
|
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
redirect: PropTypes.bool,
|
redirect: PropTypes.bool,
|
||||||
replace: PropTypes.bool,
|
replace: PropTypes.bool,
|
||||||
@ -31,7 +29,6 @@ export const Link = (
|
|||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
dispatch,
|
dispatch,
|
||||||
lang,
|
|
||||||
onClick,
|
onClick,
|
||||||
redirect,
|
redirect,
|
||||||
replace,
|
replace,
|
||||||
@ -42,7 +39,7 @@ export const Link = (
|
|||||||
to
|
to
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const url = toUrl(to, routesMap, lang);
|
const url = toUrl(to, routesMap);
|
||||||
const handler = createHandler(
|
const handler = createHandler(
|
||||||
url,
|
url,
|
||||||
routesMap,
|
routesMap,
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
@ -3,7 +3,6 @@ import { selectLocationState } from 'redux-first-router';
|
|||||||
export const paramsSelector = state => selectLocationState(state).payload || {};
|
export const paramsSelector = state => selectLocationState(state).payload || {};
|
||||||
export const locationTypeSelector =
|
export const locationTypeSelector =
|
||||||
state => selectLocationState(state).type || '';
|
state => selectLocationState(state).type || '';
|
||||||
export const langSelector = state => paramsSelector(state).lang || 'en';
|
|
||||||
export const routesMapSelector = state =>
|
export const routesMapSelector = state =>
|
||||||
selectLocationState(state).routesMap || {};
|
selectLocationState(state).routesMap || {};
|
||||||
export const pathnameSelector = state => selectLocationState(state).pathname;
|
export const pathnameSelector = state => selectLocationState(state).pathname;
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
import { actionToPath, getOptions } from 'redux-first-router';
|
import { actionToPath, getOptions } from 'redux-first-router';
|
||||||
|
|
||||||
import { addLang } from '../utils/lang.js';
|
export default (to, routesMap) => {
|
||||||
|
|
||||||
|
|
||||||
export default (to, routesMap, lang = 'en') => {
|
|
||||||
if (to && typeof to === 'string') {
|
if (to && typeof to === 'string') {
|
||||||
return addLang(to, lang);
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof to === 'object') {
|
if (typeof to === 'object') {
|
||||||
@ -17,8 +14,7 @@ export default (to, routesMap, lang = 'en') => {
|
|||||||
{
|
{
|
||||||
...action,
|
...action,
|
||||||
payload: {
|
payload: {
|
||||||
...payload,
|
...payload
|
||||||
lang: payload.lang || lang
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
routesMap,
|
routesMap,
|
||||||
|
@ -5,15 +5,8 @@ import { createSelector } from 'reselect';
|
|||||||
import { NotificationStack } from 'react-notification';
|
import { NotificationStack } from 'react-notification';
|
||||||
|
|
||||||
import { removeToast } from './redux';
|
import { removeToast } from './redux';
|
||||||
import {
|
|
||||||
submitChallenge,
|
|
||||||
clickOnReset
|
|
||||||
} from '../routes/Challenges/redux';
|
|
||||||
|
|
||||||
const registeredActions = {
|
const registeredActions = {};
|
||||||
submitChallenge,
|
|
||||||
clickOnReset
|
|
||||||
};
|
|
||||||
const mapStateToProps = state => ({ toasts: state.toasts });
|
const mapStateToProps = state => ({ toasts: state.toasts });
|
||||||
// we use styles here to overwrite those built into the library
|
// we use styles here to overwrite those built into the library
|
||||||
// but there are some styles applied using
|
// but there are some styles applied using
|
||||||
|
@ -7,12 +7,8 @@ import { combineReducers } from 'berkeleys-redux-utils';
|
|||||||
import { createEpic } from 'redux-epic';
|
import { createEpic } from 'redux-epic';
|
||||||
import appReducer from './reducer.js';
|
import appReducer from './reducer.js';
|
||||||
import routesMap from './routes-map.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 epics from './epics';
|
||||||
|
|
||||||
import { onBeforeChange } from './utils/redux-first-router.js';
|
|
||||||
import servicesCreator from '../utils/services-creator';
|
import servicesCreator from '../utils/services-creator';
|
||||||
|
|
||||||
const debug = createDebugger('fcc:app:createApp');
|
const debug = createDebugger('fcc:app:createApp');
|
||||||
@ -50,21 +46,14 @@ export default function createApp({
|
|||||||
reducer: routesReducer,
|
reducer: routesReducer,
|
||||||
middleware: routesMiddleware,
|
middleware: routesMiddleware,
|
||||||
enhancer: routesEnhancer
|
enhancer: routesEnhancer
|
||||||
} = connectRoutes(history, routesMap, { onBeforeChange });
|
} = connectRoutes(history, routesMap);
|
||||||
|
|
||||||
routesReducer.toString = () => 'location';
|
routesReducer.toString = () => 'location';
|
||||||
|
|
||||||
const {
|
|
||||||
reducer: panesReducer,
|
|
||||||
middleware: panesMiddleware
|
|
||||||
} = createPanesAspects({ createPanesMap });
|
|
||||||
|
|
||||||
const enhancer = compose(
|
const enhancer = compose(
|
||||||
addLangToRoutesEnhancer(routesMap),
|
|
||||||
routesEnhancer,
|
routesEnhancer,
|
||||||
applyMiddleware(
|
applyMiddleware(
|
||||||
routesMiddleware,
|
routesMiddleware,
|
||||||
panesMiddleware,
|
|
||||||
epicMiddleware,
|
epicMiddleware,
|
||||||
...sideMiddlewares
|
...sideMiddlewares
|
||||||
),
|
),
|
||||||
@ -75,7 +64,6 @@ export default function createApp({
|
|||||||
|
|
||||||
const reducer = combineReducers(
|
const reducer = combineReducers(
|
||||||
appReducer,
|
appReducer,
|
||||||
panesReducer,
|
|
||||||
routesReducer
|
routesReducer
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -95,7 +83,6 @@ export default function createApp({
|
|||||||
debug('hot reloading reducers');
|
debug('hot reloading reducers');
|
||||||
store.replaceReducer(combineReducers(
|
store.replaceReducer(combineReducers(
|
||||||
require('./reducer.js').default,
|
require('./reducer.js').default,
|
||||||
panesReducer,
|
|
||||||
routesReducer
|
routesReducer
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export { createPanesMap as default } from './routes/';
|
|
@ -1,7 +1,6 @@
|
|||||||
import { findIndex, property, merge, union } from 'lodash';
|
import { findIndex, property, merge } from 'lodash';
|
||||||
import uuid from 'uuid/v4';
|
import uuid from 'uuid/v4';
|
||||||
import {
|
import {
|
||||||
combineActions,
|
|
||||||
composeReducers,
|
composeReducers,
|
||||||
createAction,
|
createAction,
|
||||||
createTypes,
|
createTypes,
|
||||||
@ -9,8 +8,7 @@ import {
|
|||||||
} from 'berkeleys-redux-utils';
|
} from 'berkeleys-redux-utils';
|
||||||
|
|
||||||
import { themes } from '../../utils/themes';
|
import { themes } from '../../utils/themes';
|
||||||
import { usernameSelector, types as app } from '../redux';
|
import { usernameSelector } from '../redux';
|
||||||
import { types as challenges } from '../routes/Challenges/redux';
|
|
||||||
import { types as map } from '../Map/redux';
|
import { types as map } from '../Map/redux';
|
||||||
import legacyProjects from '../../utils/legacyProjectData';
|
import legacyProjects from '../../utils/legacyProjectData';
|
||||||
|
|
||||||
@ -204,36 +202,8 @@ export default composeReducers(
|
|||||||
},
|
},
|
||||||
handleActions(
|
handleActions(
|
||||||
() => ({
|
() => ({
|
||||||
[
|
[map.fetchMapUi.complete]: (state, { payload: { entities } }) =>
|
||||||
combineActions(
|
merge({}, state, entities),
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[types.addPortfolioItem]: (state, { payload: username }) => ({
|
[types.addPortfolioItem]: (state, { payload: username }) => ({
|
||||||
...state,
|
...state,
|
||||||
user: {
|
user: {
|
||||||
@ -324,22 +294,6 @@ export default composeReducers(
|
|||||||
languageTag
|
languageTag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
[types.updateUserCurrentChallenge]:
|
|
||||||
(
|
|
||||||
state,
|
|
||||||
{
|
|
||||||
payload: { username, currentChallengeId }
|
|
||||||
}
|
|
||||||
) => ({
|
|
||||||
...state,
|
|
||||||
user: {
|
|
||||||
...state.user,
|
|
||||||
[username]: {
|
|
||||||
...state.user[username],
|
|
||||||
currentChallengeId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
defaultState
|
defaultState
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
import { epics as app } from './redux';
|
import { epics as app } from './redux';
|
||||||
import { epics as challenge } from './routes/Challenges/redux';
|
|
||||||
import { epics as flash } from './Flash/redux';
|
import { epics as flash } from './Flash/redux';
|
||||||
import { epics as map } from './Map/redux';
|
import { epics as map } from './Map/redux';
|
||||||
import { epics as nav } from './Nav/redux';
|
import { epics as nav } from './Nav/redux';
|
||||||
import { epics as panes } from './Panes/redux';
|
|
||||||
import { epics as settings } from './routes/Settings/redux';
|
import { epics as settings } from './routes/Settings/redux';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
...app,
|
...app,
|
||||||
...challenge,
|
|
||||||
...flash,
|
...flash,
|
||||||
...map,
|
...map,
|
||||||
...nav,
|
...nav,
|
||||||
...panes,
|
|
||||||
...settings
|
...settings
|
||||||
];
|
];
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
);
|
|
@ -1,6 +1,4 @@
|
|||||||
&{ @import "./app.less"; }
|
&{ @import "./app.less"; }
|
||||||
&{ @import "./Map/map.less"; }
|
|
||||||
&{ @import "./Nav/nav.less"; }
|
&{ @import "./Nav/nav.less"; }
|
||||||
&{ @import "./Flash/flash.less"; }
|
&{ @import "./Flash/flash.less"; }
|
||||||
&{ @import "./routes/index.less"; }
|
&{ @import "./routes/index.less"; }
|
||||||
&{ @import "./Panes/panes.less"; }
|
|
||||||
|
@ -2,14 +2,14 @@ import { combineReducers } from 'berkeleys-redux-utils';
|
|||||||
|
|
||||||
import app from './redux';
|
import app from './redux';
|
||||||
import entities from './entities';
|
import entities from './entities';
|
||||||
import form from './redux-form-reducer';
|
import { reducer as form } from 'redux-form';
|
||||||
import map from './Map/redux';
|
import map from './Map/redux';
|
||||||
import nav from './Nav/redux';
|
import nav from './Nav/redux';
|
||||||
import routes from './routes/redux';
|
import routes from './routes/redux';
|
||||||
import toasts from './Toasts/redux';
|
import toasts from './Toasts/redux';
|
||||||
import files from './files';
|
|
||||||
import flash from './Flash/redux';
|
import flash from './Flash/redux';
|
||||||
|
|
||||||
|
form.toString = () => 'form';
|
||||||
|
|
||||||
export default combineReducers(
|
export default combineReducers(
|
||||||
app,
|
app,
|
||||||
@ -18,7 +18,6 @@ export default combineReducers(
|
|||||||
nav,
|
nav,
|
||||||
routes,
|
routes,
|
||||||
toasts,
|
toasts,
|
||||||
files,
|
|
||||||
flash,
|
flash,
|
||||||
form
|
form
|
||||||
);
|
);
|
||||||
|
@ -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
|
|
||||||
);
|
|
@ -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
|
|
||||||
);
|
|
@ -8,41 +8,25 @@ import {
|
|||||||
handleActions
|
handleActions
|
||||||
} from 'berkeleys-redux-utils';
|
} from 'berkeleys-redux-utils';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import debug from 'debug';
|
|
||||||
|
|
||||||
import fetchUserEpic from './fetch-user-epic.js';
|
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 nightModeEpic from './night-mode-epic.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
updateThemeMetacreator,
|
updateThemeMetacreator,
|
||||||
entitiesSelector,
|
entitiesSelector
|
||||||
fullBlocksSelector
|
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { utils } from '../Flash/redux';
|
import { utils } from '../Flash/redux';
|
||||||
import { paramsSelector } from '../Router/redux';
|
import { paramsSelector } from '../Router/redux';
|
||||||
import { types as challenges } from '../routes/Challenges/redux';
|
|
||||||
import { types as map } from '../Map/redux';
|
import { types as map } from '../Map/redux';
|
||||||
import {
|
|
||||||
createCurrentChallengeMeta,
|
|
||||||
challengeToFilesMetaCreator,
|
|
||||||
getFirstChallengeOfNextBlock,
|
|
||||||
getFirstChallengeOfNextSuperBlock,
|
|
||||||
getNextChallenge
|
|
||||||
} from '../routes/Challenges/utils';
|
|
||||||
|
|
||||||
import ns from '../ns.json';
|
import ns from '../ns.json';
|
||||||
|
|
||||||
import { themes, invertTheme } from '../../utils/themes.js';
|
import { themes, invertTheme } from '../../utils/themes.js';
|
||||||
|
|
||||||
const isDev = debug.enabled('fcc:*');
|
|
||||||
|
|
||||||
export const epics = [
|
export const epics = [
|
||||||
fetchChallengesEpic,
|
|
||||||
fetchUserEpic,
|
fetchUserEpic,
|
||||||
nightModeEpic,
|
nightModeEpic
|
||||||
updateMyCurrentChallengeEpic
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const types = createTypes([
|
export const types = createTypes([
|
||||||
@ -52,9 +36,6 @@ export const types = createTypes([
|
|||||||
'analytics',
|
'analytics',
|
||||||
'updateTitle',
|
'updateTitle',
|
||||||
|
|
||||||
createAsyncTypes('fetchChallenge'),
|
|
||||||
createAsyncTypes('fetchChallenges'),
|
|
||||||
createAsyncTypes('fetchNewBlock'),
|
|
||||||
createAsyncTypes('fetchOtherUser'),
|
createAsyncTypes('fetchOtherUser'),
|
||||||
createAsyncTypes('fetchUser'),
|
createAsyncTypes('fetchUser'),
|
||||||
'showSignIn',
|
'showSignIn',
|
||||||
@ -107,29 +88,6 @@ export function createEventMetaCreator({
|
|||||||
|
|
||||||
export const onRouteHome = createAction(types.onRouteHome);
|
export const onRouteHome = createAction(types.onRouteHome);
|
||||||
export const appMounted = createAction(types.appMounted);
|
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
|
// updateTitle(title: String) => Action
|
||||||
export const updateTitle = createAction(types.updateTitle);
|
export const updateTitle = createAction(types.updateTitle);
|
||||||
@ -202,8 +160,6 @@ const defaultState = {
|
|||||||
isSignInAttempted: false,
|
isSignInAttempted: false,
|
||||||
user: '',
|
user: '',
|
||||||
csrfToken: '',
|
csrfToken: '',
|
||||||
// eventually this should be only in the user object
|
|
||||||
currentChallenge: '',
|
|
||||||
superBlocks: []
|
superBlocks: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -211,8 +167,6 @@ export const getNS = state => state[ns];
|
|||||||
export const csrfSelector = state => getNS(state).csrfToken;
|
export const csrfSelector = state => getNS(state).csrfToken;
|
||||||
export const titleSelector = state => getNS(state).title;
|
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 signInLoadingSelector = state => !getNS(state).isSignInAttempted;
|
||||||
|
|
||||||
export const usernameSelector = state => getNS(state).user || '';
|
export const usernameSelector = state => getNS(state).user || '';
|
||||||
@ -235,92 +189,6 @@ export const themeSelector = flow(
|
|||||||
|
|
||||||
export const isSignedInSelector = state => !!userSelector(state).username;
|
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(
|
export default handleActions(
|
||||||
() => ({
|
() => ({
|
||||||
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
|
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
|
||||||
@ -332,28 +200,16 @@ export default handleActions(
|
|||||||
...state,
|
...state,
|
||||||
user
|
user
|
||||||
}),
|
}),
|
||||||
[combineActions(
|
[map.fetchMapUi.complete]: (state, { payload }) => ({
|
||||||
types.fetchChallenge.complete,
|
|
||||||
map.fetchMapUi.complete
|
|
||||||
)]: (state, { payload }) => ({
|
|
||||||
...state,
|
...state,
|
||||||
superBlocks: payload.result.superBlocks
|
superBlocks: payload.result.superBlocks
|
||||||
}),
|
}),
|
||||||
[challenges.onRouteChallenges]: (state, { payload: { dashedName } }) => ({
|
|
||||||
...state,
|
|
||||||
currentChallenge: dashedName
|
|
||||||
}),
|
|
||||||
[
|
[
|
||||||
combineActions(types.showSignIn, types.fetchUser.complete)
|
combineActions(types.showSignIn, types.fetchUser.complete)
|
||||||
]: state => ({
|
]: state => ({
|
||||||
...state,
|
...state,
|
||||||
isSignInAttempted: true
|
isSignInAttempted: true
|
||||||
}),
|
}),
|
||||||
|
|
||||||
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
|
|
||||||
...state,
|
|
||||||
points
|
|
||||||
}),
|
|
||||||
[types.delayedRedirect]: (state, { payload }) => ({
|
[types.delayedRedirect]: (state, { payload }) => ({
|
||||||
...state,
|
...state,
|
||||||
delayedRedirect: payload
|
delayedRedirect: payload
|
||||||
|
@ -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);
|
|
||||||
}
|
|
@ -1,52 +1,7 @@
|
|||||||
import flowRight from 'lodash/flowRight';
|
import flowRight from 'lodash/flowRight';
|
||||||
import { createNameIdMap } from '../../utils/map.js';
|
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(
|
export const shapeChallenges = flowRight(
|
||||||
filterComingSoonBetaFromEntities,
|
|
||||||
entities => ({
|
entities => ({
|
||||||
...entities,
|
...entities,
|
||||||
...createNameIdMap(entities)
|
...createNameIdMap(entities)
|
||||||
|
@ -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'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -2,18 +2,16 @@ import reduce from 'lodash/reduce';
|
|||||||
import { types } from './redux';
|
import { types } from './redux';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
|
||||||
const base = '/:lang';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...reduce(routes, (routes, route, type) => {
|
...reduce(routes, (routes, route, type) => {
|
||||||
let newRoute;
|
let newRoute;
|
||||||
if (typeof route === 'string') {
|
if (typeof route === 'string') {
|
||||||
newRoute = base + route;
|
newRoute = route;
|
||||||
} else {
|
} else {
|
||||||
newRoute = { ...route, path: base + route.path };
|
newRoute = { ...route, path: route.path };
|
||||||
}
|
}
|
||||||
routes[type] = newRoute;
|
routes[type] = newRoute;
|
||||||
return routes;
|
return routes;
|
||||||
}, {}),
|
}, {}),
|
||||||
[types.routeOnHome]: base
|
[types.routeOnHome]: '/'
|
||||||
};
|
};
|
||||||
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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
|
|
||||||
<a href={ RSA } target='_blank' title='Read, search, ask'>
|
|
||||||
Read-Search-Ask</a> 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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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"; }
|
|
@ -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';
|
|
@ -1 +0,0 @@
|
|||||||
"challenges"
|
|
@ -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
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
@ -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
|
|
||||||
);
|
|
@ -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
|
|
||||||
);
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
@ -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);
|
|
@ -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
|
|
||||||
);
|
|
@ -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;
|
|
@ -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 }
|
|
||||||
}));
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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() });
|
|
@ -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,
|
|
||||||
);
|
|
@ -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;
|
|
||||||
}
|
|
@ -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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -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);
|
|
@ -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);
|
|
@ -1 +0,0 @@
|
|||||||
export { default, mapStateToPanes } from './Show.jsx';
|
|
@ -1 +0,0 @@
|
|||||||
"modern"
|
|
@ -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);
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||||||
export { default, mapStateToPanes } from './Show.jsx';
|
|
@ -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);
|
|
@ -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;
|
|
@ -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%;
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export { default, mapStateToPanes } from './Show.jsx';
|
|
@ -1 +0,0 @@
|
|||||||
"classic"
|
|
@ -1,3 +0,0 @@
|
|||||||
&{ @import "./classic/classic.less"; }
|
|
||||||
&{ @import "./step/step.less"; }
|
|
||||||
&{ @import "./quiz/quiz.less"; }
|
|
@ -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
Reference in New Issue
Block a user