Merge pull request #3525 from FreeCodeCamp/feature/jobs

intermediate pull
This commit is contained in:
Quincy Larson
2015-09-28 17:35:13 -07:00
18 changed files with 601 additions and 112 deletions

View File

@ -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
);
})

View File

@ -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 };
});
}

View File

@ -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 (
<Modal
onHide={ onHide }
show={ showModal }>
<Modal.Body>
<h4>Welcome to Free Code Camp's board</h4>
<p>We post jobs specifically target to our junior developers.</p>
<Button
block={ true }
className='signup-btn'
onClick={ () => this.goToNewJob(onHide) }>
Post a Job
</Button>
</Modal.Body>
</Modal>
);
}
});

View File

@ -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 (
<div>
@ -62,7 +71,8 @@ export default contain(
</p>
<Button
bsSize='large'
className='signup-btn'>
className='signup-btn'
onClick={ jobActions.openModal }>
Try the first month 20% off!
</Button>
</Jumbotron>
@ -70,7 +80,10 @@ export default contain(
<Row>
{ this.renderChild(children, jobs) ||
this.renderList(this.handleJobClick, jobs) }
</Row>
</Row>
<CreateJobModal
onHide={ jobActions.closeModal }
showModal={ showModal } />
</div>
);
}

View File

@ -22,6 +22,7 @@ export default React.createClass({
id,
company,
position,
isHighlighted,
description,
logo,
city,
@ -44,6 +45,7 @@ export default React.createClass({
);
return (
<Panel
bsStyle={ isHighlighted ? 'warning' : 'default' }
collapsible={ true }
eventKey={ index }
header={ header }

View File

@ -0,0 +1,319 @@
import React, { PropTypes } from 'react';
import { History } from 'react-router';
import { contain } from 'thundercats-react';
import debugFactory from 'debug';
import { getDefaults } from '../utils';
import {
inHTMLData,
uriInSingleQuotedAttr
} from 'xss-filters';
import {
Button,
Col,
Input,
Row,
Well
} from 'react-bootstrap';
import {
isAscii,
isEmail,
isMobilePhone,
isURL
} from 'validator';
const debug = debugFactory('freecc:jobs:newForm');
const checkValidity = [
'position',
'locale',
'description',
'email',
'phone',
'url',
'logo',
'name',
'highlight'
];
function formatValue(value, validator, type = 'string') {
const formated = getDefaults(type);
if (validator && type === 'string') {
formated.valid = validator(value);
}
if (value) {
formated.value = value;
formated.bsStyle = formated.valid ? 'success' : 'error';
}
return formated;
}
function isValidURL(data) {
return isURL(data, { 'require_protocol': true });
}
function isValidPhone(data) {
return isMobilePhone(data, 'en-US');
}
export default contain({
actions: 'jobActions',
store: 'jobsStore',
map({ form = {} }) {
const {
position,
locale,
description,
email,
phone,
url,
logo,
name,
highlight
} = form;
return {
position: formatValue(position, isAscii),
locale: formatValue(locale, isAscii),
description: formatValue(description, isAscii),
email: formatValue(email, isEmail),
phone: formatValue(phone, isValidPhone),
url: formatValue(url, isValidURL),
logo: formatValue(logo, isValidURL),
name: formatValue(name, isAscii),
highlight: formatValue(highlight, null, 'bool')
};
},
subscribeOnWillMount() {
return typeof window !== 'undefined';
}
},
React.createClass({
displayName: 'NewJob',
propTypes: {
jobActions: PropTypes.object,
position: PropTypes.object,
locale: PropTypes.object,
description: PropTypes.object,
email: PropTypes.object,
phone: PropTypes.object,
url: PropTypes.object,
logo: PropTypes.object,
name: PropTypes.object,
highlight: PropTypes.object
},
mixins: [History],
handleSubmit(e) {
e.preventDefault();
const props = this.props;
let valid = true;
checkValidity.forEach((prop) => {
// 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 (
<div>
<Row>
<Col>
<Well className='text-center'>
<h1>Create Your Job Post</h1>
<form
className='form-horizontal'
onSubmit={ this.handleSubmit }>
<div className='spacer'>
<h2>Job Information</h2>
</div>
<Input
bsStyle={ position.bsStyle }
label='Position'
labelClassName={ labelClass }
onChange={ (e) => handleChange('position', e) }
placeholder='Position'
type='text'
value={ position.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ locale.bsStyle }
label='Location'
labelClassName={ labelClass }
onChange={ (e) => handleChange('locale', e) }
placeholder='Location'
type='text'
value={ locale.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ description.bsStyle }
label='Description'
labelClassName={ labelClass }
onChange={ (e) => handleChange('description', e) }
placeholder='Description'
rows='10'
type='textarea'
value={ description.value }
wrapperClassName={ inputClass } />
<div className='divider'>
<h2>Company Information</h2>
</div>
<Input
bsStyle={ name.bsStyle }
label='Company Name'
labelClassName={ labelClass }
onChange={ (e) => handleChange('name', e) }
placeholder='Foo, INC'
type='text'
value={ name.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ email.bsStyle }
label='Email'
labelClassName={ labelClass }
onChange={ (e) => handleChange('email', e) }
placeholder='Email'
type='email'
value={ email.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ phone.bsStyle }
label='Phone'
labelClassName={ labelClass }
onChange={ (e) => handleChange('phone', e) }
placeholder='555-123-1234'
type='tel'
value={ phone.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ url.bsStyle }
label='URL'
labelClassName={ labelClass }
onChange={ (e) => handleChange('url', e) }
placeholder='http://freecatphotoapp.com'
type='url'
value={ url.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ logo.bsStyle }
label='Logo'
labelClassName={ labelClass }
onChange={ (e) => handleChange('logo', e) }
placeholder='http://freecatphotoapp.com/logo.png'
type='url'
value={ logo.value }
wrapperClassName={ inputClass } />
<div className='divider'>
<h2>Make it stand out</h2>
</div>
<Input
checked={ highlight.value }
label='Highlight your ad'
labelClassName={ 'col-sm-offset-1 col-sm-6'}
onChange={
({ target: { checked } }) => handleForm({
highlight: !!checked
})
}
type='checkbox' />
<div className='spacer' />
<Row>
<Col
lg={ 6 }
lgOffset={ 3 }>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
type='submit'>
Preview My Ad
</Button>
</Col>
</Row>
</form>
</Well>
</Col>
</Row>
</div>
);
}
})
);

View File

@ -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
);

View File

@ -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 (
<div>
<h4 style={{ display: 'inline-block' }}>{ company }</h4>
<h5
className='pull-right hidden-xs hidden-md'
style={{ display: 'inline-block' }}>
{ position }
</h5>
</div>
);
},
render() {
const { job = {} } = this.props;
const {
logo,
position,
city,
company,
state,
email,
phone,
postedOn,
description
} = job;
return (
<div>
<Row>
<Well>
<Thumbnail
alt={ company + 'company logo' }
src={ logo }
style={ thumbnailStyle } />
<Panel>
Position: { position }
Location: { city }, { state }
<br />
Contact: { email || phone || 'N/A' }
<br />
Posted On: { moment(postedOn).format('MMMM Do, YYYY') }
</Panel>
<p>{ description }</p>
</Well>
</Row>
</div>
);
}
})
ShowJob
);

View File

@ -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 (
<div>
<h4 style={{ display: 'inline-block' }}>{ company }</h4>
<h5
className='pull-right hidden-xs hidden-md'
style={{ display: 'inline-block' }}>
{ position }
</h5>
</div>
);
},
render() {
const { job = {} } = this.props;
const {
logo,
position,
city,
company,
state,
email,
phone,
postedOn,
description
} = job;
return (
<div>
<Row>
<Well>
<Thumbnail
alt={ company + 'company logo' }
src={ logo }
style={ thumbnailStyle } />
<Panel>
Position: { position }
Location: { city }, { state }
<br />
Contact: { email || phone || 'N/A' }
<br />
Posted On: { moment(postedOn).format('MMMM Do, YYYY') }
</Panel>
<p>{ description }</p>
</Well>
</Row>
</div>
);
}
});

View File

@ -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;
});

View File

@ -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);
});

View File

@ -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

View File

@ -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]);
}

View File

@ -3,12 +3,8 @@ import Hikes from './Hikes';
export default {
path: '/',
getChildRoutes(locationState, cb) {
setTimeout(() => {
cb(null, [
Jobs,
Hikes
]);
}, 0);
}
childRoutes: [
Jobs,
Hikes
]
};

View File

@ -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": {

View File

@ -49,6 +49,7 @@ var paths = {
'!public/js/bundle*',
'node_modules/',
'client/',
'seed',
'server/manifests/*.json',
'server/rev-manifest.json'
],

View File

@ -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": {

View File

@ -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