diff --git a/.travis.yml b/.travis.yml index e2d6c293c..73b329d92 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,12 +5,10 @@ rvm: install: - bundle install - npm install - - cd client && NODE_ENV=production $(npm bin)/webpack --config webpack.rails.config.js - - NODE_ENV=production $(npm bin)/webpack --config webpack.server.config.js - + - cd client && npm run build:client + - npm run build:server env: - export RAILS_ENV=test - before_script: - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start diff --git a/Gemfile b/Gemfile index 9cb85a43f..3508b7fe7 100644 --- a/Gemfile +++ b/Gemfile @@ -39,7 +39,7 @@ gem "rails-html-sanitizer" # Use Unicorn as the app server gem "unicorn" -gem "react_on_rails", "~> 0.1.3" +gem "react_on_rails", "~> 0.1.6" gem "therubyracer" gem "autoprefixer-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 13d0ac4b7..13249a8ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -206,7 +206,7 @@ GEM raindrops (0.15.0) rake (10.4.2) rdoc (4.2.0) - react_on_rails (0.1.3) + react_on_rails (0.1.6) execjs (~> 2.5) rails (~> 4.2) ref (2.0.0) @@ -351,7 +351,7 @@ DEPENDENCIES rails-html-sanitizer rails_12factor rainbow - react_on_rails (~> 0.1.3) + react_on_rails (~> 0.1.6) rspec-rails rubocop ruby-lint diff --git a/Procfile.dev b/Procfile.dev index 03105c6e6..0d26caeb0 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ web: rails s -p 4000 -client: sh -c 'cd client && $(npm bin)/webpack -w --config webpack.rails.config.js' -server: sh -c 'cd client && $(npm bin)/webpack -w --config webpack.server.config.js' -hot: sh -c 'cd client && node server.js' +client: sh -c 'rm app/assets/javascripts/generated/* || true && cd client && npm run build:dev:client' +server: sh -c 'cd client && npm run build:dev:server' +hot: sh -c 'cd client && npm start' diff --git a/README.md b/README.md index 28ca1b4eb..bfe933a5d 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ the JS bundle. We've chosen to let Rails handle CSS, SCSS, images, fonts. ``` cd client -$(npm bin)/webpack -w --config webpack.rails.config.js +npm run build:dev ``` `client-bundle.js` is generated and saved to `app/assets/javascripts`. This is included in the @@ -182,7 +182,7 @@ jQuery and jQuery-ujs are not required within `app/assets/javascript/application and have been moved under`/client` and managed by npm. The modules are exposed via entry point by `webpack.common.config.js`. -In `application.js`, it's critical that any libraries that depend on jQuery come after the inclusion +In `application.js`, it's critical that any libraries that depend on jQuery come after the inclusion of the Webpack bundle, such as the twitter bootstrap javascript. Please refer to [Considerations for jQuery with Rails and Webpack](http://forum.railsonmaui.com/t/considerations-for-jquery-with-rails-and-webpack/344) for further info. @@ -260,7 +260,7 @@ Run the tests with `rspec`. ### RubyMine/Webstorm Linting Configuration * I started out trying to make RubyMine and WebStorm catch and fix linting errors. However, I find it faster to just do this with the command line. Your mileage may vary. - * Create a custom scope like this for RubyMine, named "Inspection Scope" + * Create a custom scope like this for RubyMine, named "Inspection Scope" file[react-rails-tutorial]:*/&&!file[react-rails-tutorial]:tmp//*&&!file[react-rails-tutorial]:log//*&&!file[react-rails-tutorial]:client/node_modules//*&&!file[react-rails-tutorial]:client/assets/fonts//*&&!file[react-rails-tutorial]:app/assets/fonts//*&&!file[react-rails-tutorial]:bin//*&&!file[react-rails-tutorial]:app/assets/javascripts//* @@ -289,7 +289,7 @@ WebStorm opened up to the `client` directory to focus on JSX and Sass files. # Misc Tips -## Cleanup local branches merged to master +## Cleanup local branches merged to master ``` alias git-cleanup-merged-branches='git branch --merged master | grep -v master | xargs git branch -d' ``` diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 34e53597a..ecd6646fe 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -13,7 +13,9 @@ // Need to be on top to allow Poltergeist test to work with React. //= require es5-shim/es5-shim -// It is important that generated/client-bundle must be before bootstrap since it is exposing jQuery and jQuery-ujs -//= require generated/client-bundle +// It is important that generated/vendor-bundle must be before bootstrap since it is exposing jQuery and jQuery-ujs +//= require generated/vendor-bundle +//= require generated/app-bundle + //= require bootstrap-sprockets //= require turbolinks diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 39848c624..19dc5fef8 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,4 +1,5 @@ class PagesController < ApplicationController def index + @comments = Comment.all end end diff --git a/app/views/pages/index.html.erb b/app/views/pages/index.html.erb index 9c514b3c9..4bffec631 100644 --- a/app/views/pages/index.html.erb +++ b/app/views/pages/index.html.erb @@ -16,4 +16,4 @@
-<%= react_component('App', {}, generator_function: true, prerender: true) %> +<%= react_component('App', @comments, generator_function: true, prerender: true) %> diff --git a/client/.eslintrc b/client/.eslintrc index 659178c0f..e9fe0cf1f 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -13,4 +13,4 @@ env: rules: indent: [1, 2, { SwitchCase: 1, VariableDeclarator: 2 }] react/sort-comp: 0 - react/jsx-quotes: 0 + react/jsx-quotes: 1 diff --git a/client/.jscsrc b/client/.jscsrc index 0be7eea16..c97c7f763 100644 --- a/client/.jscsrc +++ b/client/.jscsrc @@ -1,5 +1,7 @@ { "preset": "airbnb", "fileExtensions": [".js", ".jsx"], - "excludeFiles": ["build/**", "node_modules/**"] + "excludeFiles": ["build/**", "node_modules/**"], + + "validateQuoteMarks": null // Issue with JSX quotemarks: https://github.com/jscs-dev/babel-jscs/issues/12 } diff --git a/client/assets/javascripts/actions/CommentActionCreators.js b/client/app/actions/commentsActionCreators.js similarity index 77% rename from client/assets/javascripts/actions/CommentActionCreators.js rename to client/app/actions/commentsActionCreators.js index 5808f2b5c..58c7e4a6f 100644 --- a/client/assets/javascripts/actions/CommentActionCreators.js +++ b/client/app/actions/commentsActionCreators.js @@ -1,5 +1,5 @@ -import CommentsManager from '../utils/CommentsManager'; -import * as actionTypes from '../constants/ActionTypes'; +import commentsManager from '../utils/commentsManager'; +import * as actionTypes from '../constants/commentsConstants'; export function incrementAjaxCounter() { return { @@ -44,25 +44,23 @@ export function submitCommentFailure(error) { export function fetchComments() { return dispatch => { return ( - CommentsManager.fetchComments() + commentsManager + .fetchComments() .then(res => dispatch(fetchCommentsSuccess(res.data))) .catch(res => dispatch(fetchCommentsFailure(res.data))) ); }; } -function dispatchDecrementAjaxCounter(dispatch) { - return dispatch(decrementAjaxCounter()); -} - export function submitComment(comment) { return dispatch => { dispatch(incrementAjaxCounter()); return ( - CommentsManager.submitComment(comment) + commentsManager + .submitComment(comment) .then(res => dispatch(submitCommentSuccess(res.data))) .catch(res => dispatch(submitCommentFailure(res.data))) - .then(() => dispatchDecrementAjaxCounter(dispatch)) + .then(() => dispatch(decrementAjaxCounter())) ); }; } diff --git a/client/app/components/Comment.jsx b/client/app/components/Comment.jsx new file mode 100644 index 000000000..e3b40d1f7 --- /dev/null +++ b/client/app/components/Comment.jsx @@ -0,0 +1,26 @@ +import React, { PropTypes } from 'react'; +import marked from 'marked'; + +const Comment = React.createClass({ + displayName: 'Comment', + + propTypes: { + author: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + }, + + render() { + const { author, text } = this.props; + const rawMarkup = marked(text, { gfm: true, sanitize: true }); + return ( +
+

+ {author} +

+ +
+ ); + }, +}); + +export default Comment; diff --git a/client/assets/javascripts/components/CommentBox.jsx b/client/app/components/CommentBox.jsx similarity index 60% rename from client/assets/javascripts/components/CommentBox.jsx rename to client/app/components/CommentBox.jsx index 33870dc87..4751708ef 100644 --- a/client/assets/javascripts/components/CommentBox.jsx +++ b/client/app/components/CommentBox.jsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { PropTypes } from 'react'; + import CommentForm from './CommentForm'; import CommentList from './CommentList'; @@ -6,16 +7,15 @@ const CommentBox = React.createClass({ displayName: 'CommentBox', propTypes: { - pollInterval: React.PropTypes.number.isRequired, - actions: React.PropTypes.object.isRequired, - data: React.PropTypes.object.isRequired, + pollInterval: PropTypes.number.isRequired, + actions: PropTypes.object.isRequired, + data: PropTypes.object.isRequired, }, componentDidMount() { const { fetchComments } = this.props.actions; fetchComments(); - setInterval(fetchComments, - this.props.pollInterval); + setInterval(fetchComments, this.props.pollInterval); }, ajaxCounter() { @@ -30,20 +30,23 @@ const CommentBox = React.createClass({ const { actions, data } = this.props; return ( -
+

- Comments { this.isSendingAjax() ? `SENDING AJAX REQUEST! Ajax Counter is ${this.ajaxCounter()}` : '' } + Comments { this.isSendingAjax() && `SENDING AJAX REQUEST! Ajax Counter is ${this.ajaxCounter()}` }

Text take Github Flavored Markdown. Comments older than 24 hours are deleted. - Name is preserved, Text is reset, between submits.

+ Name is preserved, Text is reset, between submits. +

+ actions={actions} + /> + $$comments={data.get('$$comments')} + error={data.get('fetchCommentError')} + />
); }, diff --git a/client/app/components/CommentForm.jsx b/client/app/components/CommentForm.jsx new file mode 100644 index 000000000..c9b3c5dff --- /dev/null +++ b/client/app/components/CommentForm.jsx @@ -0,0 +1,244 @@ +import React, { PropTypes } from 'react'; +import Input from 'react-bootstrap/lib/Input'; +import Row from 'react-bootstrap/lib/Row'; +import Col from 'react-bootstrap/lib/Col'; +import Nav from 'react-bootstrap/lib/Nav'; +import NavItem from 'react-bootstrap/lib/NavItem'; +import Alert from 'react-bootstrap/lib/Alert'; +import ReactCSSTransitionGroup from 'react/lib/ReactCSSTransitionGroup'; + +const emptyComment = { author: '', text: '' }; +const textPlaceholder = 'Say something using markdown...'; + +const CommentForm = React.createClass({ + displayName: 'CommentForm', + + propTypes: { + ajaxSending: PropTypes.bool.isRequired, + actions: PropTypes.object.isRequired, + error: PropTypes.any, + }, + + getInitialState() { + return { + formMode: 0, + comment: emptyComment, + }; + }, + + handleSelect(selectedKey) { + this.setState({ formMode: selectedKey }); + }, + + handleChange() { + let comment; + + // This could also be done using ReactLink: + // http://facebook.github.io/react/docs/two-way-binding-helpers.html + if (this.state.formMode < 2) { + comment = { + author: this.refs.author.getValue(), + text: this.refs.text.getValue(), + }; + } else { + comment = { + // This is different because the input is a native HTML element + // rather than a React element. + author: this.refs.inlineAuthor.getDOMNode().value, + text: this.refs.inlineText.getDOMNode().value, + }; + } + + this.setState({ comment }); + }, + + handleSubmit(e) { + e.preventDefault(); + const { actions } = this.props; + actions + .submitComment(this.state.comment) + .then(this.resetAndFocus); + }, + + resetAndFocus() { + // Don't reset a form that didn't submit, this results in data loss + if (this.props.error) return; + + const comment = { author: this.state.comment.author, text: '' }; + this.setState({ comment }); + + let ref; + if (this.state.formMode < 2) { + ref = this.refs.text.getInputDOMNode(); + } else { + ref = React.findDOMNode(this.refs.inlineText); + } + + ref.focus(); + }, + + formHorizontal() { + return ( +
+
+
+ + +
+
+ +
+
+
+
+ ); + }, + + formStacked() { + return ( +
+
+
+ + + +
+
+ ); + }, + + formInline() { + return ( +
+
+
+ + + + + + + + + + + + + +
+
+ ); + }, + + errorWarning() { + // If there is no error, there is nothing to add to the DOM + if (!this.props.error) return undefined; + return ( + + Your comment was not saved! + A server error prevented your comment from being saved. Please try again. + + ); + }, + + render() { + let inputForm; + switch (this.state.formMode) { + case 0: + inputForm = this.formHorizontal(); + break; + case 1: + inputForm = this.formStacked(); + break; + case 2: + inputForm = this.formInline(); + break; + default: + throw new Error(`Unknown form mode: ${this.state.formMode}.`); + } + return ( +
+ + + {this.errorWarning()} + + + + {inputForm} +
+ ); + }, +}); + +export default CommentForm; diff --git a/client/assets/javascripts/components/CommentList.jsx b/client/app/components/CommentList.jsx similarity index 55% rename from client/assets/javascripts/components/CommentList.jsx rename to client/app/components/CommentList.jsx index 48a5a92ac..0f95a4e45 100644 --- a/client/assets/javascripts/components/CommentList.jsx +++ b/client/app/components/CommentList.jsx @@ -1,42 +1,50 @@ -import React from 'react'; -import Comment from './Comment'; +import React, { PropTypes } from 'react'; +import Immutable from 'immutable'; import Alert from 'react-bootstrap/lib/Alert'; import ReactCSSTransitionGroup from 'react/lib/ReactCSSTransitionGroup'; +import Comment from './Comment'; + const CommentList = React.createClass({ displayName: 'CommentList', propTypes: { - comments: React.PropTypes.object, - error: React.PropTypes.any.isRequired, + $$comments: PropTypes.instanceOf(Immutable.List).isRequired, + error: PropTypes.any, }, errorWarning() { // If there is no error, there is nothing to add to the DOM if (!this.props.error) return undefined; return ( - - Comments could not be retrieved. A server error prevented loading comments. Please try again. + + Comments could not be retrieved. + A server error prevented loading comments. Please try again. ); }, render() { - const commentNodes = this.props.comments.reverse().map((comment, index) => { + const { $$comments } = this.props; + const commentNodes = $$comments.reverse().map(($$comment, index) => { // `key` is a React-specific concept and is not mandatory for the // purpose of this tutorial. if you're curious, see more here: // http://facebook.github.io/react/docs/multiple-components.html#dynamic-children return ( - + ); }); return (
- + {this.errorWarning()} -
+
{commentNodes}
diff --git a/client/app/components/CommentScreen.jsx b/client/app/components/CommentScreen.jsx new file mode 100644 index 000000000..40439789e --- /dev/null +++ b/client/app/components/CommentScreen.jsx @@ -0,0 +1,49 @@ +import React, { PropTypes } from 'react'; +import CommentBox from './CommentBox'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import * as commentsActionCreators from '../actions/commentsActionCreators'; + +function select(state) { + // Which part of the Redux global state does our component want to receive as props? + return { data: state.$$commentsStore }; +} + +const CommentScreen = React.createClass({ + displayName: 'CommentScreen', + + propTypes: { + dispatch: PropTypes.func.isRequired, + data: PropTypes.object.isRequired, + }, + + render() { + const { dispatch, data } = this.props; + const actions = bindActionCreators(commentsActionCreators, dispatch); + return ( +
+ + + ); + }, +}); + +// Don't forget to actually use connect! +export default connect(select)(CommentScreen); diff --git a/client/assets/javascripts/constants/ActionTypes.js b/client/app/constants/commentsConstants.js similarity index 100% rename from client/assets/javascripts/constants/ActionTypes.js rename to client/app/constants/commentsConstants.js diff --git a/client/assets/javascripts/middleware/loggerMiddleware.js b/client/app/middlewares/loggerMiddleware.js similarity index 100% rename from client/assets/javascripts/middleware/loggerMiddleware.js rename to client/app/middlewares/loggerMiddleware.js diff --git a/client/app/reducers/commentsReducer.js b/client/app/reducers/commentsReducer.js new file mode 100644 index 000000000..5ed231b1f --- /dev/null +++ b/client/app/reducers/commentsReducer.js @@ -0,0 +1,65 @@ +/* eslint new-cap: 0 */ + +import Immutable from 'immutable'; + +import * as actionTypes from '../constants/commentsConstants'; + +export const $$initialState = Immutable.fromJS({ + $$comments: [], + ajaxCounter: 0, + fetchCommentError: null, + submitCommentError: null, +}); + +export default function commentsReducer($$state = $$initialState, action) { + const { type, comment, comments, error } = action; + + switch (type) { + + case actionTypes.FETCH_COMMENTS_SUCCESS: { + return $$state.merge({ + $$comments: comments, + fetchCommentError: null, + }); + } + + case actionTypes.FETCH_COMMENTS_FAILURE: { + return $$state.merge({ + fetchCommentError: error, + }); + } + + case actionTypes.SUBMIT_COMMENT_SUCCESS: { + return $$state.withMutations(state => ( + state + .updateIn( + ['$$comments'], + $$comments => $$comments.push(Immutable.fromJS(comment)) + ) + .merge({ submitCommentError: null }) + )); + } + + case actionTypes.SUBMIT_COMMENT_FAILURE: { + return $$state.merge({ + submitCommentError: error, + }); + } + + case actionTypes.INCREMENT_AJAX_COUNTER: { + return $$state.merge({ + ajaxCounter: $$state.get('ajaxCounter') + 1, + }); + } + + case actionTypes.DECREMENT_AJAX_COUNTER: { + return $$state.merge({ + ajaxCounter: $$state.get('ajaxCounter') - 1, + }); + } + + default: { + return $$state; + } + } +} diff --git a/client/app/reducers/index.js b/client/app/reducers/index.js new file mode 100644 index 000000000..028795b53 --- /dev/null +++ b/client/app/reducers/index.js @@ -0,0 +1,10 @@ +import commentsReducer from './commentsReducer'; +import { $$initialState as $$commentsState } from './commentsReducer'; + +export default { + $$commentsStore: commentsReducer, +}; + +export const initalStates = { + $$commentsState, +}; diff --git a/client/assets/javascripts/ClientApp.jsx b/client/app/startup/ClientApp.jsx similarity index 55% rename from client/assets/javascripts/ClientApp.jsx rename to client/app/startup/ClientApp.jsx index fe28bb08c..dbdc2f932 100644 --- a/client/assets/javascripts/ClientApp.jsx +++ b/client/app/startup/ClientApp.jsx @@ -1,12 +1,13 @@ import React from 'react'; import { Provider } from 'react-redux'; -import CommentScreen from './components/CommentScreen'; -import CommentStore from './stores/CommentStore'; +import createStore from '../stores/commentsStore'; +import CommentScreen from '../components/CommentScreen'; -const App = () => { +const App = props => { + const store = createStore(props); const reactComponent = ( - + {() => } ); diff --git a/client/app/startup/ServerApp.jsx b/client/app/startup/ServerApp.jsx new file mode 100644 index 000000000..698277229 --- /dev/null +++ b/client/app/startup/ServerApp.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Provider } from 'react-redux'; + +import createStore from '../stores/commentsStore'; +import CommentScreen from '../components/CommentScreen'; + +const App = props => { + const store = createStore(props); + const reactComponent = ( + + {() => } + + ); + return reactComponent; +}; + +export default App; diff --git a/client/assets/javascripts/clientGlobals.jsx b/client/app/startup/clientGlobals.jsx similarity index 100% rename from client/assets/javascripts/clientGlobals.jsx rename to client/app/startup/clientGlobals.jsx diff --git a/client/assets/javascripts/serverGlobals.jsx b/client/app/startup/serverGlobals.jsx similarity index 100% rename from client/assets/javascripts/serverGlobals.jsx rename to client/app/startup/serverGlobals.jsx diff --git a/client/app/stores/commentsStore.js b/client/app/stores/commentsStore.js new file mode 100644 index 000000000..0622dce70 --- /dev/null +++ b/client/app/stores/commentsStore.js @@ -0,0 +1,24 @@ +import { compose, createStore, applyMiddleware, combineReducers } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import loggerMiddleware from '../middlewares/loggerMiddleware'; +import reducers from '../reducers'; +import { initalStates } from '../reducers'; + +export default props => { + const initialComments = props; + const { $$commentsState } = initalStates; + const initialState = { + $$commentsStore: $$commentsState.merge({ + $$comments: initialComments, + }), + }; + + const reducer = combineReducers(reducers); + const composedStore = compose( + applyMiddleware(thunkMiddleware, loggerMiddleware) + ); + const storeCreator = composedStore(createStore); + const store = storeCreator(reducer, initialState); + + return store; +}; diff --git a/client/assets/javascripts/utils/CommentsManager.js b/client/app/utils/commentsManager.js similarity index 90% rename from client/assets/javascripts/utils/CommentsManager.js rename to client/app/utils/commentsManager.js index c501968f8..6698ace2e 100644 --- a/client/assets/javascripts/utils/CommentsManager.js +++ b/client/app/utils/commentsManager.js @@ -7,7 +7,7 @@ const CommentsManager = { /** * Retrieve comments from server using AJAX call. * - * @returns {Promise} - jqXHR result of ajax call. + * @returns {Promise} - result of ajax call. */ fetchComments() { return request({ @@ -21,7 +21,7 @@ const CommentsManager = { * Submit new comment to server using AJAX call. * * @param {Object} comment - Comment body to post. - * @returns {Promise} - jqXHR result of ajax call. + * @returns {Promise} - result of ajax call. */ submitComment(comment) { return request({ diff --git a/client/assets/javascripts/ServerApp.jsx b/client/assets/javascripts/ServerApp.jsx deleted file mode 100755 index fe28bb08c..000000000 --- a/client/assets/javascripts/ServerApp.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; - -import CommentScreen from './components/CommentScreen'; -import CommentStore from './stores/CommentStore'; - -const App = () => { - const reactComponent = ( - - {() => } - - ); - return reactComponent; -}; - -// Export is needed for the hot reload server -export default App; diff --git a/client/assets/javascripts/components/Comment.jsx b/client/assets/javascripts/components/Comment.jsx deleted file mode 100644 index d6d5a4e97..000000000 --- a/client/assets/javascripts/components/Comment.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import marked from 'marked'; -import React from 'react'; - -const Comment = React.createClass({ - displayName: 'Comment', - - propTypes: { - author: React.PropTypes.string.isRequired, - text: React.PropTypes.string.isRequired, - }, - - render() { - const rawMarkup = marked(this.props.text, { gfm: true, sanitize: true }); - return ( -
-

- {this.props.author} -

- -
- ); - }, -}); - -export default Comment; diff --git a/client/assets/javascripts/components/CommentForm.jsx b/client/assets/javascripts/components/CommentForm.jsx deleted file mode 100644 index 1533415b0..000000000 --- a/client/assets/javascripts/components/CommentForm.jsx +++ /dev/null @@ -1,198 +0,0 @@ -import React from 'react/addons'; -import Input from 'react-bootstrap/lib/Input'; -import Row from 'react-bootstrap/lib/Row'; -import Col from 'react-bootstrap/lib/Col'; -import Nav from 'react-bootstrap/lib/Nav'; -import NavItem from 'react-bootstrap/lib/NavItem'; -import Alert from 'react-bootstrap/lib/Alert'; -import ReactCSSTransitionGroup from 'react/lib/ReactCSSTransitionGroup'; - -const emptyComment = {author: '', text: ''}; -const textPlaceholder = 'Say something using markdown...'; - -const CommentForm = React.createClass({ - displayName: 'CommentForm', - - propTypes: { - ajaxSending: React.PropTypes.bool.isRequired, - actions: React.PropTypes.object.isRequired, - error: React.PropTypes.any.isRequired, - }, - - getInitialState() { - return { - formMode: 0, - comment: emptyComment, - }; - }, - - handleSelect(selectedKey) { - this.setState({formMode: selectedKey}); - }, - - handleChange() { - let comment; - - // This could also be done using ReactLink: - // http://facebook.github.io/react/docs/two-way-binding-helpers.html - if (this.state.formMode < 2) { - comment = { - author: this.refs.author.getValue(), - text: this.refs.text.getValue(), - }; - } else { - comment = { - // This is different because the input is a native HTML element - // rather than a React element. - author: this.refs.inlineAuthor.getDOMNode().value, - text: this.refs.inlineText.getDOMNode().value, - }; - } - - this.setState({comment}); - }, - - handleSubmit(e) { - e.preventDefault(); - this.props.actions.submitComment(this.state.comment).then( - () => this.resetAndFocus() - ); - }, - - resetAndFocus() { - // Don't reset a form that didn't submit, this results in data loss - if (this.props.error) return; - - const comment = {author: this.state.comment.author, text: ''}; - this.setState({comment}); - - let ref; - if (this.state.formMode < 2) { - ref = this.refs.text.getInputDOMNode(); - } else { - ref = React.findDOMNode(this.refs.inlineText); - } - - ref.focus(); - }, - - formHorizontal() { - return ( -
-
-
- - - -
-
-
-
-
-
- ); - }, - - formStacked() { - return ( -
-
-
- - - -
-
- ); - }, - - formInline() { - return ( -
-
-
- - - - - - - - - - - - - -
-
- ); - }, - - errorWarning() { - // If there is no error, there is nothing to add to the DOM - if (!this.props.error) return undefined; - return ( - - Your comment was not saved! A server error prevented your comment from being saved. Please try - again. - - ); - }, - - render() { - let inputForm; - switch (this.state.formMode) { - case 0: - inputForm = this.formHorizontal(); - break; - case 1: - inputForm = this.formStacked(); - break; - case 2: - inputForm = this.formInline(); - break; - default: - throw new Error(`Unknown form mode: ${this.state.formMode}.`); - } - return ( -
- - - {this.errorWarning()} - - - - {inputForm} -
- ); - }, -}); - -export default CommentForm; diff --git a/client/assets/javascripts/components/CommentScreen.jsx b/client/assets/javascripts/components/CommentScreen.jsx deleted file mode 100644 index 843c37a53..000000000 --- a/client/assets/javascripts/components/CommentScreen.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import CommentBox from './CommentBox'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { fetchComments, submitComment } from '../actions/CommentActionCreators'; - -const CommentActionsCreators = { fetchComments, submitComment }; - -function select(state) { - // Which part of the Redux global state does our component want to receive as props? - return { data: state.commentsData }; -} - -const CommentScreen = React.createClass({ - displayName: 'CommentScreen', - - propTypes: { - dispatch: React.PropTypes.func.isRequired, - data: React.PropTypes.object.isRequired, - }, - - render() { - const { dispatch, data } = this.props; - const actions = bindActionCreators(CommentActionsCreators, dispatch); - return ( -
- - - - ); - }, -}); - -// Don't forget to actually use connect! -export default connect(select)(CommentScreen); diff --git a/client/assets/javascripts/reducers/commentReducer.js b/client/assets/javascripts/reducers/commentReducer.js deleted file mode 100644 index 0d75247f0..000000000 --- a/client/assets/javascripts/reducers/commentReducer.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint new-cap: 0 */ - -import * as actionTypes from '../constants/ActionTypes'; - -import { Map, List } from 'immutable'; - -const initialState = Map({ - comments: List(), - ajaxCounter: 0, - fetchCommentError: '', - submitCommentError: '', -}); - -export default function commentsReducer(state = initialState, action) { - switch (action.type) { - case actionTypes.FETCH_COMMENTS_SUCCESS: - return state.merge({comments: action.comments, fetchCommentError: ''}); - case actionTypes.FETCH_COMMENTS_FAILURE: - return state.merge({fetchCommentError: action.error}); - case actionTypes.SUBMIT_COMMENT_SUCCESS: - return state.withMutations(mState => { - mState - .updateIn(['comments'], comments => comments.push(Map(action.comment))) - .merge({submitCommentError: ''}); - }); - - case actionTypes.SUBMIT_COMMENT_FAILURE: - return state.merge({submitCommentError: action.error}); - case actionTypes.INCREMENT_AJAX_COUNTER: - return state.merge({ajaxCounter: state.get('ajaxCounter') + 1}); - case actionTypes.DECREMENT_AJAX_COUNTER: - return state.merge({ajaxCounter: state.get('ajaxCounter') - 1}); - default: - return state; - } -} diff --git a/client/assets/javascripts/reducers/index.js b/client/assets/javascripts/reducers/index.js deleted file mode 100644 index 825f73ec6..000000000 --- a/client/assets/javascripts/reducers/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import commentsData from './commentReducer'; - -export default { commentsData }; diff --git a/client/assets/javascripts/stores/CommentStore.js b/client/assets/javascripts/stores/CommentStore.js deleted file mode 100644 index 052559350..000000000 --- a/client/assets/javascripts/stores/CommentStore.js +++ /dev/null @@ -1,8 +0,0 @@ -import { createStore, applyMiddleware, combineReducers } from 'redux'; -import thunk from 'redux-thunk'; -import reducers from '../reducers'; -import loggerMiddleware from '../middleware/loggerMiddleware'; - -// applyMiddleware supercharges createStore with middleware: -const createStoreWithMiddleware = applyMiddleware(thunk, loggerMiddleware)(createStore); -export default createStoreWithMiddleware(combineReducers(reducers)); diff --git a/client/bin/lint b/client/bin/lint deleted file mode 100755 index 284b920ed..000000000 --- a/client/bin/lint +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -echo ================================================================================ -echo Warnings are OPTIONAL -echo ================================================================================ - -npm run eslint . -- --ext .jsx,.js -npm run jscs . - -echo ================================================================================ -echo Warnings are OPTIONAL -echo ================================================================================ - diff --git a/client/gulpfile.js b/client/gulpfile.js deleted file mode 100644 index c1e0b2442..000000000 --- a/client/gulpfile.js +++ /dev/null @@ -1,13 +0,0 @@ -const gulp = require('gulp'); -const eslint = require('eslint/lib/cli'); - -// Note: To have the process exit with an error code (1) on -// lint error, return the stream and pipe to failOnError last. -gulp.task('lint', function gulpLint(done) { - eslint.execute('--ext .js,.jsx .'); - return done(); -}); - -gulp.task('default', ['lint'], function gulpDefault() { - // This will only run if the lint task is successful... -}); diff --git a/client/index.html b/client/index.html deleted file mode 100755 index c1906cd7e..000000000 --- a/client/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - Hello React - - -
- - -

