diff --git a/client/index.js b/client/index.js index 79c5c541c1..44bdb0a82c 100644 --- a/client/index.js +++ b/client/index.js @@ -4,7 +4,7 @@ import React from 'react'; import Fetchr from 'fetchr'; import debugFactory from 'debug'; import { Router } from 'react-router'; -import { history } from 'react-router/lib/BrowserHistory'; +import { createLocation, createHistory } from 'history'; import { hydrate } from 'thundercats'; import { Render } from 'thundercats-react'; @@ -18,21 +18,29 @@ const services = new Fetchr({ }); Rx.config.longStackSupport = !!debug.enabled; - +const history = createHistory(); +const appLocation = createLocation( + location.pathname + location.search +); // returns an observable -app$(history) +app$({ history, location: appLocation }) .flatMap( ({ AppCat }) => { + // instantiate the cat with service const appCat = AppCat(null, services); + // hydrate the stores return hydrate(appCat, catState) .map(() => appCat); }, - ({ initialState }, appCat) => ({ initialState, appCat }) + // not using nextLocation at the moment but will be used for + // redirects in the future + ({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat }) ) - .flatMap(({ initialState, appCat }) => { + .flatMap(({ props, appCat }) => { + props.history = history; return Render( appCat, - React.createElement(Router, initialState), + React.createElement(Router, props), DOMContianer ); }) diff --git a/common/app/app-stream.jsx b/common/app/app-stream.jsx index 25ae2a6300..82d5568c09 100644 --- a/common/app/app-stream.jsx +++ b/common/app/app-stream.jsx @@ -1,17 +1,17 @@ import Rx from 'rx'; -import { Router } from 'react-router'; +import { match } from 'react-router'; import App from './App.jsx'; import AppCat from './Cat'; import childRoutes from './routes'; -const router$ = Rx.Observable.fromNodeCallback(Router.run, Router); +const route$ = Rx.Observable.fromNodeCallback(match); const routes = Object.assign({ components: App }, childRoutes); -export default function app$(location) { - return router$(routes, location) - .map(([initialState, transistion]) => { - return { initialState, transistion, AppCat }; +export default function app$({ location, history }) { + return route$({ routes, location, history }) + .map(([nextLocation, props]) => { + return { nextLocation, props, AppCat }; }); } diff --git a/common/app/routes/Jobs/components/CreateJobModal.jsx b/common/app/routes/Jobs/components/CreateJobModal.jsx new file mode 100644 index 0000000000..446ed957d6 --- /dev/null +++ b/common/app/routes/Jobs/components/CreateJobModal.jsx @@ -0,0 +1,43 @@ +import React, { PropTypes } from 'react'; +import { History } from 'react-router'; +import { Button, Modal } from 'react-bootstrap'; + +export default React.createClass({ + displayName: 'CreateJobsModal', + + propTypes: { + onHide: PropTypes.func, + showModal: PropTypes.bool + }, + + mixins: [History], + + goToNewJob(onHide) { + onHide(); + this.history.pushState(null, '/jobs/new'); + }, + + render() { + const { + showModal, + onHide + } = this.props; + + return ( + + +

Welcome to Free Code Camp's board

+

We post jobs specifically target to our junior developers.

