diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..892af96 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": [["airbnb", { "modules": false }]], + "plugins": ["syntax-dynamic-import"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..1514c54 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,15 @@ +{ + "extends": ["airbnb"], + "parser": "babel-eslint", + "root": true, + "rules": { + "react/prop-types": 0, + "jsx-a11y/label-has-for": 0, + "react/no-find-dom-node": 0, + "react/no-string-refs": 0, + "jsx-a11y/no-autofocus": 0, + "jsx-a11y/anchor-is-valid": [2, { + "specialLink": [ "to" ] + }] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..902af01 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Code Splitting + SSR with RRv4 demo + +This is a demo repository set up to demo code splitting by route on React Router v4 with server rendered React components. + +Running the demo: + +``` +git clone git@github.com:gdborton/rrv4-ssr-and-code-splitting.git +cd rrv4-ssr-and-code-splitting/ +npm install +webpack +node server +open http://localhost:3000 +``` + +## Things of note: + - The contents of this repo were based on the [TodoMVC code](https://github.com/tastejs/todomvc/tree/master/examples/react) originally written by [Pete Hunt](https://github.com/petehunt). + - We're using babel-eslint to enable `import()`. + - We're using the Airbnb dynamic import plugins, webpack's `import()` creates references to `window` that don't work in node: + - [babel-plugin-dynamic-import-webpack](https://github.com/airbnb/babel-plugin-dynamic-import-webpack) for client side code. + - [babel-plugin-dynamic-import-node](https://github.com/airbnb/babel-plugin-dynamic-import-node) for server side code. + - We have two webpack configs: + - One for server (`libraryTarget = commonjs2` and `babel-plugin-dynamic-import-node`). + - Another for client (`babel-plugin-dynamic-import-webpack`). + - The server, starts with some static data, **and is never updated**, you'll lose your changes if you reload the page. diff --git a/css/base.css b/css/base.css new file mode 100644 index 0000000..da65968 --- /dev/null +++ b/css/base.css @@ -0,0 +1,141 @@ +hr { + margin: 20px 0; + border: 0; + border-top: 1px dashed #c5c5c5; + border-bottom: 1px dashed #f7f7f7; +} + +.learn a { + font-weight: normal; + text-decoration: none; + color: #b83f45; +} + +.learn a:hover { + text-decoration: underline; + color: #787e7e; +} + +.learn h3, +.learn h4, +.learn h5 { + margin: 10px 0; + font-weight: 500; + line-height: 1.2; + color: #000; +} + +.learn h3 { + font-size: 24px; +} + +.learn h4 { + font-size: 18px; +} + +.learn h5 { + margin-bottom: 0; + font-size: 14px; +} + +.learn ul { + padding: 0; + margin: 0 0 30px 25px; +} + +.learn li { + line-height: 20px; +} + +.learn p { + font-size: 15px; + font-weight: 300; + line-height: 1.3; + margin-top: 0; + margin-bottom: 0; +} + +#issue-count { + display: none; +} + +.quote { + border: none; + margin: 20px 0 60px 0; +} + +.quote p { + font-style: italic; +} + +.quote p:before { + content: '“'; + font-size: 50px; + opacity: .15; + position: absolute; + top: -20px; + left: 3px; +} + +.quote p:after { + content: '”'; + font-size: 50px; + opacity: .15; + position: absolute; + bottom: -42px; + right: 3px; +} + +.quote footer { + position: absolute; + bottom: -40px; + right: 0; +} + +.quote footer img { + border-radius: 3px; +} + +.quote footer a { + margin-left: 5px; + vertical-align: middle; +} + +.speech-bubble { + position: relative; + padding: 10px; + background: rgba(0, 0, 0, .04); + border-radius: 5px; +} + +.speech-bubble:after { + content: ''; + position: absolute; + top: 100%; + right: 30px; + border: 13px solid transparent; + border-top-color: rgba(0, 0, 0, .04); +} + +.learn-bar > .learn { + position: absolute; + width: 272px; + top: 8px; + left: -300px; + padding: 10px; + border-radius: 5px; + background-color: rgba(255, 255, 255, .6); + transition-property: left; + transition-duration: 500ms; +} + +@media (min-width: 899px) { + .learn-bar { + width: auto; + padding-left: 300px; + } + + .learn-bar > .learn { + left: 8px; + } +} diff --git a/css/index.css b/css/index.css new file mode 100644 index 0000000..e6e089c --- /dev/null +++ b/css/index.css @@ -0,0 +1,378 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; + font-weight: 300; +} + +button, +input[type="checkbox"] { + outline: none; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + outline: none; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +label[for='toggle-all'] { + display: none; +} + +.toggle-all { + position: absolute; + top: -55px; + left: -12px; + width: 60px; + height: 34px; + text-align: center; + border: none; /* Mobile Safari */ +} + +.toggle-all:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 13px 17px 12px 17px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li .toggle:checked:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li label { + white-space: pre-line; + word-break: break-all; + padding: 15px 60px 15px 15px; + margin-left: 45px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a.selected, +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; + position: relative; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } + + .toggle-all { + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + -webkit-appearance: none; + appearance: none; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..ba3eb5f --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + React • TodoMVC + + + + + +
{{thing}}
+ + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..508e8cf --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "rrv4-ssr-and-code-splitting", + "version": "0.0.0", + "description": "This is a demo of RRv4 with SSR and code splitting.", + "main": "index.js", + "scripts": { + "lint": "eslint ./src", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com:gdborton/rrv4-ssr-and-code-splitting.git" + }, + "keywords": [ + "code", + "splitting", + "react", + "react-router", + "ssr", + "server", + "side", + "rendering" + ], + "author": "Gary Borton ", + "license": "MIT", + "bugs": { + "url": "https://github.com/gdborton/rrv4-ssr-and-code-splitting/issues" + }, + "homepage": "https://github.com/gdborton/rrv4-ssr-and-code-splitting#readme", + "dependencies": { + "classnames": "^2.2.5", + "express": "^4.16.2", + "mime": "^2.0.3", + "react": "^15.6.2", + "react-dom": "^15.6.2", + "react-router": "^4.2.0", + "react-router-config": "^1.0.0-beta.4", + "react-router-dom": "^4.2.2" + }, + "devDependencies": { + "babel-core": "^6.26.0", + "babel-eslint": "^7.2.3", + "babel-loader": "^7.1.2", + "babel-plugin-dynamic-import-node": "^1.1.0", + "babel-plugin-dynamic-import-webpack": "^1.0.1", + "babel-plugin-syntax-dynamic-import": "^6.18.0", + "babel-preset-airbnb": "^2.4.0", + "eslint": "^4.9.0", + "eslint-config-airbnb": "^16.1.0", + "eslint-plugin-import": "^2.8.0", + "eslint-plugin-jsx-a11y": "^6.0.2", + "eslint-plugin-react": "^7.4.0", + "react-dom": "^15.6.1", + "webpack": "^3.5.5" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..47039e8 --- /dev/null +++ b/server.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const express = require('express'); +const ReactDOMServer = require('react-dom/server'); +const App = require('./dist/index.server.bundle.js'); + +const PORT = 3000; + +const app = express(); +const template = fs.readFileSync('./index.html', 'utf8'); // stupid simple template. + +const todos = [ + { id: 'ed0bcc48-bbbe-5f06-c7c9-2ccb0456ceba', title: 'Wake Up.', completed: true }, + { id: '42582304-3c6e-311e-7f88-7e3791caf88c', title: 'Grab a brush and put a little makeup.', completed: true }, + { id: '036af7f9-1181-fb8f-258f-3f06034c020f', title: 'Write a blog post.', completed: false }, + { id: '1cf63885-5f75-8deb-19dc-9b6765deae6c', title: 'Create a demo repository.', completed: false }, + { id: '63a871b2-0b6f-4427-9c35-304bc680a4b7', title: '??????', completed: false }, + { id: '63a871b2-0b6f-4422-9c35-304bc680a4b7', title: 'Profit.', completed: false }, +]; + +// express.static was only working for some requests, but not others. +app.use('/dist', express.static(`${__dirname}/dist`)); +app.use('/css', express.static(`${__dirname}/css`)); + +app.get('*', (req, res) => { + const props = { todos }; + + App.default(req.url, props).then((reactComponent) => { + const result = ReactDOMServer.renderToString(reactComponent); + const html = template.replace('{{thing}}', result).replace('{{props}}', JSON.stringify(props)); + res.send(html); + res.end(); + }); +}); + +app.listen(PORT, () => { + console.log(`listening on port: ${PORT}`); +}); diff --git a/src/active-todos.jsx b/src/active-todos.jsx new file mode 100644 index 0000000..bf981c1 --- /dev/null +++ b/src/active-todos.jsx @@ -0,0 +1,7 @@ +import React from 'react'; +import TodoList from './todoList'; + +export default function ActiveTodos({ todos, ...props }) { + const filteredTodos = todos.filter(todo => !todo.completed); + return ; +} diff --git a/src/all-todos.jsx b/src/all-todos.jsx new file mode 100644 index 0000000..c202ebd --- /dev/null +++ b/src/all-todos.jsx @@ -0,0 +1,6 @@ +import React from 'react'; +import TodoList from './todoList'; + +export default function AllTodos(props) { + return ; +} diff --git a/src/app.jsx b/src/app.jsx new file mode 100644 index 0000000..9be8903 --- /dev/null +++ b/src/app.jsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { renderRoutes } from 'react-router-config'; +import TodoFooter from './footer'; +import utils from './utils'; + +const ENTER_KEY = 13; + +class TodoApp extends React.Component { + constructor(props) { + super(props); + + this.state = { + editing: null, + newTodo: '', + todos: props.todos, + }; + } + + handleChange(event) { + this.setState({ newTodo: event.target.value }); + } + + handleNewTodoKeyDown(event) { + if (event.keyCode !== ENTER_KEY) { + return; + } + + event.preventDefault(); + + const val = this.state.newTodo.trim(); + + if (val) { + this.setState({ + todos: this.state.todos.concat({ + id: utils.uuid(), + title: val, + completed: false, + }), + newTodo: '', + }); + } + } + + toggleAll(event) { + const { checked } = event.target; + this.setState({ + todos: this.state.todos.map(todo => Object.assign({}, todo, { completed: checked })), + }); + } + + toggle(todoToToggle) { + this.setState({ + todos: this.state.todos.map((todo) => { + if (todo === todoToToggle) { + return Object.assign({}, todo, { + completed: !todo.completed, + }); + } + return todo; + }), + }); + } + + destroy(passedTodo) { + this.setState({ + todos: this.state.todos.filter(todo => todo !== passedTodo), + }); + } + + edit(todo) { + this.setState({ editing: todo.id }); + } + + save(todoToSave, text) { + this.setState({ + todos: this.state.todos.map((todo) => { + if (todo === todoToSave) { + return Object.assign({}, todo, { + title: text, + }); + } + return todo; + }), + editing: null, + }); + } + + cancel() { + this.setState({ editing: null }); + } + + clearCompleted() { + this.setState({ + todos: this.state.todos.filter(todo => !todo.completed), + }); + } + + render() { + let footer; + let main; + const { todos } = this.state; + + const activeTodoCount = todos.reduce((accum, todo) => (todo.completed ? accum : accum + 1), 0); + + const completedCount = todos.length - activeTodoCount; + + if (activeTodoCount || completedCount) { + footer = + ( { this.clearCompleted(); }} + />); + } + + if (todos.length) { + main = ( +
+ +
    + { + renderRoutes(this.props.route.routes, { + todos, + onToggle: (todo) => { this.toggle(todo); }, + onDestroy: (todo) => { this.destroy(todo); }, + onEdit: (todo) => { this.edit(todo); }, + editing: todo => this.state.editing === todo.id, + onSave: (todo, text) => { this.save(todo, text); }, + onCancel: () => this.cancel(), + }) + } +
+
+ ); + } + + return ( +
+
+

todos

+ { this.handleNewTodoKeyDown(event); }} + onChange={(event) => { this.handleChange(event); }} + autoFocus + /> +
+ {main} + {footer} +
+ ); + } +} + +export default TodoApp; diff --git a/src/completed-todos.jsx b/src/completed-todos.jsx new file mode 100644 index 0000000..e6b359a --- /dev/null +++ b/src/completed-todos.jsx @@ -0,0 +1,7 @@ +import React from 'react'; +import TodoList from './todoList'; + +export default function ActiveTodos({ todos, ...props }) { + const filteredTodos = todos.filter(todo => todo.completed); + return ; +} diff --git a/src/entry.jsx b/src/entry.jsx new file mode 100644 index 0000000..0091140 --- /dev/null +++ b/src/entry.jsx @@ -0,0 +1,32 @@ +import { BrowserRouter, StaticRouter } from 'react-router-dom'; +import { render } from 'react-dom'; +import React from 'react'; +import { renderRoutes } from 'react-router-config'; + +import routes from './routes'; + +import { convertCustomRouteConfig, ensureReady } from './rrv4Helpers'; + +const routeConfig = convertCustomRouteConfig(routes); + +if (typeof window !== 'undefined') { + ensureReady(routeConfig).then(() => { + const props = JSON.parse(document.getElementById('props').dataset.props); // eslint-disable-line + render( + ( + + { renderRoutes(routeConfig, props) } + + ), + document.getElementsByClassName('todoapp')[0], // eslint-disable-line + ); + }); +} + +export default function render2(location, props) { + return ensureReady(routeConfig, location).then(() => ( + + {renderRoutes(routeConfig, props)} + + )); +} diff --git a/src/enums.js b/src/enums.js new file mode 100644 index 0000000..36b9842 --- /dev/null +++ b/src/enums.js @@ -0,0 +1,5 @@ +export default { + ALL_TODOS: '/all', + ACTIVE_TODOS: '/active', + COMPLETED_TODOS: '/completed', +}; diff --git a/src/footer.jsx b/src/footer.jsx new file mode 100644 index 0000000..7cafae3 --- /dev/null +++ b/src/footer.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; +import utils from './utils'; +import enums from './enums'; + + +export default function Footer(props) { + const activeTodoWord = utils.pluralize(props.count, 'item'); + const { nowShowing } = props; + return ( +
+ + {props.count} {activeTodoWord} left + +
    +
  • + + All + +
  • + {' '} +
  • + + Active + +
  • + {' '} +
  • + + Completed + +
  • +
+ { + props.completedCount ? + + : + null + } +
+ ); +} diff --git a/src/routes.js b/src/routes.js new file mode 100644 index 0000000..b05fb9a --- /dev/null +++ b/src/routes.js @@ -0,0 +1,35 @@ +import App from './app'; +import { generateAsyncRouteComponent } from './rrv4Helpers'; + +export default [ + { + component: App, + path: parentRoute => `${parentRoute}/`, + routes: [ + { + path: parentRoute => `${parentRoute}/all`, + component: generateAsyncRouteComponent({ + loader: () => import('./all-todos'), + }), + }, + { + path: parentRoute => `${parentRoute}/all`, + component: generateAsyncRouteComponent({ + loader: () => import('./all-todos'), + }), + }, + { + path: parentRoute => `${parentRoute}/active`, + component: generateAsyncRouteComponent({ + loader: () => import('./active-todos'), + }), + }, + { + path: parentRoute => `${parentRoute}/completed`, + component: generateAsyncRouteComponent({ + loader: () => import('./completed-todos'), + }), + }, + ], + }, +]; diff --git a/src/rrv4Helpers.jsx b/src/rrv4Helpers.jsx new file mode 100644 index 0000000..06f776d --- /dev/null +++ b/src/rrv4Helpers.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { matchRoutes } from 'react-router-config'; + +/** + * Returns a new React component, ready to be instantiated. + * Note the closure here protecting Component, and providing a unique + * instance of Component to the static implementation of `load`. + */ +export function generateAsyncRouteComponent({ loader, Placeholder }) { + let Component = null; + return class AsyncRouteComponent extends React.Component { + /** + * Static so that you can call load against an uninstantiated version of + * this component. This should only be called one time outside of the + * normal render path. + */ + static load() { + return loader().then((ResolvedComponent) => { + Component = ResolvedComponent.default || ResolvedComponent; + }); + } + + constructor() { + super(); + this.updateState = this.updateState.bind(this); + this.state = { + Component, + }; + } + + componentWillMount() { + AsyncRouteComponent.load().then(this.updateState); + } + + updateState() { + // Only update state if we don't already have a reference to the + // component, this prevent unnecessary renders. + if (this.state.Component !== Component) { + this.setState({ + Component, + }); + } + } + + render() { + const { Component: ComponentFromState } = this.state; + if (ComponentFromState) { + return ; + } + + if (Placeholder) { + return ; + } + + return null; + } + }; +} + +/** + * First match the routes via react-router-config's `matchRoutes` function. + * Then iterate over all of the matched routes, if they've got a load function + * call it. + * + * This helps us to make sure all the async code is loaded before rendering. + */ +export function ensureReady(routeConfig, providedLocation) { + const matches = matchRoutes(routeConfig, providedLocation || location.pathname); + return Promise.all(matches.map((match) => { + const { component } = match.route; + if (component && component.load) { + return component.load(); + } + return undefined; + })); +} + +export function convertCustomRouteConfig(customRouteConfig, parentRoute) { + return customRouteConfig.map((route) => { + if (typeof route.path === 'function') { + const pathResult = route.path(parentRoute || '').replace('//', '/'); + return { + path: pathResult, + component: route.component, + exact: route.exact, + routes: route.routes ? convertCustomRouteConfig(route.routes, pathResult) : [], + }; + } + const pathResult = `${parentRoute}${route.path}`; + return { + path: pathResult, + component: route.component, + exact: route.exact, + routes: route.routes ? convertCustomRouteConfig(route.routes, pathResult) : [], + }; + }); +} diff --git a/src/todoItem.jsx b/src/todoItem.jsx new file mode 100644 index 0000000..ea88fb4 --- /dev/null +++ b/src/todoItem.jsx @@ -0,0 +1,109 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; + +const ESCAPE_KEY = 27; +const ENTER_KEY = 13; + +export default class TodoItem extends React.Component { + constructor(props) { + super(props); + this.state = { + editText: props.todo.title, + }; + this.handleEdit = this.handleEdit.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + /** + * This is a completely optional performance enhancement that you can + * implement on any React component. If you were to delete this method + * the app would still work correctly (and still be very performant!), we + * just use it as an example of how little code it takes to get an order + * of magnitude performance improvement. + */ + shouldComponentUpdate(nextProps, nextState) { + return ( + nextProps.todo !== this.props.todo || + nextProps.editing !== this.props.editing || + nextState.editText !== this.state.editText + ); + } + + /** + * Safely manipulate the DOM after updating the state when invoking + * `this.props.onEdit()` in the `handleEdit` method above. + * For more info refer to notes at https://facebook.github.io/react/docs/component-api.html#setstate + * and https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate + */ + componentDidUpdate(prevProps) { + if (!prevProps.editing && this.props.editing) { + const node = ReactDOM.findDOMNode(this.refs.editField); + node.focus(); + node.setSelectionRange(node.value.length, node.value.length); + } + } + + handleSubmit() { + const val = this.state.editText.trim(); + if (val) { + this.props.onSave(val); + this.setState({ editText: val }); + } else { + this.props.onDestroy(); + } + } + + handleEdit() { + this.props.onEdit(); + this.setState({ editText: this.props.todo.title }); + } + + handleKeyDown(event) { + if (event.which === ESCAPE_KEY) { + this.setState({ editText: this.props.todo.title }); + this.props.onCancel(event); + } else if (event.which === ENTER_KEY) { + this.handleSubmit(event); + } + } + + handleChange(event) { + if (this.props.editing) { + this.setState({ editText: event.target.value }); + } + } + + render() { + return ( +
  • +
    + + +
    + +
  • + ); + } +} diff --git a/src/todoList.jsx b/src/todoList.jsx new file mode 100644 index 0000000..f151c45 --- /dev/null +++ b/src/todoList.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import TodoItem from './todoItem'; + +export default function TodoList(props) { + const todoItems = props.todos.map(todo => ( + { props.onToggle(todo); }} + onDestroy={() => { props.onDestroy(todo); }} + onEdit={() => { props.onEdit(todo); }} + editing={props.editing(todo)} + onSave={(text) => { props.onSave(todo, text); }} + onCancel={() => { props.onCancel(); }} + /> + )); + + return ( +
    + {todoItems} +
    + ); +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..bf8aa90 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,27 @@ +/* global localStorage */ +export default { + uuid() { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; + }, + + pluralize(count, word) { + return count === 1 ? word : `${word}s`; + }, + + store(namespace, data) { + if (typeof window !== 'undefined') { + if (data) { + return localStorage.setItem(namespace, JSON.stringify(data)); + } + + const store = localStorage.getItem(namespace); + return (store && JSON.parse(store)) || []; + } + return []; + }, +}; diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..34e79db --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,52 @@ +const path = require('path'); + +const entry = './src/entry.jsx'; +const outputPath = path.resolve('./dist'); +const publicPath = '/dist/'; +const resolve = { + extensions: ['.js', '.jsx'], +}; + +const clientConfig = { + entry, + output: { + path: outputPath, + filename: 'index.bundle.js', + publicPath, + }, + module: { + loaders: [{ + loader: 'babel-loader', + include: [path.resolve('./src')], + options: { + plugins: ['dynamic-import-webpack'], + }, + }], + }, + resolve, +}; + +const serverConfig = { + entry, + output: { + path: outputPath, + filename: 'index.server.bundle.js', + libraryTarget: 'commonjs2', + publicPath, + }, + module: { + loaders: [{ + loader: 'babel-loader', + include: [path.resolve('./src')], + options: { + plugins: ['dynamic-import-node'], + }, + }], + }, + resolve, +}; + +module.exports = [ + clientConfig, + serverConfig, +];