From dc363963690f220f813444e570d2aa119cc82687 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 7 Jun 2016 20:41:42 -0700 Subject: [PATCH] Add view logic for all projects --- common/app/create-reducer.js | 10 +- common/app/redux/reducer.js | 6 +- common/app/routes/Jobs/components/NewJob.jsx | 65 ++------ .../routes/Jobs/redux/jobs-form-normalizer.js | 41 +---- .../app/routes/challenges/components/Show.jsx | 3 +- .../challenges/components/project/Forms.jsx | 154 ++++++++++++++++++ .../challenges/components/project/Project.jsx | 101 ++---------- .../components/project/Side-Panel.jsx | 50 ++++++ .../components/project/Tool-Panel.jsx | 101 ++++++++++++ common/app/routes/challenges/redux/actions.js | 1 + .../challenges/redux/completion-saga.js | 73 +++++---- common/app/routes/challenges/redux/index.js | 2 + .../challenges/redux/project-normalizer.js | 11 ++ common/app/routes/challenges/redux/reducer.js | 12 +- .../app/routes/challenges/redux/selectors.js | 47 ++++-- common/app/routes/challenges/redux/types.js | 1 + common/app/routes/challenges/utils.js | 8 +- common/app/utils/challengeTypes.js | 19 ++- common/app/utils/form.js | 70 ++++++++ 19 files changed, 546 insertions(+), 229 deletions(-) create mode 100644 common/app/routes/challenges/components/project/Forms.jsx create mode 100644 common/app/routes/challenges/components/project/Side-Panel.jsx create mode 100644 common/app/routes/challenges/components/project/Tool-Panel.jsx create mode 100644 common/app/routes/challenges/redux/project-normalizer.js create mode 100644 common/app/utils/form.js diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js index c962d6415b..e91c704a8e 100644 --- a/common/app/create-reducer.js +++ b/common/app/create-reducer.js @@ -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 + }) }); } diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js index fd2ba31390..ee93ba151d 100644 --- a/common/app/redux/reducer.js +++ b/common/app/redux/reducer.js @@ -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, diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx index 9f657d19ef..1bab05eb77 100644 --- a/common/app/routes/Jobs/components/NewJob.jsx +++ b/common/app/routes/Jobs/components/NewJob.jsx @@ -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 {
How should they apply? Tell us about your organization handleChange('company', e) } @@ -295,7 +264,7 @@ export class NewJob extends PureComponent { { ...company } /> ({ initialValues: state.jobsApp.initialValues }), { diff --git a/common/app/routes/Jobs/redux/jobs-form-normalizer.js b/common/app/routes/Jobs/redux/jobs-form-normalizer.js index fb6ed8bc62..df7baa8507 100644 --- a/common/app/routes/Jobs/redux/jobs-form-normalizer.js +++ b/common/app/routes/Jobs/redux/jobs-form-normalizer.js @@ -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) } }; diff --git a/common/app/routes/challenges/components/Show.jsx b/common/app/routes/challenges/components/Show.jsx index ae3e7f3e6f..095af60b0e 100644 --- a/common/app/routes/challenges/components/Show.jsx +++ b/common/app/routes/challenges/components/Show.jsx @@ -15,7 +15,8 @@ import { challengeSelector } from '../redux/selectors'; const views = { step: Step, classic: Classic, - project: Project + project: Project, + simple: Project }; const bindableActions = { diff --git a/common/app/routes/challenges/components/project/Forms.jsx b/common/app/routes/challenges/components/project/Forms.jsx new file mode 100644 index 0000000000..3ab5b62df9 --- /dev/null +++ b/common/app/routes/challenges/components/project/Forms.jsx @@ -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 ( + + + + ); +} + +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 ( +
+ { isSubmitting ? : null } + + + ); +} + +_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 ( +
+ { isSubmitting ? : null } + { isSubmitting ? + + + : + null + } + + + ); +} + +_BackEndForm.propTypes = propTypes; + +export const BackEndForm = reduxForm( + { + form: 'NewBackEndProject', + fields: backEndFields, + validate: createFormValidator(backEndFieldValidators) + }, + null, + bindableActions +)(_BackEndForm); diff --git a/common/app/routes/challenges/components/project/Project.jsx b/common/app/routes/challenges/components/project/Project.jsx index 05518335e4..deae5424cb 100644 --- a/common/app/routes/challenges/components/project/Project.jsx +++ b/common/app/routes/challenges/components/project/Project.jsx @@ -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 ( - - ); - } - - renderDescription(title = '', description = []) { - return description - .map((line, 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 (
    -

    - { title } - { this.renderIcon(isCompleted) } -

    -
    -
      - { this.renderDescription(title, description) } -
    + + >

    - -
    - - - - +
    @@ -147,6 +79,5 @@ export class Project extends PureComponent { } export default connect( - mapStateToProps, - bindableActions + mapStateToProps )(Project); diff --git a/common/app/routes/challenges/components/project/Side-Panel.jsx b/common/app/routes/challenges/components/project/Side-Panel.jsx new file mode 100644 index 0000000000..e4ecdd6d25 --- /dev/null +++ b/common/app/routes/challenges/components/project/Side-Panel.jsx @@ -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 ( + + ); + } + + renderDescription(title = '', description = []) { + return description.map((line, index) => ( +
  • + )); + } + + render() { + const { title, description, isCompleted } = this.props; + return ( +
    +

    + { title } + { this.renderIcon(isCompleted) } +

    +
    +
      + { this.renderDescription(title, description) } +
    +
    + ); + } +} diff --git a/common/app/routes/challenges/components/project/Tool-Panel.jsx b/common/app/routes/challenges/components/project/Tool-Panel.jsx new file mode 100644 index 0000000000..1a1d38db03 --- /dev/null +++ b/common/app/routes/challenges/components/project/Tool-Panel.jsx @@ -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 ( + + ); + } + + render() { + const { + isFrontEnd, + isSimple, + isSignedIn, + isSubmitting, + submitChallenge + } = this.props; + + const FormElement = isFrontEnd ? FrontEndForm : BackEndForm; + return ( +
    + { + isSimple ? + this.renderSubmitButton(isSignedIn, submitChallenge) : + + } +
    + + + + +
    + ); + } +} + +export default connect( + mapStateToProps, + { submitChallenge } +)(ToolPanel); diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index a8c23782bf..2d20da0adb 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -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, diff --git a/common/app/routes/challenges/redux/completion-saga.js b/common/app/routes/challenges/redux/completion-saga.js index ce3dbd3d4b..ad9dce4aee 100644 --- a/common/app/routes/challenges/redux/completion-saga.js +++ b/common/app/routes/challenges/redux/completion-saga.js @@ -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); }); } diff --git a/common/app/routes/challenges/redux/index.js b/common/app/routes/challenges/redux/index.js index 35165ea9f2..920d0f9e7f 100644 --- a/common/app/routes/challenges/redux/index.js +++ b/common/app/routes/challenges/redux/index.js @@ -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 ]; diff --git a/common/app/routes/challenges/redux/project-normalizer.js b/common/app/routes/challenges/redux/project-normalizer.js new file mode 100644 index 0000000000..a7c69981dc --- /dev/null +++ b/common/app/routes/challenges/redux/project-normalizer.js @@ -0,0 +1,11 @@ +import { callIfDefined, formatUrl } from '../../../utils/form'; + +export default { + NewFrontEndProject: { + solution: callIfDefined(formatUrl) + }, + NewBackEndProject: { + githubLink: callIfDefined(formatUrl), + solution: callIfDefined(formatUrl) + } +}; diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index 6f78ad1d28..cddf1cc9c0 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -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 {}; } diff --git a/common/app/routes/challenges/redux/selectors.js b/common/app/routes/challenges/redux/selectors.js index 9c989ed24a..2318d3eb1f 100644 --- a/common/app/routes/challenges/redux/selectors.js +++ b/common/app/routes/challenges/redux/selectors.js @@ -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' }; diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index fc6db656d2..12a006b52f 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -30,6 +30,7 @@ export default createTypes([ 'updateTests', 'checkChallenge', 'showChallengeComplete', + 'showProjectSubmit', 'submitChallenge', 'moveToNextChallenge', diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index 7689ca88cf..8cf63ed7e1 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -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 }) { diff --git a/common/app/utils/challengeTypes.js b/common/app/utils/challengeTypes.js index 7f0835208b..b353282eca 100644 --- a/common/app/utils/challengeTypes.js +++ b/common/app/utils/challengeTypes.js @@ -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'; diff --git a/common/app/utils/form.js b/common/app/utils/form.js new file mode 100644 index 0000000000..877657c5e8 --- /dev/null +++ b/common/app/utils/form.js @@ -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'; +}