+ +
+
+ ); + } +}); diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index a6bc6a9a9f..a4d8354b3f 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -1,7 +1,9 @@ import React, { cloneElement, PropTypes } from 'react'; import { contain } from 'thundercats-react'; -import { Navigation } from 'react-router'; +import { History } from 'react-router'; import { Button, Jumbotron, Row } from 'react-bootstrap'; + +import CreateJobModal from './CreateJobModal.jsx'; import ListJobs from './List.jsx'; export default contain( @@ -13,12 +15,14 @@ export default contain( React.createClass({ displayName: 'Jobs', + mixins: [History], + propTypes: { children: PropTypes.element, jobActions: PropTypes.object, - jobs: PropTypes.array + jobs: PropTypes.array, + showModal: PropTypes.bool }, - mixins: [Navigation], handleJobClick(id) { const { jobActions } = this.props; @@ -26,7 +30,7 @@ export default contain( return null; } jobActions.findJob(id); - this.transitionTo(`/jobs/${id}`); + this.history.pushState(null, `/jobs/${id}`); }, renderList(handleJobClick, jobs) { @@ -48,7 +52,12 @@ export default contain( }, render() { - const { children, jobs } = this.props; + const { + children, + jobs, + showModal, + jobActions + } = this.props; return (
@@ -62,7 +71,8 @@ export default contain(

@@ -70,7 +80,10 @@ export default contain( { this.renderChild(children, jobs) || this.renderList(this.handleJobClick, jobs) } - + +
); } diff --git a/common/app/routes/Jobs/components/List.jsx b/common/app/routes/Jobs/components/List.jsx index ec8325a98b..2457bcb7f0 100644 --- a/common/app/routes/Jobs/components/List.jsx +++ b/common/app/routes/Jobs/components/List.jsx @@ -22,6 +22,7 @@ export default React.createClass({ id, company, position, + isHighlighted, description, logo, city, @@ -44,6 +45,7 @@ export default React.createClass({ ); return ( { + // if value exist, check if it is valid + if (props[prop].value && props[prop].type !== 'boolean') { + valid = valid && !!props[prop].valid; + } + }); + + if (!valid) { + debug('form not valid'); + return; + } + + const { + position, + locale, + description, + email, + phone, + url, + logo, + name, + highlight, + jobActions + } = this.props; + + // sanitize user output + const jobValues = { + position: inHTMLData(position.value), + location: inHTMLData(locale.value), + description: inHTMLData(description.value), + email: inHTMLData(email.value), + phone: inHTMLData(phone.value), + url: uriInSingleQuotedAttr(url.value), + logo: uriInSingleQuotedAttr(logo.value), + name: inHTMLData(name.value), + highlight: !!highlight.value + }; + + const job = Object.keys(jobValues).reduce((accu, prop) => { + if (jobValues[prop]) { + accu[prop] = jobValues[prop]; + } + return accu; + }, {}); + + job.postedOn = new Date(); + debug('job sanitized', job); + jobActions.saveForm(job); + + this.history.pushState(null, '/jobs/new/preview'); + }, + + componentDidMount() { + const { jobActions } = this.props; + jobActions.getSavedForm(); + }, + + handleChange(name, { target: { value } }) { + const { jobActions: { handleForm } } = this.props; + handleForm({ [name]: value }); + }, + + render() { + const { + position, + locale, + description, + email, + phone, + url, + logo, + name, + highlight, + jobActions: { handleForm } + } = this.props; + const { handleChange } = this; + const labelClass = 'col-sm-offset-1 col-sm-2'; + const inputClass = 'col-sm-6'; + + return ( +
+ + + +

Create Your Job Post

+
+ +
+

Job Information

+
+ handleChange('position', e) } + placeholder='Position' + type='text' + value={ position.value } + wrapperClassName={ inputClass } /> + handleChange('locale', e) } + placeholder='Location' + type='text' + value={ locale.value } + wrapperClassName={ inputClass } /> + handleChange('description', e) } + placeholder='Description' + rows='10' + type='textarea' + value={ description.value } + wrapperClassName={ inputClass } /> + +
+

Company Information

+
+ handleChange('name', e) } + placeholder='Foo, INC' + type='text' + value={ name.value } + wrapperClassName={ inputClass } /> + handleChange('email', e) } + placeholder='Email' + type='email' + value={ email.value } + wrapperClassName={ inputClass } /> + handleChange('phone', e) } + placeholder='555-123-1234' + type='tel' + value={ phone.value } + wrapperClassName={ inputClass } /> + handleChange('url', e) } + placeholder='http://freecatphotoapp.com' + type='url' + value={ url.value } + wrapperClassName={ inputClass } /> + handleChange('logo', e) } + placeholder='http://freecatphotoapp.com/logo.png' + type='url' + value={ logo.value } + wrapperClassName={ inputClass } /> + +
+

Make it stand out

+
+ handleForm({ + highlight: !!checked + }) + } + type='checkbox' /> +
+ + + + + + + + + +
+ ); + } + }) +); diff --git a/common/app/routes/Jobs/components/Preview.jsx b/common/app/routes/Jobs/components/Preview.jsx new file mode 100644 index 0000000000..5b6081be5c --- /dev/null +++ b/common/app/routes/Jobs/components/Preview.jsx @@ -0,0 +1,14 @@ +// import React, { PropTypes } from 'react'; +import { contain } from 'thundercats-react'; +import ShowJob from './ShowJob.jsx'; + +export default contain( + { + store: 'JobsStore', + actions: 'JobActions', + map({ form: job = {} }) { + return { job }; + } + }, + ShowJob +); diff --git a/common/app/routes/Jobs/components/Show.jsx b/common/app/routes/Jobs/components/Show.jsx index 0baedb82b3..ce2512c27d 100644 --- a/common/app/routes/Jobs/components/Show.jsx +++ b/common/app/routes/Jobs/components/Show.jsx @@ -1,13 +1,5 @@ -import React, { PropTypes } from 'react'; import { contain } from 'thundercats-react'; -import { Row, Thumbnail, Panel, Well } from 'react-bootstrap'; -import moment from 'moment'; - -const thumbnailStyle = { - backgroundColor: 'white', - maxHeight: '100px', - maxWidth: '100px' -}; +import ShowJob from './ShowJob.jsx'; export default contain( { @@ -28,61 +20,5 @@ export default contain( return job.id !== id; } }, - React.createClass({ - displayName: 'ShowJob', - propTypes: { - job: PropTypes.object, - params: PropTypes.object - }, - - renderHeader({ company, position }) { - return ( -
-