AltStyle によって変換されたページ (->オリジナル) /

- diff --git a/client/index.jade b/client/index.jade new file mode 100644 index 000000000..859a03823 --- /dev/null +++ b/client/index.jade @@ -0,0 +1,14 @@ +doctype html +html + + head + title Hello, React + + body + + #app + + script(src="vendor-bundle.js") + script(src="app-bundle.js") + script. + React.render(App(!{props}), document.getElementById('app')); diff --git a/client/karma.conf.js b/client/karma.conf.js deleted file mode 100644 index 138c1d555..000000000 --- a/client/karma.conf.js +++ /dev/null @@ -1,43 +0,0 @@ -const webpack = require('webpack'); - -module.exports = function karmaMain(config) { - config.set({ - - browserNoActivityTimeout: 30000, - - browsers: [process.env.CONTINUOUS_INTEGRATION ? 'Firefox' : 'Chrome'], - - singleRun: process.env.CONTINUOUS_INTEGRATION === 'true', - - frameworks: ['mocha'], - - files: [ - 'tests.webpack.js', - ], - - preprocessors: { - 'tests.webpack.js': ['webpack', 'sourcemap'], - }, - - reporters: ['dots'], - - webpack: { - devtool: 'inline-source-map', - module: { - loaders: [ - {test: /\.js$/, loader: 'babel-loader'}, - ], - }, - plugins: [ - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('test'), - }), - ], - }, - - webpackServer: { - noInfo: true, - }, - - }); -}; diff --git a/client/package.json b/client/package.json index bd8e7a5e3..63b91a0e3 100644 --- a/client/package.json +++ b/client/package.json @@ -22,6 +22,17 @@ "url": "https://github.com/shakacode/react-webpack-rails-tutorial/issues" }, "homepage": "https://github.com/shakacode/react-webpack-rails-tutorial", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js", + "build:client": "NODE_ENV=production webpack --config webpack.client.rails.config.js", + "build:server": "NODE_ENV=production webpack --config webpack.server.rails.config.js", + "build:dev:client": "webpack -w --config webpack.client.rails.config.js", + "build:dev:server": "webpack -w --config webpack.server.rails.config.js", + "lint": "npm run eslint && npm run jscs", + "eslint": "eslint --ext .js,.jsx .", + "jscs": "jscs --verbose ." + }, "dependencies": { "axios": "^0.5.4", "babel-core": "^5.8.23", @@ -57,8 +68,7 @@ "expose-loader": "^0.7.0", "express": "^4.13.3", "file-loader": "^0.8.4", - "gulp": "^3.9.0", - "gulp-eslint": "^1.0.0", + "jade": "^1.11.0", "jscs": "^2.1.1", "node-sass": "^3.3.2", "react-hot-loader": "^1.3.0", @@ -66,12 +76,5 @@ "style-loader": "^0.12.3", "url-loader": "^0.5.6", "webpack-dev-server": "^1.10.1" - }, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "node server.js", - "gulp": "gulp", - "jscs": "jscs --verbose", - "eslint": "eslint" } } diff --git a/client/scripts/rails_only.jsx b/client/scripts/rails_only.jsx deleted file mode 100644 index e56bcd414..000000000 --- a/client/scripts/rails_only.jsx +++ /dev/null @@ -1,10 +0,0 @@ -// Only used by rails - -// Example of including es5 shims for supporting older browsers -// https://facebook.github.io/react/docs/working-with-the-browser.html -require('es5-shim/es5-shim'); -require('es5-shim/es5-sham'); - -// Due to issue: https://github.com/ariya/phantomjs/issues/12401 -// Phantomjs does not like promises -require('es6-promise').polyfill(); diff --git a/client/scripts/webpack_only.jsx b/client/scripts/webpack_only.jsx deleted file mode 100755 index a0fbe11d7..000000000 --- a/client/scripts/webpack_only.jsx +++ /dev/null @@ -1,8 +0,0 @@ -// These are only loaded by the webpack dev server - -require('test-stylesheet.css'); - -// Test out Sass. -// Note that any sass in here cannot use the variables and mixins -// defined in the boostrap customizations file. -require('test-sass-stylesheet.scss'); diff --git a/client/server.js b/client/server.js index b95d3e789..e4908f403 100644 --- a/client/server.js +++ b/client/server.js @@ -2,18 +2,19 @@ var bodyParser = require('body-parser'); var webpack = require('webpack'); var WebpackDevServer = require('webpack-dev-server'); -var config = require('./webpack.hot.config'); +var jade = require('jade'); var sleep = require('sleep'); +var config = require('./webpack.client.hot.config'); var comments = [ - {author: 'Pete Hunt', text: 'Hey there!'}, - {author: 'Justin Gordon', text: 'Aloha from @railsonmaui'}, + { author: 'Pete Hunt', text: 'Hey there!' }, + { author: 'Justin Gordon', text: 'Aloha from @railsonmaui' }, ]; var server = new WebpackDevServer(webpack(config), { publicPath: config.output.publicPath, hot: true, - noInfo: false, + historyApiFallback: true, stats: { colors: true, hash: false, @@ -41,10 +42,16 @@ server.app.post('/comments.json', function(req, res) { res.send(JSON.stringify(req.body.comment)); }); -server.listen(3000, 'localhost', function(err) { - if (err) { - console.log(err); - } +server.app.use('/', function(req, res) { + var locals = { + props: JSON.stringify(comments), + }; + var layout = process.cwd() + '/index.jade'; + var html = jade.compileFile(layout, { pretty: true })(locals); + res.send(html); +}); +server.listen(3000, 'localhost', function(err) { + if (err) console.log(err); console.log('Listening at localhost:3000...'); }); diff --git a/client/tests.webpack.js b/client/tests.webpack.js deleted file mode 100644 index 5e9765c01..000000000 --- a/client/tests.webpack.js +++ /dev/null @@ -1,2 +0,0 @@ -const context = require.context('./modules', true, /-test\.js$/); -context.keys().forEach(context); diff --git a/client/webpack.common.config.js b/client/webpack.client.base.config.js similarity index 50% rename from client/webpack.common.config.js rename to client/webpack.client.base.config.js index 4c59b907b..c664fa481 100644 --- a/client/webpack.common.config.js +++ b/client/webpack.client.base.config.js @@ -1,25 +1,36 @@ // Common webpack configuration used by webpack.hot.config and webpack.rails.config. -const path = require('path'); +const webpack = require('webpack'); module.exports = { // the project dir context: __dirname, - entry: [], - - resolve: { - root: [ - path.join(__dirname, 'scripts'), - path.join(__dirname, 'assets/javascripts'), + entry: { + vendor: [ + 'jquery', + 'jquery-ujs', ], + app: [], + }, + resolve: { extensions: ['', '.webpack.js', '.web.js', '.js', '.jsx', '.scss', '.css', 'config.js'], }, + plugins: [ + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + chunks: ['app'], + filename: 'vendor-bundle.js', + minChunks: Infinity, + }), + ], module: { loaders: [ // React is necessary for the client rendering: {test: require.resolve('react'), loader: 'expose?React'}, + {test: require.resolve('jquery'), loader: 'expose?jQuery'}, + {test: require.resolve('jquery'), loader: 'expose?$'}, ], }, }; diff --git a/client/webpack.hot.config.js b/client/webpack.client.hot.config.js similarity index 66% rename from client/webpack.hot.config.js rename to client/webpack.client.hot.config.js index 09007011c..4115e7d68 100644 --- a/client/webpack.hot.config.js +++ b/client/webpack.client.hot.config.js @@ -1,40 +1,39 @@ // Run like this: // cd client && node server.js -const path = require('path'); -const config = require('./webpack.common.config'); const webpack = require('webpack'); +const path = require('path'); +const config = require('./webpack.client.base.config'); // We're using the bootstrap-sass loader. // See: https://github.com/shakacode/bootstrap-sass-loader -config.entry.push('webpack-dev-server/client?http://localhost:3000', +config.entry.vendor.push('bootstrap-sass!./bootstrap-sass.config.js'); +config.entry.app.push( + + // Webpack dev server + 'webpack-dev-server/client?http://localhost:3000', 'webpack/hot/dev-server', - './scripts/webpack_only', - 'jquery', - 'jquery-ujs', - './assets/javascripts/clientGlobals', - // custom bootstrap - 'bootstrap-sass!./bootstrap-sass.config.js'); + // Test out Css & Sass + './assets/stylesheets/test-stylesheet.css', + './assets/stylesheets/test-sass-stylesheet.scss', + + // App entry point + './app/startup/clientGlobals' +); + config.output = { // this file is served directly by webpack - filename: 'express-bundle.js', + filename: '[name]-bundle.js', path: __dirname, }; -config.plugins = [new webpack.HotModuleReplacementPlugin()]; +config.plugins.unshift(new webpack.HotModuleReplacementPlugin()); config.devtool = 'eval-source-map'; -// Add the styles -config.resolve.root.push(path.join(__dirname, 'assets/stylesheets')); - // All the styling loaders only apply to hot-reload, not rails config.module.loaders.push( {test: /\.jsx?$/, loaders: ['react-hot', 'babel'], exclude: /node_modules/}, - - {test: require.resolve('jquery'), loader: 'expose?jQuery'}, - {test: require.resolve('jquery'), loader: 'expose?$'}, - {test: /\.css$/, loader: 'style-loader!css-loader'}, { test: /\.scss$/, diff --git a/client/webpack.rails.config.js b/client/webpack.client.rails.config.js similarity index 52% rename from client/webpack.rails.config.js rename to client/webpack.client.rails.config.js index af75a50ed..96db26438 100644 --- a/client/webpack.rails.config.js +++ b/client/webpack.client.rails.config.js @@ -1,33 +1,45 @@ // Run like this: -// cd client && $(npm bin)/webpack -w --config webpack.rails.config.js +// cd client && npm run build:dev // Note that Foreman (Procfile.dev) has also been configured to take care of this. // NOTE: All style sheets handled by the asset pipeline in rails -const config = require('./webpack.common.config'); +const webpack = require('webpack'); +const config = require('./webpack.client.base.config'); + +const devBuild = process.env.NODE_ENV !== 'production'; config.output = { - filename: 'client-bundle.js', + filename: '[name]-bundle.js', path: '../app/assets/javascripts/generated', }; // You can add entry points specific to rails here -config.entry.push('./scripts/rails_only', 'jquery', 'jquery-ujs', './assets/javascripts/clientGlobals'); +config.entry.vendor.unshift( + 'es5-shim/es5-shim', + 'es5-shim/es5-sham' +); +config.entry.app.push('./app/startup/clientGlobals'); // See webpack.common.config for adding modules common to both the webpack dev server and rails config.module.loaders.push( - {test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader'}, - {test: require.resolve('jquery'), loader: 'expose?jQuery'}, - {test: require.resolve('jquery'), loader: 'expose?$'} + {test: /\.jsx?$/, loader: 'babel-loader', exclude: /node_modules/} ); + module.exports = config; -// Next line is Heroku specific. You'll have BUILDPACK_URL defined for your Heroku install. -const devBuild = (typeof process.env.BUILDPACK_URL) === 'undefined'; if (devBuild) { console.log('Webpack dev build for Rails'); // eslint-disable-line no-console module.exports.devtool = 'eval-source-map'; } else { + config.plugins.push( + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: JSON.stringify('production'), + }, + }), + new webpack.optimize.DedupePlugin() + ); console.log('Webpack production build for Rails'); // eslint-disable-line no-console } diff --git a/client/webpack.server.config.js b/client/webpack.server.config.js deleted file mode 100644 index ec952ad06..000000000 --- a/client/webpack.server.config.js +++ /dev/null @@ -1,14 +0,0 @@ -const config = require('./webpack.common.config'); - -config.output = { - filename: 'server-bundle.js', - path: '../app/assets/javascripts/generated', - - // CRITICAL for enabling Rails to find the globally exposed variables. - libaryTarget: 'this', -}; - -config.entry.push('./assets/javascripts/serverGlobals'); - -config.module.loaders.push({ loader: 'babel-loader' }); -module.exports = config; diff --git a/client/webpack.server.rails.config.js b/client/webpack.server.rails.config.js new file mode 100644 index 000000000..ac164a4d6 --- /dev/null +++ b/client/webpack.server.rails.config.js @@ -0,0 +1,26 @@ +// Common webpack configuration for server bundle + +module.exports = { + + // the project dir + context: __dirname, + entry: ['./app/startup/serverGlobals'], + output: { + filename: 'server-bundle.js', + path: '../app/assets/javascripts/generated', + + // CRITICAL for enabling Rails to find the globally exposed variables. + libaryTarget: 'this', + }, + resolve: { + extensions: ['', '.webpack.js', '.web.js', '.js', '.jsx', 'config.js'], + }, + module: { + loaders: [ + {test: /\.jsx?$/, loader: 'babel-loader', exclude: /node_modules/}, + + // React is necessary for the client rendering: + {test: require.resolve('react'), loader: 'expose?React'}, + ], + }, +}; diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake index 2c43109d4..df0694d80 100644 --- a/lib/tasks/assets.rake +++ b/lib/tasks/assets.rake @@ -13,8 +13,8 @@ namespace :assets do desc "Compile assets with webpack" task :webpack do - sh "cd client && $(npm bin)/webpack --config webpack.rails.config.js" - sh "cd client && $(npm bin)/webpack --config webpack.server.config.js" + sh "cd client && npm run build:client" + sh "cd client && npm run build:server" end task :clobber do diff --git a/package.json b/package.json index 21ffb2eb0..997f1928b 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,8 @@ "node": "0.12.7" }, "scripts": { - "postinstall": "cd ./client && npm install", - "gulp": "cd ./client && npm run gulp", - "test": "rspec && client/bin/lint && (cd client && npm run jscs .)" + "postinstall": "cd client && npm install", + "test": "rspec && (cd client && npm run lint)" }, "repository": { "type": "git",