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