{ company }

-
- { position } -
-
- ); - }, - - render() { - const { job = {} } = this.props; - const { - logo, - position, - city, - company, - state, - email, - phone, - postedOn, - description - } = job; - - return ( -
- - - - - Position: { position } - Location: { city }, { state } -
- Contact: { email || phone || 'N/A' } -
- Posted On: { moment(postedOn).format('MMMM Do, YYYY') } -
-

{ description }

-
-
-
- ); - } - }) + ShowJob ); diff --git a/common/app/routes/Jobs/components/ShowJob.jsx b/common/app/routes/Jobs/components/ShowJob.jsx new file mode 100644 index 0000000000..1a048a3fff --- /dev/null +++ b/common/app/routes/Jobs/components/ShowJob.jsx @@ -0,0 +1,67 @@ +import React, { PropTypes } from 'react'; +import { Row, Thumbnail, Panel, Well } from 'react-bootstrap'; +import moment from 'moment'; + +const thumbnailStyle = { + backgroundColor: 'white', + maxHeight: '100px', + maxWidth: '100px' +}; + +export default React.createClass({ + displayName: 'ShowJob', + propTypes: { + job: PropTypes.object, + params: PropTypes.object + }, + + renderHeader({ company, position }) { + return ( +
+

{ company }

+
+ { position } +
+
+ ); + }, + + render() { + const { job = {} } = this.props; + const { + logo, + position, + city, + company, + state, + email, + phone, + postedOn, + description + } = job; + + return ( +
+ + + + + Position: { position } + Location: { city }, { state } +
+ Contact: { email || phone || 'N/A' } +
+ Posted On: { moment(postedOn).format('MMMM Do, YYYY') } +
+

{ description }

+
+
+
+ ); + } +}); diff --git a/common/app/routes/Jobs/flux/Actions.js b/common/app/routes/Jobs/flux/Actions.js index 4df2ae42c6..5900e6dda1 100644 --- a/common/app/routes/Jobs/flux/Actions.js +++ b/common/app/routes/Jobs/flux/Actions.js @@ -1,7 +1,9 @@ import { Actions } from 'thundercats'; +import store from 'store'; import debugFactory from 'debug'; const debug = debugFactory('freecc:jobs:actions'); +const assign = Object.assign; export default Actions({ setJobs: null, @@ -23,7 +25,7 @@ export default Actions({ // if no job found this will be null which is a op noop return foundJob ? - Object.assign({}, oldState, { currentJob: foundJob }) : + assign({}, oldState, { currentJob: foundJob }) : null; }; }, @@ -31,6 +33,31 @@ export default Actions({ getJob: null, getJobs(params) { return { params }; + }, + openModal() { + return { showModal: true }; + }, + closeModal() { + return { showModal: false }; + }, + handleForm(value) { + return { + transform(oldState) { + const { form } = oldState; + const newState = assign({}, oldState); + newState.form = assign( + {}, + form, + value + ); + return newState; + } + }; + }, + saveForm: null, + getSavedForm: null, + setForm(form) { + return { form }; } }) .refs({ displayName: 'JobActions' }) @@ -56,8 +83,22 @@ export default Actions({ debug('job services experienced an issue', err); return jobActions.setError({ err }); } - jobActions.setJobs({ currentJob: job }); + if (job) { + jobActions.setJobs({ currentJob: job }); + } + jobActions.setJobs({}); }); }); + + jobActions.saveForm.subscribe((form) => { + store.set('newJob', form); + }); + + jobActions.getSavedForm.subscribe(() => { + const job = store.get('newJob'); + if (job && !Array.isArray(job) && typeof job === 'object') { + jobActions.setForm(job); + } + }); return jobActions; }); diff --git a/common/app/routes/Jobs/flux/Store.js b/common/app/routes/Jobs/flux/Store.js index 2fdfa50207..b2f5132013 100644 --- a/common/app/routes/Jobs/flux/Store.js +++ b/common/app/routes/Jobs/flux/Store.js @@ -6,12 +6,25 @@ const { transformer } = Store; -export default Store() +export default Store({ showModal: false }) .refs({ displayName: 'JobsStore' }) .init(({ instance: jobsStore, args: [cat] }) => { - const { setJobs, findJob, setError } = cat.getActions('JobActions'); + const { + setJobs, + findJob, + setError, + openModal, + closeModal, + handleForm, + setForm + } = cat.getActions('JobActions'); const register = createRegistrar(jobsStore); register(setter(setJobs)); - register(transformer(findJob)); register(setter(setError)); + register(setter(openModal)); + register(setter(closeModal)); + register(setter(setForm)); + + register(transformer(findJob)); + register(handleForm); }); diff --git a/common/app/routes/Jobs/index.js b/common/app/routes/Jobs/index.js index ac6b07f866..6c556c994e 100644 --- a/common/app/routes/Jobs/index.js +++ b/common/app/routes/Jobs/index.js @@ -1,5 +1,7 @@ import Jobs from './components/Jobs.jsx'; +import NewJob from './components/NewJob.jsx'; import Show from './components/Show.jsx'; +import Preview from './components/Preview.jsx'; /* * index: /jobs list jobs @@ -11,6 +13,12 @@ export default { childRoutes: [{ path: '/jobs', component: Jobs + }, { + path: 'jobs/new', + component: NewJob + }, { + path: 'jobs/new/preview', + component: Preview }, { path: 'jobs/:id', component: Show diff --git a/common/app/routes/Jobs/utils.js b/common/app/routes/Jobs/utils.js new file mode 100644 index 0000000000..aeb0396c12 --- /dev/null +++ b/common/app/routes/Jobs/utils.js @@ -0,0 +1,22 @@ +const defaults = { + 'string': { + value: '', + valid: false, + pristine: true, + type: 'string' + }, + bool: { + value: false, + type: 'boolean' + } +}; + +export function getDefaults(type, value) { + if (!type) { + return defaults['string']; + } + if (value) { + return Object.assign({}, defaults[type], { value }); + } + return Object.assign({}, defaults[type]); +} diff --git a/common/app/routes/index.js b/common/app/routes/index.js index 973e9f2bf0..1f14733f82 100644 --- a/common/app/routes/index.js +++ b/common/app/routes/index.js @@ -3,12 +3,8 @@ import Hikes from './Hikes'; export default { path: '/', - getChildRoutes(locationState, cb) { - setTimeout(() => { - cb(null, [ - Jobs, - Hikes - ]); - }, 0); - } + childRoutes: [ + Jobs, + Hikes + ] }; diff --git a/common/models/job.json b/common/models/job.json index a3392fee82..f77fc6defa 100644 --- a/common/models/job.json +++ b/common/models/job.json @@ -1,6 +1,7 @@ { "name": "job", "base": "PersistedModel", + "strict": true, "idInjection": true, "trackChanges": false, "properties": { @@ -29,6 +30,9 @@ "state": { "type": "string" }, + "url": { + "type": "string" + }, "country": { "type": "string" }, @@ -38,7 +42,7 @@ "description": { "type": "string" }, - "isApproverd": { + "isApproved": { "type": "boolean" }, "isHighlighted": { diff --git a/gulpfile.js b/gulpfile.js index afe9b421f5..fd5fdda38d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -49,6 +49,7 @@ var paths = { '!public/js/bundle*', 'node_modules/', 'client/', + 'seed', 'server/manifests/*.json', 'server/rev-manifest.json' ], diff --git a/package.json b/package.json index 901b54ea3a..f16e4bb9db 100644 --- a/package.json +++ b/package.json @@ -58,10 +58,10 @@ "gulp-webpack": "^1.5.0", "helmet": "~0.9.0", "helmet-csp": "^0.2.3", + "history": "^1.9.0", "jade": "~1.8.0", "json-loader": "^0.5.2", - "less": "~1.7.5", - "less-middleware": "~2.0.1", + "less": "~2.5.1", "lodash": "^3.9.3", "loopback": "https://github.com/FreeCodeCamp/loopback.git#fix/no-password", "loopback-boot": "2.8.2", @@ -89,7 +89,7 @@ "react": "^0.13.3", "react-bootstrap": "~0.23.7", "react-motion": "~0.1.0", - "react-router": "https://github.com/BerkeleyTrue/react-router#freecodecamp", + "react-router": "^1.0.0-rc1", "react-vimeo": "^0.0.3", "request": "~2.53.0", "rev-del": "^1.0.5", @@ -97,12 +97,14 @@ "sanitize-html": "~1.6.1", "sort-keys": "^1.1.1", "source-map-support": "^0.3.2", + "store": "https://github.com/berkeleytrue/store.js.git#feature/noop-server", "thundercats": "^2.1.0", "thundercats-react": "^0.1.0", "twit": "~1.1.20", "uglify-js": "~2.4.15", - "validator": "~3.22.1", + "validator": "^3.22.1", "webpack": "^1.9.12", + "xss-filters": "^1.2.6", "yui": "~3.18.1" }, "devDependencies": { diff --git a/server/boot/a-react.js b/server/boot/a-react.js index 6c0cd04819..9b1f4926c9 100644 --- a/server/boot/a-react.js +++ b/server/boot/a-react.js @@ -1,7 +1,7 @@ import React from 'react'; -import Router from 'react-router'; +import { RoutingContext } from 'react-router'; import Fetchr from 'fetchr'; -import Location from 'react-router/lib/Location'; +import { createLocation } from 'history'; import debugFactory from 'debug'; import { app$ } from '../../common/app'; import { RenderToString } from 'thundercats-react'; @@ -30,25 +30,25 @@ export default function reactSubRouter(app) { function serveReactApp(req, res, next) { const services = new Fetchr({ req }); - const location = new Location(req.path, req.query); + const location = createLocation(req.path); // returns a router wrapped app - app$(location) + app$({ location }) // if react-router does not find a route send down the chain - .filter(function({ initialState }) { - if (!initialState) { + .filter(function({ props}) { + if (!props) { debug('react tried to find %s but got 404', location.pathname); return next(); } - return !!initialState; + return !!props; }) - .flatMap(function({ initialState, AppCat }) { + .flatMap(function({ props, AppCat }) { // call thundercats renderToString // prefetches data and sets up it up for current state debug('rendering to string'); return RenderToString( AppCat(null, services), - React.createElement(Router, initialState) + React.createElement(RoutingContext, props) ); }) // makes sure we only get one onNext and closes subscription