Add view logic for all projects
This commit is contained in:
@@ -4,7 +4,10 @@ import { reducer as formReducer } from 'redux-form';
|
||||
import { reducer as app } from './redux';
|
||||
import entitiesReducer from './redux/entities-reducer';
|
||||
import { reducer as hikesApp } from './routes/Hikes/redux';
|
||||
import { reducer as challengesApp } from './routes/challenges/redux';
|
||||
import {
|
||||
reducer as challengesApp,
|
||||
projectNormalizer
|
||||
} from './routes/challenges/redux';
|
||||
import {
|
||||
reducer as jobsApp,
|
||||
formNormalizer as jobsNormalizer
|
||||
@@ -18,6 +21,9 @@ export default function createReducer(sideReducers = {}) {
|
||||
hikesApp,
|
||||
jobsApp,
|
||||
challengesApp,
|
||||
form: formReducer.normalize(jobsNormalizer)
|
||||
form: formReducer.normalize({
|
||||
...jobsNormalizer,
|
||||
...projectNormalizer
|
||||
})
|
||||
});
|
||||
}
|
||||
|
@@ -25,7 +25,11 @@ export default handleActions(
|
||||
toast
|
||||
}),
|
||||
|
||||
[types.setUser]: (state, { payload: user }) => ({ ...state, ...user }),
|
||||
[types.setUser]: (state, { payload: user }) => ({
|
||||
...state,
|
||||
...user,
|
||||
isSignedIn: true
|
||||
}),
|
||||
|
||||
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
|
||||
...state,
|
||||
|
@@ -5,12 +5,7 @@ import { push } from 'react-router-redux';
|
||||
import { reduxForm } from 'redux-form';
|
||||
// import debug from 'debug';
|
||||
import dedent from 'dedent';
|
||||
|
||||
import {
|
||||
isAscii,
|
||||
isEmail,
|
||||
isURL
|
||||
} from 'validator';
|
||||
import { isAscii, isEmail } from 'validator';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -19,6 +14,13 @@ import {
|
||||
Row
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import {
|
||||
isValidURL,
|
||||
makeOptional,
|
||||
makeRequired,
|
||||
createFormValidator,
|
||||
getValidationState
|
||||
} from '../../../utils/form';
|
||||
import { saveForm, loadSavedForm } from '../redux/actions';
|
||||
|
||||
// const log = debug('fcc:jobs:newForm');
|
||||
@@ -48,10 +50,6 @@ const certTypes = {
|
||||
isBackEndCert: 'isBackEndCert'
|
||||
};
|
||||
|
||||
function isValidURL(data) {
|
||||
return isURL(data, { require_protocol: true });
|
||||
}
|
||||
|
||||
const fields = [
|
||||
'position',
|
||||
'locale',
|
||||
@@ -78,35 +76,6 @@ const fieldValidators = {
|
||||
howToApply: makeRequired(isAscii)
|
||||
};
|
||||
|
||||
function makeOptional(validator) {
|
||||
return val => val ? validator(val) : true;
|
||||
}
|
||||
function makeRequired(validator) {
|
||||
return (val) => val ? validator(val) : false;
|
||||
}
|
||||
|
||||
function validateForm(values) {
|
||||
return Object.keys(fieldValidators)
|
||||
.map(field => {
|
||||
if (fieldValidators[field](values[field])) {
|
||||
return null;
|
||||
}
|
||||
return { [field]: !fieldValidators[field](values[field]) };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.reduce((errors, error) => ({ ...errors, ...error }), {});
|
||||
}
|
||||
|
||||
function getBsStyle(field) {
|
||||
if (field.pristine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return field.error ?
|
||||
'error' :
|
||||
'success';
|
||||
}
|
||||
|
||||
export class NewJob extends PureComponent {
|
||||
static displayName = 'NewJob';
|
||||
|
||||
@@ -223,7 +192,7 @@ export class NewJob extends PureComponent {
|
||||
</div>
|
||||
<hr />
|
||||
<Input
|
||||
bsStyle={ getBsStyle(position) }
|
||||
bsStyle={ getValidationState(position) }
|
||||
label='Job Title'
|
||||
labelClassName={ labelClass }
|
||||
placeholder={
|
||||
@@ -235,7 +204,7 @@ export class NewJob extends PureComponent {
|
||||
{ ...position }
|
||||
/>
|
||||
<Input
|
||||
bsStyle={ getBsStyle(locale) }
|
||||
bsStyle={ getValidationState(locale) }
|
||||
label='Location'
|
||||
labelClassName={ labelClass }
|
||||
placeholder='e.g. San Francisco, Remote, etc.'
|
||||
@@ -245,7 +214,7 @@ export class NewJob extends PureComponent {
|
||||
{ ...locale }
|
||||
/>
|
||||
<Input
|
||||
bsStyle={ getBsStyle(description) }
|
||||
bsStyle={ getValidationState(description) }
|
||||
label='Description'
|
||||
labelClassName={ labelClass }
|
||||
required={ true }
|
||||
@@ -268,7 +237,7 @@ export class NewJob extends PureComponent {
|
||||
<h2>How should they apply?</h2>
|
||||
</div>
|
||||
<Input
|
||||
bsStyle={ getBsStyle(howToApply) }
|
||||
bsStyle={ getValidationState(howToApply) }
|
||||
label=' '
|
||||
labelClassName={ labelClass }
|
||||
placeholder={ howToApplyCopy }
|
||||
@@ -286,7 +255,7 @@ export class NewJob extends PureComponent {
|
||||
<h2>Tell us about your organization</h2>
|
||||
</div>
|
||||
<Input
|
||||
bsStyle={ getBsStyle(company) }
|
||||
bsStyle={ getValidationState(company) }
|
||||
label='Company Name'
|
||||
labelClassName={ labelClass }
|
||||
onChange={ (e) => handleChange('company', e) }
|
||||
@@ -295,7 +264,7 @@ export class NewJob extends PureComponent {
|
||||
{ ...company }
|
||||
/>
|
||||
<Input
|
||||
bsStyle={ getBsStyle(email) }
|
||||
bsStyle={ getValidationState(email) }
|
||||
label='Email'
|
||||
labelClassName={ labelClass }
|
||||
placeholder='This is how we will contact you'
|
||||
@@ -305,7 +274,7 @@ export class NewJob extends PureComponent {
|
||||
{ ...email }
|
||||
/>
|
||||
<Input
|
||||
bsStyle={ getBsStyle(url) }
|
||||
bsStyle={ getValidationState(url) }
|
||||
label='URL'
|
||||
labelClassName={ labelClass }
|
||||
placeholder='http://yourcompany.com'
|
||||
@@ -314,7 +283,7 @@ export class NewJob extends PureComponent {
|
||||
{ ...url }
|
||||
/>
|
||||
<Input
|
||||
bsStyle={ getBsStyle(logo) }
|
||||
bsStyle={ getValidationState(logo) }
|
||||
label='Logo'
|
||||
labelClassName={ labelClass }
|
||||
placeholder='http://yourcompany.com/logo.png'
|
||||
@@ -381,7 +350,7 @@ export default reduxForm(
|
||||
{
|
||||
form: 'NewJob',
|
||||
fields,
|
||||
validate: validateForm
|
||||
validate: createFormValidator(fieldValidators)
|
||||
},
|
||||
state => ({ initialValues: state.jobsApp.initialValues }),
|
||||
{
|
||||
|
@@ -1,42 +1,19 @@
|
||||
import normalizeUrl from 'normalize-url';
|
||||
import {
|
||||
inHTMLData,
|
||||
uriInSingleQuotedAttr
|
||||
} from 'xss-filters';
|
||||
|
||||
const normalizeOptions = {
|
||||
stripWWW: false
|
||||
};
|
||||
|
||||
function ifDefinedNormalize(normalizer) {
|
||||
return value => value ? normalizer(value) : value;
|
||||
}
|
||||
|
||||
function formatUrl(url) {
|
||||
if (
|
||||
typeof url === 'string' &&
|
||||
url.length > 4 &&
|
||||
url.indexOf('.') !== -1
|
||||
) {
|
||||
// prevent trailing / from being stripped during typing
|
||||
let lastChar = '';
|
||||
if (url.substring(url.length - 1) === '/') {
|
||||
lastChar = '/';
|
||||
}
|
||||
return normalizeUrl(url, normalizeOptions) + lastChar;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
import { callIfDefined, formatUrl } from '../../../utils/form';
|
||||
|
||||
export default {
|
||||
NewJob: {
|
||||
position: ifDefinedNormalize(inHTMLData),
|
||||
locale: ifDefinedNormalize(inHTMLData),
|
||||
description: ifDefinedNormalize(inHTMLData),
|
||||
email: ifDefinedNormalize(inHTMLData),
|
||||
url: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))),
|
||||
logo: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))),
|
||||
company: ifDefinedNormalize(inHTMLData),
|
||||
howToApply: ifDefinedNormalize(inHTMLData)
|
||||
position: callIfDefined(inHTMLData),
|
||||
locale: callIfDefined(inHTMLData),
|
||||
description: callIfDefined(inHTMLData),
|
||||
email: callIfDefined(inHTMLData),
|
||||
url: callIfDefined(value => formatUrl(uriInSingleQuotedAttr(value))),
|
||||
logo: callIfDefined(value => formatUrl(uriInSingleQuotedAttr(value))),
|
||||
company: callIfDefined(inHTMLData),
|
||||
howToApply: callIfDefined(inHTMLData)
|
||||
}
|
||||
};
|
||||
|
@@ -15,7 +15,8 @@ import { challengeSelector } from '../redux/selectors';
|
||||
const views = {
|
||||
step: Step,
|
||||
classic: Classic,
|
||||
project: Project
|
||||
project: Project,
|
||||
simple: Project
|
||||
};
|
||||
|
||||
const bindableActions = {
|
||||
|
154
common/app/routes/challenges/components/project/Forms.jsx
Normal file
154
common/app/routes/challenges/components/project/Forms.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import {
|
||||
Button,
|
||||
FormGroup,
|
||||
FormControl
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import {
|
||||
isValidURL,
|
||||
makeRequired,
|
||||
createFormValidator,
|
||||
getValidationState
|
||||
} from '../../../../utils/form';
|
||||
import { submitChallenge, showProjectSubmit } from '../../redux/actions';
|
||||
|
||||
const propTypes = {
|
||||
isSignedIn: PropTypes.bool,
|
||||
isSubmitting: PropTypes.bool,
|
||||
showProjectSubmit: PropTypes.func,
|
||||
fields: PropTypes.object,
|
||||
handleSubmit: PropTypes.func,
|
||||
submitChallenge: PropTypes.func
|
||||
};
|
||||
|
||||
const bindableActions = { submitChallenge, showProjectSubmit };
|
||||
const frontEndFields = [ 'solution' ];
|
||||
const backEndFields = [
|
||||
'solution',
|
||||
'githubLink'
|
||||
];
|
||||
|
||||
const fieldValidators = {
|
||||
solution: makeRequired(isValidURL)
|
||||
};
|
||||
|
||||
const backEndFieldValidators = {
|
||||
...fieldValidators,
|
||||
githubLink: makeRequired(isValidURL)
|
||||
};
|
||||
|
||||
export function SolutionInput({ solution }) {
|
||||
return (
|
||||
<FormGroup
|
||||
controlId='solution'
|
||||
validationState={ getValidationState(solution) }
|
||||
>
|
||||
<FormControl
|
||||
name='solution'
|
||||
placeholder='https://codepen.io/your-pen-here'
|
||||
type='url'
|
||||
{ ...solution}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
SolutionInput.propTypes = { solution: PropTypes.object };
|
||||
|
||||
export function _FrontEndForm({
|
||||
fields,
|
||||
handleSubmit,
|
||||
submitChallenge,
|
||||
isSubmitting,
|
||||
showProjectSubmit
|
||||
}) {
|
||||
const buttonCopy = isSubmitting ?
|
||||
'Submit and go to my next challenge' :
|
||||
"I've completed this challenge";
|
||||
return (
|
||||
<form
|
||||
name='NewFrontEndProject'
|
||||
onSubmit={ handleSubmit(submitChallenge)}
|
||||
>
|
||||
{ isSubmitting ? <SolutionInput { ...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,
|
||||
submitChallenge,
|
||||
isSubmitting,
|
||||
showProjectSubmit
|
||||
}) {
|
||||
const buttonCopy = isSubmitting ?
|
||||
'Submit and go to my next challenge' :
|
||||
"I've completed this challenge";
|
||||
return (
|
||||
<form
|
||||
name='NewBackEndProject'
|
||||
onSubmit={ handleSubmit(submitChallenge)}
|
||||
>
|
||||
{ isSubmitting ? <SolutionInput 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);
|
@@ -4,42 +4,28 @@ import { connect } from 'react-redux';
|
||||
|
||||
import Youtube from 'react-youtube';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
import { Button, ButtonGroup, Col } from 'react-bootstrap';
|
||||
import { Col } from 'react-bootstrap';
|
||||
import SidePanel from './Side-Panel.jsx';
|
||||
import ToolPanel from './Tool-Panel.jsx';
|
||||
|
||||
import { challengeSelector } from '../../redux/selectors';
|
||||
|
||||
const bindableActions = {};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
challengeSelector,
|
||||
state => state.app.windowHeight,
|
||||
state => state.app.navHeight,
|
||||
state => state.app.isSignedIn,
|
||||
state => state.challengesApp.tests,
|
||||
state => state.challengesApp.output,
|
||||
(
|
||||
{
|
||||
challenge: {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
challengeSeed: [ videoId = ''] = []
|
||||
challengeSeed: [ videoId = '' ] = []
|
||||
} = {}
|
||||
},
|
||||
windowHeight,
|
||||
navHeight,
|
||||
isSignedIn,
|
||||
tests,
|
||||
output
|
||||
}
|
||||
) => ({
|
||||
id,
|
||||
videoId,
|
||||
title,
|
||||
description,
|
||||
height: windowHeight - navHeight - 20,
|
||||
tests,
|
||||
output,
|
||||
isSignedIn
|
||||
description
|
||||
})
|
||||
);
|
||||
|
||||
@@ -50,32 +36,9 @@ export class Project extends PureComponent {
|
||||
videoId: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.arrayOf(PropTypes.string),
|
||||
isCompleted: PropTypes.bool,
|
||||
isSignedIn: PropTypes.bool
|
||||
isCompleted: PropTypes.bool
|
||||
};
|
||||
|
||||
renderIcon(isCompleted) {
|
||||
if (!isCompleted) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<i
|
||||
className='ion-checkmark-circled text-primary'
|
||||
title='Completed'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDescription(title = '', description = []) {
|
||||
return description
|
||||
.map((line, index) => (
|
||||
<li
|
||||
className='step-text wrappable'
|
||||
dangerouslySetInnerHTML={{ __html: line }}
|
||||
key={ title.slice(6) + index }
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
@@ -83,30 +46,22 @@ export class Project extends PureComponent {
|
||||
title,
|
||||
videoId,
|
||||
isCompleted,
|
||||
description,
|
||||
isSignedIn
|
||||
description
|
||||
} = this.props;
|
||||
|
||||
|
||||
const buttonCopy = isSignedIn ?
|
||||
"I've completed this challenge" :
|
||||
'Go to my next challenge';
|
||||
return (
|
||||
<div>
|
||||
<Col md={ 4 }>
|
||||
<h4 className='text-center challenge-instructions-title'>
|
||||
{ title }
|
||||
{ this.renderIcon(isCompleted) }
|
||||
</h4>
|
||||
<hr />
|
||||
<ul>
|
||||
{ this.renderDescription(title, description) }
|
||||
</ul>
|
||||
<SidePanel
|
||||
description={ description }
|
||||
isCompleted={ isCompleted }
|
||||
title={ title }
|
||||
/>
|
||||
</Col>
|
||||
<Col
|
||||
md={ 8 }
|
||||
xs={ 12 }
|
||||
>
|
||||
>
|
||||
<div className='embed-responsive embed-responsive-16by9'>
|
||||
<Youtube
|
||||
className='embed-responsive-item'
|
||||
@@ -115,30 +70,7 @@ export class Project extends PureComponent {
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<Button
|
||||
block={ true }
|
||||
bsStyle='primary'
|
||||
className='btn-big'
|
||||
>
|
||||
{ buttonCopy } (ctrl + enter)
|
||||
</Button>
|
||||
<div className='button-spacer' />
|
||||
<ButtonGroup justified={ true }>
|
||||
<Button
|
||||
bsStyle='primary'
|
||||
className='btn-primary-ghost btn-big'
|
||||
componentClass='div'
|
||||
>
|
||||
Help
|
||||
</Button>
|
||||
<Button
|
||||
bsStyle='primary'
|
||||
className='btn-primary-ghost btn-big'
|
||||
componentClass='div'
|
||||
>
|
||||
Bug
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ToolPanel />
|
||||
<br />
|
||||
</Col>
|
||||
</div>
|
||||
@@ -147,6 +79,5 @@ export class Project extends PureComponent {
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
bindableActions
|
||||
mapStateToProps
|
||||
)(Project);
|
||||
|
@@ -0,0 +1,50 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
|
||||
export default class SidePanel extends PureComponent {
|
||||
static propTypes = {
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.arrayOf(PropTypes.string),
|
||||
isCompleted: PropTypes.bool,
|
||||
isSignedIn: PropTypes.bool
|
||||
};
|
||||
|
||||
renderIcon(isCompleted) {
|
||||
if (!isCompleted) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<i
|
||||
className='ion-checkmark-circled text-primary'
|
||||
title='Completed'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDescription(title = '', description = []) {
|
||||
return description.map((line, index) => (
|
||||
<li
|
||||
className='step-text wrappable'
|
||||
dangerouslySetInnerHTML={{ __html: line }}
|
||||
key={ title.slice(6) + index }
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title, description, isCompleted } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<h4 className='text-center challenge-instructions-title'>
|
||||
{ title }
|
||||
{ this.renderIcon(isCompleted) }
|
||||
</h4>
|
||||
<hr />
|
||||
<ul>
|
||||
{ this.renderDescription(title, description) }
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
101
common/app/routes/challenges/components/project/Tool-Panel.jsx
Normal file
101
common/app/routes/challenges/components/project/Tool-Panel.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { Button, ButtonGroup } from 'react-bootstrap';
|
||||
import {
|
||||
FrontEndForm,
|
||||
BackEndForm
|
||||
} from './Forms.jsx';
|
||||
|
||||
import { submitChallenge } from '../../redux/actions';
|
||||
import { challengeSelector } from '../../redux/selectors';
|
||||
import {
|
||||
simpleProject,
|
||||
frontEndProject
|
||||
} from '../../../../utils/challengeTypes';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
challengeSelector,
|
||||
state => state.app.isSignedIn,
|
||||
state => state.challengesApp.isSubmitting,
|
||||
(
|
||||
{ challenge: { challengeType = simpleProject } },
|
||||
isSignedIn,
|
||||
isSubmitting
|
||||
) => ({
|
||||
isSignedIn,
|
||||
isSubmitting,
|
||||
isSimple: challengeType === simpleProject,
|
||||
isFrontEnd: challengeType === frontEndProject
|
||||
})
|
||||
);
|
||||
|
||||
export class ToolPanel extends PureComponent {
|
||||
static propTypes = {
|
||||
isSignedIn: PropTypes.bool,
|
||||
isSimple: PropTypes.bool,
|
||||
isFrontEnd: PropTypes.bool,
|
||||
isSubmitting: PropTypes.bool
|
||||
};
|
||||
|
||||
renderSubmitButton(isSignedIn, submitChallenge) {
|
||||
const buttonCopy = isSignedIn ?
|
||||
'Submit and go to my next challenge' :
|
||||
"I've completed this challenge";
|
||||
return (
|
||||
<Button
|
||||
block={ true }
|
||||
bsStyle='primary'
|
||||
className='btn-big'
|
||||
onClick={ submitChallenge }
|
||||
>
|
||||
{ buttonCopy } (ctrl + enter)
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFrontEnd,
|
||||
isSimple,
|
||||
isSignedIn,
|
||||
isSubmitting,
|
||||
submitChallenge
|
||||
} = this.props;
|
||||
|
||||
const FormElement = isFrontEnd ? FrontEndForm : BackEndForm;
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
isSimple ?
|
||||
this.renderSubmitButton(isSignedIn, submitChallenge) :
|
||||
<FormElement isSubmitting={ isSubmitting }/>
|
||||
}
|
||||
<div className='button-spacer' />
|
||||
<ButtonGroup justified={ true }>
|
||||
<Button
|
||||
bsStyle='primary'
|
||||
className='btn-primary-ghost btn-big'
|
||||
componentClass='div'
|
||||
>
|
||||
Help
|
||||
</Button>
|
||||
<Button
|
||||
bsStyle='primary'
|
||||
className='btn-primary-ghost btn-big'
|
||||
componentClass='div'
|
||||
>
|
||||
Bug
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ submitChallenge }
|
||||
)(ToolPanel);
|
@@ -58,6 +58,7 @@ export const updateOutput = createAction(types.updateOutput, loggerToStr);
|
||||
|
||||
export const checkChallenge = createAction(types.checkChallenge);
|
||||
|
||||
export const showProjectSubmit = createAction(types.showProjectSubmit);
|
||||
let id = 0;
|
||||
export const showChallengeComplete = createAction(
|
||||
types.showChallengeComplete,
|
||||
|
@@ -64,6 +64,35 @@ function completedChallenge(state) {
|
||||
return Observable.merge(saveChallenge$, challengeCompleted$);
|
||||
}
|
||||
|
||||
function submitModern(type, state) {
|
||||
const { tests } = state.challengesApp;
|
||||
if (tests.length > 0 && tests.every(test => test.pass && !test.err)) {
|
||||
if (type === types.checkChallenge) {
|
||||
return Observable.of(
|
||||
showChallengeComplete()
|
||||
);
|
||||
}
|
||||
|
||||
if (type === types.submitChallenge) {
|
||||
return completedChallenge(state);
|
||||
}
|
||||
}
|
||||
return Observable.just(makeToast({
|
||||
message: 'Not all tests are passing, yet.',
|
||||
title: 'Almost There!',
|
||||
type: 'info'
|
||||
}));
|
||||
}
|
||||
|
||||
function submitFrontEnd() {
|
||||
return Observable.just(null);
|
||||
}
|
||||
|
||||
const submitTypes = {
|
||||
tests: submitModern,
|
||||
'project.frontEnd': submitFrontEnd
|
||||
};
|
||||
|
||||
export default function completionSaga(actions$, getState) {
|
||||
return actions$
|
||||
.filter(({ type }) => (
|
||||
@@ -71,36 +100,22 @@ export default function completionSaga(actions$, getState) {
|
||||
type === types.submitChallenge ||
|
||||
type === types.moveToNextChallenge
|
||||
))
|
||||
.flatMap(({ type }) => {
|
||||
.flatMap(({ type, payload }) => {
|
||||
const state = getState();
|
||||
const { tests } = state.challengesApp;
|
||||
if (tests.length > 0 && tests.every(test => test.pass && !test.err)) {
|
||||
if (type === types.checkChallenge) {
|
||||
return Observable.of(
|
||||
showChallengeComplete()
|
||||
);
|
||||
}
|
||||
|
||||
if (type === types.submitChallenge) {
|
||||
return completedChallenge(state);
|
||||
}
|
||||
|
||||
if (type === types.moveToNextChallenge) {
|
||||
const nextChallenge = getNextChallenge(
|
||||
state.challengesApp.challenge,
|
||||
state.entities,
|
||||
state.challengesApp.superBlocks
|
||||
);
|
||||
return Observable.of(
|
||||
updateCurrentChallenge(nextChallenge),
|
||||
push(`/challenges/${nextChallenge.dashedName}`)
|
||||
);
|
||||
}
|
||||
const { submitType } = challengeSelector(state);
|
||||
const submitter = submitTypes[submitType] ||
|
||||
(() => Observable.just(null));
|
||||
if (type === types.moveToNextChallenge) {
|
||||
const nextChallenge = getNextChallenge(
|
||||
state.challengesApp.challenge,
|
||||
state.entities,
|
||||
state.challengesApp.superBlocks
|
||||
);
|
||||
return Observable.of(
|
||||
updateCurrentChallenge(nextChallenge),
|
||||
push(`/challenges/${nextChallenge.dashedName}`)
|
||||
);
|
||||
}
|
||||
return Observable.just(makeToast({
|
||||
message: 'Not all tests are passing, yet.',
|
||||
title: 'Almost There!',
|
||||
type: 'info'
|
||||
}));
|
||||
return submitter(type, state, payload);
|
||||
});
|
||||
}
|
||||
|
@@ -5,4 +5,6 @@ export types from './types';
|
||||
import fetchChallengesSaga from './fetch-challenges-saga';
|
||||
import completionSaga from './completion-saga';
|
||||
|
||||
export projectNormalizer from './project-normalizer';
|
||||
|
||||
export const sagas = [ fetchChallengesSaga, completionSaga ];
|
||||
|
11
common/app/routes/challenges/redux/project-normalizer.js
Normal file
11
common/app/routes/challenges/redux/project-normalizer.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { callIfDefined, formatUrl } from '../../../utils/form';
|
||||
|
||||
export default {
|
||||
NewFrontEndProject: {
|
||||
solution: callIfDefined(formatUrl)
|
||||
},
|
||||
NewBackEndProject: {
|
||||
githubLink: callIfDefined(formatUrl),
|
||||
solution: callIfDefined(formatUrl)
|
||||
}
|
||||
};
|
@@ -2,7 +2,7 @@ import { handleActions } from 'redux-actions';
|
||||
import { createPoly } from '../../../../utils/polyvinyl';
|
||||
|
||||
import types from './types';
|
||||
import { BONFIRE, HTML, JS } from '../../../utils/challengeTypes';
|
||||
import { bonfire, html, js } from '../../../utils/challengeTypes';
|
||||
import {
|
||||
arrayToString,
|
||||
buildSeed,
|
||||
@@ -50,6 +50,10 @@ const mainReducer = handleActions(
|
||||
...state,
|
||||
toast
|
||||
}),
|
||||
[types.showProjectSubmit]: state => ({
|
||||
...state,
|
||||
isSubmitting: true
|
||||
}),
|
||||
|
||||
// map
|
||||
[types.updateFilter]: (state, { payload = ''}) => ({
|
||||
@@ -105,9 +109,9 @@ const filesReducer = handleActions(
|
||||
return challenge.files;
|
||||
}
|
||||
if (
|
||||
challenge.challengeType !== HTML &&
|
||||
challenge.challengeType !== JS &&
|
||||
challenge.challengeType !== BONFIRE
|
||||
challenge.challengeType !== html &&
|
||||
challenge.challengeType !== js &&
|
||||
challenge.challengeType !== bonfire
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
@@ -2,16 +2,34 @@ import * as challengeTypes from '../../../utils/challengeTypes';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
const viewTypes = {
|
||||
[ challengeTypes.HTML ]: 'classic',
|
||||
[ challengeTypes.JS ]: 'classic',
|
||||
[ challengeTypes.BONFIRE ]: 'classic',
|
||||
[ challengeTypes.ZIPLINE ]: 'project',
|
||||
[ challengeTypes.BASEJUMP ]: 'project',
|
||||
[ challengeTypes.html]: 'classic',
|
||||
[ challengeTypes.js ]: 'classic',
|
||||
[ challengeTypes.bonfire ]: 'classic',
|
||||
[ challengeTypes.frontEndProject]: 'project',
|
||||
[ challengeTypes.backEndProject]: 'project',
|
||||
// might not be used anymore
|
||||
[ challengeTypes.OLDVIDEO ]: 'video',
|
||||
[ challengeTypes.simpleProject]: 'project',
|
||||
// formally hikes
|
||||
[ challengeTypes.VIDEO ]: 'video',
|
||||
[ challengeTypes.STEP ]: 'step'
|
||||
[ challengeTypes.video ]: 'video',
|
||||
[ challengeTypes.step ]: 'step'
|
||||
};
|
||||
|
||||
const submitTypes = {
|
||||
[ challengeTypes.html ]: 'tests',
|
||||
[ challengeTypes.js ]: 'tests',
|
||||
[ challengeTypes.bonfire ]: 'tests',
|
||||
// requires just a button press
|
||||
[ challengeTypes.simpleProject ]: 'project.simple',
|
||||
// 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',
|
||||
// formally hikes
|
||||
[ challengeTypes.video ]: 'video',
|
||||
[ challengeTypes.step ]: 'step'
|
||||
};
|
||||
|
||||
export const challengeSelector = createSelector(
|
||||
@@ -22,14 +40,13 @@ export const challengeSelector = createSelector(
|
||||
return {};
|
||||
}
|
||||
const challenge = challengeMap[challengeName];
|
||||
const challengeType = challenge && challenge.challengeType;
|
||||
return {
|
||||
challenge: challenge,
|
||||
viewType: viewTypes[challenge.challengeType] || 'classic',
|
||||
|
||||
showPreview: challenge &&
|
||||
challenge.challengeType === challengeTypes.HTML,
|
||||
|
||||
mode: challenge && challenge.challengeType === challengeTypes.HTML ?
|
||||
challenge,
|
||||
viewType: viewTypes[challengeType] || 'classic',
|
||||
submitType: submitTypes[challengeType] || 'tests',
|
||||
showPreview: challengeType === challengeTypes.html,
|
||||
mode: challenge && challengeType === challengeTypes.html ?
|
||||
'text/html' :
|
||||
'javascript'
|
||||
};
|
||||
|
@@ -30,6 +30,7 @@ export default createTypes([
|
||||
'updateTests',
|
||||
'checkChallenge',
|
||||
'showChallengeComplete',
|
||||
'showProjectSubmit',
|
||||
'submitChallenge',
|
||||
'moveToNextChallenge',
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { compose } from 'redux';
|
||||
import { BONFIRE, HTML, JS } from '../../utils/challengeTypes';
|
||||
import { bonfire, html, js } from '../../utils/challengeTypes';
|
||||
import { dashify } from '../../../utils';
|
||||
|
||||
export function encodeScriptTags(value) {
|
||||
@@ -41,9 +41,9 @@ export function buildSeed({ challengeSeed = [] } = {}) {
|
||||
}
|
||||
|
||||
const pathsMap = {
|
||||
[HTML]: 'html',
|
||||
[JS]: 'js',
|
||||
[BONFIRE]: 'js'
|
||||
[html]: 'html',
|
||||
[js]: 'js',
|
||||
[bonfire]: 'js'
|
||||
};
|
||||
|
||||
export function getPreFile({ challengeType }) {
|
||||
|
@@ -1,8 +1,11 @@
|
||||
export const HTML = '0';
|
||||
export const JS = '1';
|
||||
export const OLDVIDEO = '2';
|
||||
export const ZIPLINE = '3';
|
||||
export const BASEJUMP = '4';
|
||||
export const BONFIRE = '5';
|
||||
export const VIDEO = '6';
|
||||
export const STEP = '7';
|
||||
export const html = '0';
|
||||
export const js = '1';
|
||||
export const oldVideo = '2';
|
||||
export const simpleProject = '2';
|
||||
export const zipline = '3';
|
||||
export const frontEndProject = '3';
|
||||
export const basejump = '4';
|
||||
export const backEndProject = '4';
|
||||
export const bonfire = '5';
|
||||
export const video = '6';
|
||||
export const step = '7';
|
||||
|
70
common/app/utils/form.js
Normal file
70
common/app/utils/form.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import normalizeUrl from 'normalize-url';
|
||||
import { isURL } from 'validator';
|
||||
|
||||
const normalizeOptions = {
|
||||
stripWWW: false
|
||||
};
|
||||
|
||||
// callIfDefined(fn: (Any) => Any) => (value: Any) => Any
|
||||
export function callIfDefined(fn) {
|
||||
return value => value ? fn(value) : value;
|
||||
}
|
||||
|
||||
// formatUrl(url: String) => String
|
||||
export function formatUrl(url) {
|
||||
if (
|
||||
typeof url === 'string' &&
|
||||
url.length > 4 &&
|
||||
url.indexOf('.') !== -1
|
||||
) {
|
||||
// prevent trailing / from being stripped during typing
|
||||
let lastChar = '';
|
||||
if (url.substring(url.length - 1) === '/') {
|
||||
lastChar = '/';
|
||||
}
|
||||
// prevent normalize-url from stripping last dot during typing
|
||||
if (url.substring(url.length - 1) === '.') {
|
||||
lastChar = '.';
|
||||
}
|
||||
return normalizeUrl(url, normalizeOptions) + lastChar;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function isValidURL(data) {
|
||||
/* eslint-disable quote-props */
|
||||
return isURL(data, { 'require_protocol': true });
|
||||
/* eslint-enable quote-props */
|
||||
}
|
||||
|
||||
export function makeOptional(validator) {
|
||||
return val => val ? validator(val) : true;
|
||||
}
|
||||
|
||||
export function makeRequired(validator) {
|
||||
return (val) => val ? validator(val) : false;
|
||||
}
|
||||
|
||||
export function createFormValidator(fieldValidators) {
|
||||
const fieldKeys = Object.keys(fieldValidators);
|
||||
return values => fieldKeys
|
||||
.map(field => {
|
||||
if (fieldValidators[field](values[field])) {
|
||||
return null;
|
||||
}
|
||||
return { [field]: !fieldValidators[field](values[field]) };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.reduce((errors, error) => ({ ...errors, ...error }), {});
|
||||
}
|
||||
|
||||
|
||||
export function getValidationState(field) {
|
||||
if (field.pristine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return field.error ?
|
||||
'error' :
|
||||
'success';
|
||||
}
|
Reference in New Issue
Block a user