├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── LICENSE ├── favicon.ico ├── karma.conf.js ├── package.json ├── readme.md ├── src ├── app.css ├── app.js ├── bootstrap.js ├── controller.js ├── controller.test.js ├── graph │ ├── index.js │ └── render.js ├── helpers.js ├── index.html ├── model.js ├── store.js ├── template.js ├── todo.js └── view.js └── webpack.config.babel.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["es2015", {"modules": false}], 4 | "es2016", 5 | "stage-2", 6 | "react" 7 | ], 8 | "env": { 9 | "test": { 10 | "plugins": [ 11 | ["__coverage__", {"ignore": "*.+(test|stub).*"}] 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | dist 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "kentcdodds/best-practices", 4 | "kentcdodds/possible-errors", 5 | "kentcdodds/es6/best-practices", 6 | "kentcdodds/es6/possible-errors", 7 | "kentcdodds/import/best-practices", 8 | "kentcdodds/import/possible-errors", 9 | "kentcdodds/mocha", 10 | "kentcdodds/webpack", 11 | "kentcdodds/react", 12 | ], 13 | "rules": { 14 | // these are only here because I did not 15 | // want to update the entire codebase ¯\_(ツ)_/¯ 16 | "func-names": 0, 17 | "no-var": 0, 18 | "func-style": 0, 19 | "comma-dangle": 0, 20 | "valid-jsdoc": 0, 21 | "vars-on-top": 0, 22 | "complexity": [2, 8], 23 | "max-depth": [2, 6], 24 | "consistent-return": 0, 25 | "id-match": 0, 26 | "import/newline-after-import": 0, 27 | // es6 stuff we technically can not do yet 28 | "strict": 0, 29 | "object-shorthand": 0, 30 | "prefer-arrow-callback": 0, 31 | "prefer-template": 0, 32 | "babel/object-shorthand": 0, 33 | }, 34 | "globals": { 35 | "describe": false, 36 | "it": false, 37 | "expect": false, 38 | "$on": false, 39 | "beforeEach": false, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage/ 3 | dist 4 | .opt-in 5 | scripts/deploy 6 | package-log.json 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Kent C. Dodds 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/es6-todomvc/bf2db416838858efe6c2edd3b7438e6f99c0b2d6/favicon.ico -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | process.env.BABEL_ENV = 'test' 2 | const webpackEnv = {test: true} 3 | const webpackConfig = require('./webpack.config.babel')(webpackEnv) 4 | 5 | const testGlob = 'src/**/*.test.js' 6 | const srcGlob = 'src/**/!(*.test|*.stub).js' 7 | 8 | module.exports = config => { 9 | config.set({ 10 | basePath: '', 11 | frameworks: ['mocha', 'chai'], 12 | files: [testGlob, srcGlob], 13 | exclude: ['src/bootstrap.js'], 14 | preprocessors: { 15 | [testGlob]: ['webpack'], 16 | [srcGlob]: ['webpack'], 17 | }, 18 | webpack: webpackConfig, 19 | webpackMiddleware: {noInfo: true}, 20 | reporters: ['progress', 'coverage'], 21 | coverageReporter: { 22 | check: { 23 | global: { 24 | statements: 11, 25 | branches: 0, 26 | functions: 0, 27 | lines: 11, 28 | }, 29 | }, 30 | reporters: [ 31 | {type: 'lcov', dir: 'coverage/', subdir: '.'}, 32 | {type: 'json', dir: 'coverage/', subdir: '.'}, 33 | {type: 'text-summary'}, 34 | ], 35 | }, 36 | port: 9876, 37 | colors: true, 38 | logLevel: config.LOG_INFO, 39 | autoWatch: false, 40 | browsers: ['Chrome'], 41 | singleRun: true, 42 | concurrency: Infinity, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "d3": "3.5.17", 5 | "lodash": "4.14.1", 6 | "rd3": "0.7.0", 7 | "react": "15.3.0", 8 | "react-dom": "15.3.0", 9 | "todomvc-app-css": "2.0.6" 10 | }, 11 | "devDependencies": { 12 | "babel-core": "6.13.2", 13 | "babel-loader": "6.2.4", 14 | "babel-plugin-__coverage__": "11.0.0", 15 | "babel-preset-es2015": "6.13.2", 16 | "babel-preset-es2016": "6.11.3", 17 | "babel-preset-react": "6.11.1", 18 | "babel-preset-stage-2": "6.13.0", 19 | "chai": "3.5.0", 20 | "cpy-cli": "1.0.1", 21 | "css-loader": "0.23.1", 22 | "eslint": "3.2.2", 23 | "eslint-config-kentcdodds": "^9.0.0", 24 | "extract-text-webpack-plugin": "2.0.0-beta.3", 25 | "ghooks": "1.3.2", 26 | "html-webpack-plugin": "2.22.0", 27 | "http-server": "0.9.0", 28 | "inline-manifest-webpack-plugin": "3.0.1", 29 | "karma": "1.1.2", 30 | "karma-chai": "0.1.0", 31 | "karma-chrome-launcher": "1.0.1", 32 | "karma-coverage": "1.1.1", 33 | "karma-mocha": "1.1.1", 34 | "karma-webpack": "1.7.0", 35 | "mocha": "3.0.1", 36 | "npm-run-all": "2.3.0", 37 | "offline-plugin": "3.4.2", 38 | "opt-cli": "1.5.1", 39 | "progress-bar-webpack-plugin": "1.9.0", 40 | "rimraf": "2.5.4", 41 | "style-loader": "0.13.1", 42 | "surge": "0.18.0", 43 | "webpack": "2.1.0-beta.20", 44 | "webpack-config-utils": "2.0.0", 45 | "webpack-dev-server": "2.1.0-beta.0", 46 | "webpack-validator": "2.2.7" 47 | }, 48 | "config": { 49 | "ghooks": { 50 | "pre-commit": "opt --in pre-commit --exec \"npm run validate\"" 51 | } 52 | }, 53 | "scripts": { 54 | "predeploy": "npm run build", 55 | "deploy": "./scripts/deploy", 56 | "prebuild": "rimraf dist", 57 | "build": "webpack --env.prod -p", 58 | "postbuild": "cpy favicon.ico dist", 59 | "prebuild:dev": "rimraf dist", 60 | "build:dev": "webpack --env.dev", 61 | "postbuild:dev": "cpy favicon.ico dist", 62 | "start": "http-server dist", 63 | "dev": "webpack-dev-server --env.dev --hot", 64 | "debug": "node-nightly --inspect --debug-brk node_modules/.bin/webpack --env.debug", 65 | "debug:dev": "npm run debug -- --env.dev", 66 | "debug:prod": "npm run debug -- --env.prod", 67 | "test": "karma start", 68 | "watch:test": "npm test -- --auto-watch --no-single-run", 69 | "validate": "npm-run-all --parallel lint build test", 70 | "lint": "eslint .", 71 | "setup": "npm install && npm run validate", 72 | "setup:fem": "git checkout FEM/07.1-deploy-surge && npm install && npm run validate && rimraf dist coverage && git checkout FEM/00-original-project", 73 | "setup:workshop": "git checkout workshop/07-coverage && npm install && npm run validate && rimraf dist coverage && git checkout workshop/00-original-project", 74 | "setup:egghead": "git checkout prelesson/polyfill-promises && npm install && mkdir dist && npm run validate" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Webpack Workshop 2 | 3 | > Using the Vanilla JavaScript TodoMVC Example 4 | 5 | [![MIT License][license-badge]][LICENSE] 6 | [![Donate][donate-badge]][donate] 7 | [![Star on GitHub][github-star-badge]][github-star] 8 | [![Tweet][twitter-badge]][twitter] 9 | 10 | Sponsor 11 | 12 | ## Purpose 13 | 14 | This was originally part of [an ES6 training](http://kcd.im/es6-intro-slides) by [Kent C. Dodds](https://twitter.com/kentcdodds) 15 | 16 | Now I'm using it to teach people about [Webpack](http://webpack.github.io/): 17 | 18 | - [Egghead.io lessons](http://kcd.im/egghead-webpack) 19 | - [Frontend Masters](http://kcd.im/fem-webpack) 20 | 21 | ## Thanks 22 | 23 | This codebase was originally taken from the TodoMVC project starting [here](https://github.com/tastejs/todomvc/tree/563d1e1b8cee5f6ec962ec43663cb66a72b69d76/examples/vanillajs). Big thanks to them! 24 | 25 | ## Latest Workshop 26 | 27 | This repo has been used to teach in several different places (egghead, Frontend Masters, etc.). If you're coming here 28 | to follow along with that, then I recommend you follow the project setup for that (see below). The most recent and 29 | up-to-date version of the workshop is [the Frontend Masters workshop](http://kcd.im/fem-webpack). This is a linear 30 | workshop and starts with the `FEM/00-original-project` branch. See 31 | [the slides](https://slides.com/kentcdodds/webpack-deep-dive). 32 | 33 | ## Project Setup 34 | 35 | This project assumes you have [NodeJS v6](http://nodejs.org/) or greater installed. You should 36 | also have [npm v3](https://www.npmjs.com/) or greater installed as well (this comes packaged 37 | with Node 6). You'll also need a recent version of [git](https://git-scm.com/) installed 38 | as well. 39 | 40 | You may have come to this project from different varying sources. There are a 41 | different series of branches for each workshop/course I've done. To get started with 42 | the project, start with this: 43 | 44 | 1. [Sign up](https://github.com/join) for a GitHub Account (if you don't already have one) 45 | 2. [Fork](https://help.github.com/articles/fork-a-repo/) this repo 46 | 3. [Clone](https://help.github.com/articles/cloning-a-repository/) your fork 47 | 4. In the directory you cloned the repository, run `git fetch --all` 48 | 49 | If you need help with these steps, you might check out 50 | [this free Egghead.io course](http://kcd.im/pull-request) which can help you get things going. 51 | 52 | Finally, based on which version of the project you're looking for (workshop, egghead, or 53 | Frontend Masters) you'll run one of the following commands in the cloned directory: 54 | 55 | - **ES6 Workshop**: `npm run setup:workshop` 56 | - **Egghead Course**: `npm run setup:egghead` 57 | - **Frontend Masters Workshop**: `npm run setup:fem` 58 | 59 | If you get any failures at this point something is wrong and needs to be fixed. Remember, 60 | [Google](https://google.com) and [StackOverflow](https://stackoverflow.com) are your friends. 61 | 62 | You might find it helpful to see a list of the available branches. Run: `git branch` for that. 63 | 64 | ## Notes 65 | 66 | Because Webpack 2 is currently in beta, there are issues with `peerDependencies`, so you’ll have to use npm version 3 to be able to install the dependencies. 67 | 68 | Running Webpack with `webpack -p` is not [showing the warnings](https://webpack.js.org/guides/migrating/#uglifyjsplugin-warnings) about the dead code elimination anymore. However, it is working as expected. 69 | 70 | ### Updates 71 | 72 | The ecosystem moves fast. So this section of the README will be dedicated to tracking changes in the ecosystem so you 73 | can follow the repo, but be aware of changes in the latest versions of the dependencies you'll be installing (or if 74 | there are better dependencies to use now). 75 | 76 | - All courses 77 | - `babel-preset-es2015-webpack` is no longer necessary for leveraging tree shaking. You can now just use 78 | `babel-preset-es2015` with a special configuration to indicate modules should not be transformed. 79 | [More info](https://github.com/kentcdodds/es6-todomvc/issues/13) 80 | - Egghead Course 81 | - Using `istanbul` is no longer necessary for checking code coverage and can actually be accomplished using special 82 | configuration in `karma.conf.js` with the `karma-coverage` plugin like 83 | [this](https://github.com/kentcdodds/es6-todomvc/blob/f4f790ef7602bf9de4620841848d91f5213e647e/karma.conf.js#L22-L29). 84 | 85 | ### Contributing 86 | 87 | Super nice that you're interested in contributing. Unfortunately things are pretty complex with how things are set up 88 | with branches for each section of the workshop. So feel free to file pull requests to indicate what needs to be changed 89 | but if I decide to implement a change in the workshop code, I'll probably just have to manually make the updates. 90 | Thanks! 91 | 92 | ## Windows 10 Setup Instructions 93 | 94 | 1. Fork and clone this repository 95 | 2. Download Git Bash 96 | 3. Follow the instructions on this page to clone all branches at once in Git Bash: https://stackoverflow.com/questions/40310932/git-hub-clone-all-branches-at-once 97 | 4. In Git Bash: run `cd es6-todomvc` 98 | 5. Run `npm run setup:fem` (this will fail, but there is a workaround) 99 | 6. After that fails: 100 | 101 | * In **Git Bash** run `git stash` 102 | * run `git checkout FEM/00-original-project --force` 103 | * Make sure http-server is installed globally: `npm i -g http-server` 104 | * run `http-server --silent -c-1 -p 3084` (or whatever port number you want to use). If the port number you are trying to use is already in use, it will give you a nasty error that says something like: Error: listen EADDRINUSE 0.0.0.0:8081 105 | * Open `http://localhost:3084/` or change the URL to indicate the port number you wish to use 106 | * In your package.json file, add `-p 3084` to the end of your "start" script 107 | * Add `open http://localhost:3084/ && ` to the beginning of your start script (make sure there is a space between `&&` and `http-server` 108 | * now you can just run `npm start` and the app should load up at `http://localhost:3084` 109 | 110 | ## LICENSE 111 | 112 | MIT 113 | 114 | [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 115 | [license]: https://github.com/kentcdodds/es6-todomvc/blob/master/LICENSE 116 | [donate-badge]: https://img.shields.io/badge/%EF%BC%84-support-green.svg?style=flat-square 117 | [donate]: http://kcd.im/donate 118 | [github-star-badge]: https://img.shields.io/github/stars/kentcdodds/es6-todomvc.svg?style=social&label=Star 119 | [github-star]: https://github.com/kentcdodds/es6-todomvc/stargazers 120 | [twitter]: https://twitter.com/intent/tweet?text=Check%20out%20this%20fantastic%20webpack%20workshop!%20http://kcd.im/webpack-workshop-repo%20%F0%9F%98%8E 121 | [twitter-badge]: https://img.shields.io/twitter/url/https/kcd.im/webpack-workshop-repo.svg?style=social 122 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | .toggle-graph { 2 | float: left; 3 | margin-left: 16px; 4 | cursor: pointer; 5 | position: relative; 6 | z-index: 1; 7 | } 8 | .toggle-graph svg { 9 | height: 20px; 10 | width: 20px; 11 | } 12 | .toggle-graph svg path { 13 | fill: #777; 14 | } 15 | 16 | .toggle-graph.active svg path, 17 | .toggle-graph:hover svg path, 18 | .toggle-graph:focus svg path { 19 | fill: black; 20 | } 21 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import 'todomvc-app-css/index.css' 2 | import './app.css' 3 | 4 | import {$on} from './helpers' 5 | import {updateTodo} from './todo' 6 | import toggleGraph from './graph' 7 | 8 | export function onLoad() { // eslint-disable-line import/prefer-default-export 9 | updateTodo() 10 | const toggleGraphButton = document.querySelector('.toggle-graph') 11 | $on( 12 | toggleGraphButton, 13 | 'click', 14 | () => { 15 | const active = toggleGraph() 16 | if (active) { 17 | toggleGraphButton.classList.add('active') 18 | } else { 19 | toggleGraphButton.classList.remove('active') 20 | } 21 | }, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/bootstrap.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import {install as offlineInstall} from 'offline-plugin/runtime' 3 | import {onLoad} from './app' 4 | import {$on} from './helpers' 5 | 6 | // this is only relevant when using `hot` mode with webpack 7 | // special thanks to Eric Clemmons: https://github.com/ericclemmons/webpack-hot-server-example 8 | const reloading = document.readyState === 'complete' 9 | if (module.hot) { 10 | module.hot.accept(function(err) { 11 | console.log('❌ HMR Error:', err) 12 | }) 13 | if (reloading) { 14 | console.log('🔁 HMR Reloading.') 15 | onLoad() 16 | } else { 17 | console.info('✅ HMR Enabled.') 18 | bootstrap() 19 | } 20 | } else { 21 | console.info('❌ HMR Not Supported.') 22 | bootstrap() 23 | } 24 | 25 | function bootstrap() { 26 | $on(window, 'load', onLoad) 27 | $on(window, 'hashchange', onLoad) 28 | if (process.env.NODE_ENV === 'production') { 29 | offlineInstall() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/controller.js: -------------------------------------------------------------------------------- 1 | export default Controller 2 | 3 | /** 4 | * Takes a model and view and acts as the controller between them 5 | * 6 | * @constructor 7 | * @param {object} model The model instance 8 | * @param {object} view The view instance 9 | */ 10 | function Controller(model, view) { 11 | var that = this 12 | that.model = model 13 | that.view = view 14 | 15 | that.view.bind('newTodo', function(title) { 16 | that.addItem(title) 17 | }) 18 | 19 | that.view.bind('itemEdit', function(item) { 20 | that.editItem(item.id) 21 | }) 22 | 23 | that.view.bind('itemEditDone', function(item) { 24 | that.editItemSave(item.id, item.title) 25 | }) 26 | 27 | that.view.bind('itemEditCancel', function(item) { 28 | that.editItemCancel(item.id) 29 | }) 30 | 31 | that.view.bind('itemRemove', function(item) { 32 | that.removeItem(item.id) 33 | }) 34 | 35 | that.view.bind('itemToggle', function(item) { 36 | that.toggleComplete(item.id, item.completed) 37 | }) 38 | 39 | that.view.bind('removeCompleted', function() { 40 | that.removeCompletedItems() 41 | }) 42 | 43 | that.view.bind('toggleAll', function(status) { 44 | that.toggleAll(status.completed) 45 | }) 46 | } 47 | 48 | /** 49 | * Loads and initialises the view 50 | * 51 | * @param {string} '' | 'active' | 'completed' 52 | */ 53 | Controller.prototype.setView = function(locationHash) { 54 | var route = locationHash.split('/')[1] 55 | var page = route || '' 56 | this._updateFilterState(page) 57 | } 58 | 59 | /** 60 | * An event to fire on load. Will get all items and display them in the 61 | * todo-list 62 | */ 63 | Controller.prototype.showAll = function() { 64 | var that = this 65 | that.model.read(function(data) { 66 | that.view.render('showEntries', data) 67 | }) 68 | } 69 | 70 | /** 71 | * Renders all active tasks 72 | */ 73 | Controller.prototype.showActive = function() { 74 | var that = this 75 | that.model.read({completed: false}, function(data) { 76 | that.view.render('showEntries', data) 77 | }) 78 | } 79 | 80 | /** 81 | * Renders all completed tasks 82 | */ 83 | Controller.prototype.showCompleted = function() { 84 | var that = this 85 | that.model.read({completed: true}, function(data) { 86 | that.view.render('showEntries', data) 87 | }) 88 | } 89 | 90 | /** 91 | * An event to fire whenever you want to add an item. Simply pass in the event 92 | * object and it'll handle the DOM insertion and saving of the new item. 93 | */ 94 | Controller.prototype.addItem = function(title) { 95 | var that = this 96 | 97 | if (title.trim() === '') { 98 | return 99 | } 100 | 101 | that.model.create(title, function() { 102 | that.view.render('clearNewTodo') 103 | that._filter(true) 104 | }) 105 | } 106 | 107 | /* 108 | * Triggers the item editing mode. 109 | */ 110 | Controller.prototype.editItem = function(id) { 111 | var that = this 112 | that.model.read(id, function(data) { 113 | that.view.render('editItem', {id, title: data[0].title}) 114 | }) 115 | } 116 | 117 | /* 118 | * Finishes the item editing mode successfully. 119 | */ 120 | Controller.prototype.editItemSave = function(id, title) { 121 | var that = this 122 | if (title.trim()) { 123 | that.model.update(id, {title}, function() { 124 | that.view.render('editItemDone', {id, title}) 125 | }) 126 | } else { 127 | that.removeItem(id) 128 | } 129 | } 130 | 131 | /* 132 | * Cancels the item editing mode. 133 | */ 134 | Controller.prototype.editItemCancel = function(id) { 135 | var that = this 136 | that.model.read(id, function(data) { 137 | that.view.render('editItemDone', {id, title: data[0].title}) 138 | }) 139 | } 140 | 141 | /** 142 | * By giving it an ID it'll find the DOM element matching that ID, 143 | * remove it from the DOM and also remove it from storage. 144 | * 145 | * @param {number} id The ID of the item to remove from the DOM and 146 | * storage 147 | */ 148 | Controller.prototype.removeItem = function(id) { 149 | var that = this 150 | that.model.remove(id, function() { 151 | that.view.render('removeItem', id) 152 | }) 153 | 154 | that._filter() 155 | } 156 | 157 | /** 158 | * Will remove all completed items from the DOM and storage. 159 | */ 160 | Controller.prototype.removeCompletedItems = function() { 161 | var that = this 162 | that.model.read({completed: true}, function(data) { 163 | data.forEach(function(item) { 164 | that.removeItem(item.id) 165 | }) 166 | }) 167 | 168 | that._filter() 169 | } 170 | 171 | /** 172 | * Give it an ID of a model and a checkbox and it will update the item 173 | * in storage based on the checkbox's state. 174 | * 175 | * @param {number} id The ID of the element to complete or uncomplete 176 | * @param {object} checkbox The checkbox to check the state of complete 177 | * or not 178 | * @param {boolean|undefined} silent Prevent re-filtering the todo items 179 | */ 180 | Controller.prototype.toggleComplete = function(id, completed, silent) { 181 | var that = this 182 | that.model.update(id, {completed}, function() { 183 | that.view.render('elementComplete', { 184 | id, 185 | completed, 186 | }) 187 | }) 188 | 189 | if (!silent) { 190 | that._filter() 191 | } 192 | } 193 | 194 | /** 195 | * Will toggle ALL checkboxes' on/off state and completeness of models. 196 | * Just pass in the event object. 197 | */ 198 | Controller.prototype.toggleAll = function(completed) { 199 | var that = this 200 | that.model.read({completed: !completed}, function(data) { 201 | data.forEach(function(item) { 202 | that.toggleComplete(item.id, completed, true) 203 | }) 204 | }) 205 | 206 | that._filter() 207 | } 208 | 209 | /** 210 | * Updates the pieces of the page which change depending on the remaining 211 | * number of todos. 212 | */ 213 | Controller.prototype._updateCount = function() { 214 | var that = this 215 | that.model.getCount(function(todos) { 216 | that.view.render('updateElementCount', todos.active) 217 | that.view.render('clearCompletedButton', { 218 | completed: todos.completed, 219 | visible: todos.completed > 0 220 | }) 221 | 222 | that.view.render('toggleAll', {checked: todos.completed === todos.total}) 223 | that.view.render('contentBlockVisibility', {visible: todos.total > 0}) 224 | }) 225 | } 226 | 227 | /** 228 | * Re-filters the todo items, based on the active route. 229 | * @param {boolean|undefined} force forces a re-painting of todo items. 230 | */ 231 | Controller.prototype._filter = function(force) { 232 | var activeRoute = this._activeRoute.charAt(0).toUpperCase() + this._activeRoute.substr(1) 233 | 234 | // Update the elements on the page, which change with each completed todo 235 | this._updateCount() 236 | 237 | // If the last active route isn't "All", or we're switching routes, we 238 | // re-create the todo item elements, calling: 239 | // this.show[All|Active|Completed](); 240 | if (force || this._lastActiveRoute !== 'All' || this._lastActiveRoute !== activeRoute) { 241 | this['show' + activeRoute]() 242 | } 243 | 244 | this._lastActiveRoute = activeRoute 245 | } 246 | 247 | /** 248 | * Simply updates the filter nav's selected states 249 | */ 250 | Controller.prototype._updateFilterState = function(currentPage) { 251 | // Store a reference to the active route, allowing us to re-filter todo 252 | // items as they are marked complete or incomplete. 253 | currentPage = currentPage.split('?')[0] 254 | this._activeRoute = currentPage 255 | 256 | if (currentPage === '') { 257 | this._activeRoute = 'All' 258 | } 259 | 260 | this._filter() 261 | 262 | this.view.render('setFilter', currentPage) 263 | } 264 | -------------------------------------------------------------------------------- /src/controller.test.js: -------------------------------------------------------------------------------- 1 | import Controller from './controller' 2 | 3 | describe('controller', () => { 4 | it('exists', () => { 5 | expect(Controller).to.exist 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/graph/index.js: -------------------------------------------------------------------------------- 1 | import {subscribe, getTodo} from '../todo' 2 | 3 | let graphArea 4 | const unsubscribe = { 5 | store: null, 6 | todo: null, 7 | } 8 | 9 | export default toggleGraph 10 | 11 | function toggleGraph() { 12 | if (graphArea) { 13 | graphArea.remove() 14 | graphArea = null 15 | unsubscribe.store() 16 | unsubscribe.todo() 17 | return false 18 | } else { 19 | graphArea = document.createElement('div') 20 | document.body.querySelector('.graph-area-container').appendChild(graphArea) 21 | const {storage} = getTodo() 22 | loadAndRenderGraph(graphArea, storage) 23 | updateTodoSubscription() 24 | updateStoreSubscription(storage) 25 | return true 26 | } 27 | } 28 | 29 | function updateTodoSubscription() { 30 | if (unsubscribe.todo) { 31 | unsubscribe.todo() 32 | } 33 | unsubscribe.todo = subscribe(function onTodoUpdate() { 34 | const {storage} = getTodo() 35 | updateStoreSubscription(storage) 36 | loadAndRenderGraph(graphArea, storage) 37 | }) 38 | } 39 | 40 | function updateStoreSubscription(store) { 41 | if (unsubscribe.store) { 42 | unsubscribe.store() 43 | } 44 | unsubscribe.store = store.subscribe(function onStoreUpdate() { 45 | loadAndRenderGraph(graphArea, store) 46 | }) 47 | } 48 | 49 | function loadAndRenderGraph(node, store) { 50 | System.import('./render').then(({default: renderGraph}) => { 51 | renderGraph(node, store) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /src/graph/render.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {PieChart} from 'rd3' 4 | import {chain} from 'lodash' 5 | 6 | export default updateGraph 7 | 8 | function updateGraph(node, store) { 9 | store.findAll(todos => { 10 | ReactDOM.render( 11 | , 12 | node, 13 | ) 14 | }) 15 | } 16 | 17 | function Graph({todos}) { 18 | const data = chain(todos) 19 | .groupBy('completed') 20 | .map((group, complete) => ({ 21 | label: complete === 'true' ? 'Complete' : 'Incomplete', 22 | value: Math.round(group.length / todos.length * 10000) / 100 23 | })) 24 | .value() 25 | return ( 26 |
27 | There are {todos.length} total todos 28 |
29 | 38 |
39 |
40 | ) 41 | } 42 | Graph.propTypes = { 43 | todos: React.PropTypes.array, 44 | } 45 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | export {qs, qsa, $on, $delegate, $parent, remove} 2 | 3 | // Get element(s) by CSS selector: 4 | function qs(selector, scope) { 5 | return (scope || document).querySelector(selector) 6 | } 7 | 8 | function qsa(selector, scope) { 9 | return (scope || document).querySelectorAll(selector) 10 | } 11 | 12 | // addEventListener wrapper: 13 | function $on(target, type, callback, useCapture) { 14 | target.addEventListener(type, callback, !!useCapture) 15 | } 16 | 17 | // Attach a handler to event for all elements that match the selector, 18 | // now or in the future, based on a root element 19 | function $delegate(target, selector, type, handler) { 20 | // https://developer.mozilla.org/en-US/docs/Web/Events/blur 21 | var useCapture = type === 'blur' || type === 'focus' 22 | $on(target, type, dispatchEvent, useCapture) 23 | 24 | function dispatchEvent(event) { 25 | var targetElement = event.target 26 | var potentialElements = qsa(selector, target) 27 | var hasMatch = Array.prototype.indexOf.call(potentialElements, targetElement) >= 0 28 | 29 | if (hasMatch) { 30 | handler.call(targetElement, event) 31 | } 32 | } 33 | } 34 | 35 | // Find the element's parent with the given tag name: 36 | // $parent(qs('a'), 'div'); 37 | function $parent(element, tagName) { 38 | if (!element.parentNode) { 39 | return undefined 40 | } 41 | if (element.parentNode.tagName.toLowerCase() === tagName.toLowerCase()) { 42 | return element.parentNode 43 | } 44 | return $parent(element.parentNode, tagName) 45 | } 46 | 47 | // removes an element from an array 48 | // const x = [1,2,3] 49 | // remove(x, 2) 50 | // x ~== [1,3] 51 | function remove(array, thing) { 52 | const index = array.indexOf(thing) 53 | if (index === -1) { 54 | return array 55 | } 56 | array.splice(index, 1) 57 | } 58 | 59 | // Allow for looping on nodes by chaining: 60 | // qsa('.foo').forEach(function () {}) 61 | NodeList.prototype.forEach = Array.prototype.forEach 62 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VanillaJS • TodoMVC 6 | <%=htmlWebpackPlugin.files.webpackManifest%> 7 | 8 | 9 |
10 |
11 |

todos

12 | 13 |
14 |
15 | 16 | 17 |
    18 |
    19 |
    20 | 21 | 26 | 37 | 38 |
    39 |
    40 |
    41 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/model.js: -------------------------------------------------------------------------------- 1 | export default Model 2 | 3 | /** 4 | * Creates a new Model instance and hooks up the storage. 5 | * 6 | * @constructor 7 | * @param {object} storage A reference to the client side storage class 8 | */ 9 | function Model(storage) { 10 | this.storage = storage 11 | } 12 | 13 | /** 14 | * Creates a new todo model 15 | * 16 | * @param {string} [title] The title of the task 17 | * @param {function} [callback] The callback to fire after the model is created 18 | */ 19 | Model.prototype.create = function(title, callback) { 20 | title = title || '' 21 | callback = callback || function() { 22 | } 23 | 24 | var newItem = { 25 | title: title.trim(), 26 | completed: false 27 | } 28 | 29 | this.storage.save(newItem, callback) 30 | } 31 | 32 | /** 33 | * Finds and returns a model in storage. If no query is given it'll simply 34 | * return everything. If you pass in a string or number it'll look that up as 35 | * the ID of the model to find. Lastly, you can pass it an object to match against. 36 | * 37 | * @param {string|number|object} [query] A query to match models against 38 | * @param {function} [callback] The callback to fire after the model is found 39 | * 40 | * @example 41 | * model.read(1, func); // Will find the model with an ID of 1 42 | * model.read('1'); // Same as above 43 | * //Below will find a model with foo equalling bar and hello equalling world. 44 | * model.read({ foo: 'bar', hello: 'world' }); 45 | */ 46 | Model.prototype.read = function(query, callback) { 47 | var queryType = typeof query 48 | callback = callback || function() { 49 | } 50 | 51 | if (queryType === 'function') { 52 | callback = query 53 | return this.storage.findAll(callback) 54 | } else if (queryType === 'string' || queryType === 'number') { 55 | query = parseInt(query, 10) 56 | this.storage.find({id: query}, callback) 57 | } else { 58 | this.storage.find(query, callback) 59 | } 60 | return undefined 61 | } 62 | 63 | /** 64 | * Updates a model by giving it an ID, data to update, and a callback to fire when 65 | * the update is complete. 66 | * 67 | * @param {number} id The id of the model to update 68 | * @param {object} data The properties to update and their new value 69 | * @param {function} callback The callback to fire when the update is complete. 70 | */ 71 | Model.prototype.update = function(id, data, callback) { 72 | this.storage.save(data, callback, id) 73 | } 74 | 75 | /** 76 | * Removes a model from storage 77 | * 78 | * @param {number} id The ID of the model to remove 79 | * @param {function} callback The callback to fire when the removal is complete. 80 | */ 81 | Model.prototype.remove = function(id, callback) { 82 | this.storage.remove(id, callback) 83 | } 84 | 85 | /** 86 | * WARNING: Will remove ALL data from storage. 87 | * 88 | * @param {function} callback The callback to fire when the storage is wiped. 89 | */ 90 | Model.prototype.removeAll = function(callback) { 91 | this.storage.drop(callback) 92 | } 93 | 94 | /** 95 | * Returns a count of all todos 96 | */ 97 | Model.prototype.getCount = function(callback) { 98 | var todos = { 99 | active: 0, 100 | completed: 0, 101 | total: 0 102 | } 103 | 104 | this.storage.findAll(function(data) { 105 | data.forEach(function(todo) { 106 | if (todo.completed) { 107 | todos.completed++ 108 | } else { 109 | todos.active++ 110 | } 111 | 112 | todos.total++ 113 | }) 114 | callback(todos) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import {remove} from './helpers' 2 | export default Store 3 | 4 | /** 5 | * Creates a new client side storage object and will create an empty 6 | * collection if no collection already exists. 7 | * 8 | * @param {string} name The name of our DB we want to use 9 | * @param {function} callback Our fake DB uses callbacks because in 10 | * real life you probably would be making AJAX calls 11 | */ 12 | function Store(name, callback) { 13 | callback = callback || function() { 14 | } 15 | 16 | this._dbName = name 17 | 18 | if (!localStorage[name]) { 19 | var data = { 20 | todos: [] 21 | } 22 | 23 | localStorage[name] = JSON.stringify(data) 24 | } 25 | 26 | callback.call(this, JSON.parse(localStorage[name])) 27 | this.subscribers = [] 28 | } 29 | 30 | Store.prototype.subscribe = function(subscriber) { 31 | this.subscribers.push(subscriber) 32 | return () => remove(this.subscribers, subscriber) 33 | } 34 | 35 | Store.prototype._notify = function() { 36 | this.subscribers.forEach(s => s()) 37 | } 38 | 39 | /** 40 | * Finds items based on a query given as a JS object 41 | * 42 | * @param {object} query The query to match against (i.e. {foo: 'bar'}) 43 | * @param {function} callback The callback to fire when the query has 44 | * completed running 45 | * 46 | * @example 47 | * db.find({foo: 'bar', hello: 'world'}, function (data) { 48 | * // data will return any items that have foo: bar and 49 | * // hello: world in their properties 50 | * }); 51 | */ 52 | Store.prototype.find = function(query, callback) { 53 | if (!callback) { 54 | return 55 | } 56 | 57 | var todos = JSON.parse(localStorage[this._dbName]).todos 58 | 59 | callback.call(this, todos.filter(function(todo) { 60 | for (var q in query) { 61 | if (query[q] !== todo[q]) { 62 | return false 63 | } 64 | } 65 | return true 66 | })) 67 | } 68 | 69 | /** 70 | * Will retrieve all data from the collection 71 | * 72 | * @param {function} callback The callback to fire upon retrieving data 73 | */ 74 | Store.prototype.findAll = function(callback) { 75 | callback = callback || function() { 76 | } 77 | callback.call(this, JSON.parse(localStorage[this._dbName]).todos) 78 | } 79 | 80 | /** 81 | * Will save the given data to the DB. If no item exists it will create a new 82 | * item, otherwise it'll simply update an existing item's properties 83 | * 84 | * @param {object} updateData The data to save back into the DB 85 | * @param {function} callback The callback to fire after saving 86 | * @param {number} id An optional param to enter an ID of an item to update 87 | */ 88 | Store.prototype.save = function(updateData, callback, id) { 89 | var data = JSON.parse(localStorage[this._dbName]) 90 | var todos = data.todos 91 | 92 | callback = callback || function() { 93 | } 94 | 95 | // If an ID was actually given, find the item and update each property 96 | if (id) { 97 | for (var i = 0; i < todos.length; i++) { 98 | if (todos[i].id === id) { 99 | for (var key in updateData) { // eslint-disable-line guard-for-in 100 | todos[i][key] = updateData[key] 101 | } 102 | break 103 | } 104 | } 105 | 106 | localStorage[this._dbName] = JSON.stringify(data) 107 | callback.call(this, JSON.parse(localStorage[this._dbName]).todos) 108 | } else { 109 | // Generate an ID 110 | updateData.id = new Date().getTime() 111 | 112 | todos.push(updateData) 113 | localStorage[this._dbName] = JSON.stringify(data) 114 | callback.call(this, [updateData]) 115 | } 116 | this._notify() 117 | } 118 | 119 | /** 120 | * Will remove an item from the Store based on its ID 121 | * 122 | * @param {number} id The ID of the item you want to remove 123 | * @param {function} callback The callback to fire after saving 124 | */ 125 | Store.prototype.remove = function(id, callback) { 126 | var data = JSON.parse(localStorage[this._dbName]) 127 | var todos = data.todos 128 | 129 | for (var i = 0; i < todos.length; i++) { 130 | if (todos[i].id === id) { 131 | todos.splice(i, 1) 132 | break 133 | } 134 | } 135 | 136 | localStorage[this._dbName] = JSON.stringify(data) 137 | callback.call(this, JSON.parse(localStorage[this._dbName]).todos) 138 | this._notify() 139 | } 140 | 141 | /** 142 | * Will drop all storage and start fresh 143 | * 144 | * @param {function} callback The callback to fire after dropping the data 145 | */ 146 | Store.prototype.drop = function(callback) { 147 | localStorage[this._dbName] = JSON.stringify({todos: []}) 148 | callback.call(this, JSON.parse(localStorage[this._dbName]).todos) 149 | this._notify() 150 | } 151 | -------------------------------------------------------------------------------- /src/template.js: -------------------------------------------------------------------------------- 1 | export default Template 2 | 3 | var htmlEscapes = { 4 | '&': '&', 5 | '<': '<', 6 | '>': '>', 7 | '"': '"', 8 | '\'': ''', 9 | '`': '`' 10 | } 11 | 12 | var escapeHtmlChar = function(chr) { 13 | return htmlEscapes[chr] 14 | } 15 | 16 | var reUnescapedHtml = /[&<>"'`]/g 17 | var reHasUnescapedHtml = new RegExp(reUnescapedHtml.source) 18 | 19 | var escape = function(string) { 20 | if (string && reHasUnescapedHtml.test(string)) { 21 | return string.replace(reUnescapedHtml, escapeHtmlChar) 22 | } else { 23 | return string 24 | } 25 | } 26 | 27 | /** 28 | * Sets up defaults for all the Template methods such as a default template 29 | * 30 | * @constructor 31 | */ 32 | function Template() { 33 | this.defaultTemplate = ` 34 |
  • 35 |
    36 | 37 | 38 | 39 |
    40 |
  • 41 | ` 42 | } 43 | 44 | /** 45 | * Creates an
  • HTML string and returns it for placement in your app. 46 | * 47 | * NOTE: In real life you should be using a templating engine such as Mustache 48 | * or Handlebars, however, this is a vanilla JS example. 49 | * 50 | * @param {object} data The object containing keys you want to find in the 51 | * template to replace. 52 | * @returns {string} HTML String of an
  • element 53 | * 54 | * @example 55 | * view.show({ 56 | * id: 1, 57 | * title: "Hello World", 58 | * completed: 0, 59 | * }); 60 | */ 61 | Template.prototype.show = function(data) { 62 | var i, l 63 | var view = '' 64 | 65 | for (i = 0, l = data.length; i < l; i++) { 66 | var template = this.defaultTemplate 67 | var completed = '' 68 | var checked = '' 69 | 70 | if (data[i].completed) { 71 | completed = 'completed' 72 | checked = 'checked' 73 | } 74 | 75 | template = template.replace('{{id}}', data[i].id) 76 | template = template.replace('{{title}}', escape(data[i].title)) 77 | template = template.replace('{{completed}}', completed) 78 | template = template.replace('{{checked}}', checked) 79 | 80 | view = view + template 81 | } 82 | 83 | return view 84 | } 85 | 86 | /** 87 | * Displays a counter of how many to dos are left to complete 88 | * 89 | * @param {number} activeTodos The number of active todos. 90 | * @returns {string} String containing the count 91 | */ 92 | Template.prototype.itemCounter = function(activeTodos) { 93 | var plural = activeTodos === 1 ? '' : 's' 94 | 95 | return '' + activeTodos + ' item' + plural + ' left' 96 | } 97 | 98 | /** 99 | * Updates the text within the "Clear completed" button 100 | * 101 | * @param {[type]} completedTodos The number of completed todos. 102 | * @returns {string} String containing the count 103 | */ 104 | Template.prototype.clearCompletedButton = function(completedTodos) { 105 | if (completedTodos > 0) { 106 | return 'Clear completed' 107 | } else { 108 | return '' 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/todo.js: -------------------------------------------------------------------------------- 1 | import View from './view' 2 | import Controller from './controller' 3 | import Model from './model' 4 | import Store from './store' 5 | import Template from './template' 6 | import {remove} from './helpers' 7 | 8 | export {updateTodo, getTodo, subscribe} 9 | 10 | let todo 11 | const subscribers = [] 12 | 13 | /** 14 | * Sets up a brand new Todo list. 15 | * 16 | * @param {string} name The name of your new to do list. 17 | */ 18 | function Todo(name) { 19 | this.storage = new Store(name) 20 | this.model = new Model(this.storage) 21 | this.template = new Template() 22 | this.view = new View(this.template) 23 | this.controller = new Controller(this.model, this.view) 24 | } 25 | 26 | function updateTodo() { 27 | todo = new Todo('todos-vanillajs') 28 | todo.controller.setView(document.location.hash) 29 | subscribers.forEach(s => s()) 30 | } 31 | 32 | function getTodo() { 33 | return todo 34 | } 35 | 36 | function subscribe(cb) { 37 | subscribers.push(cb) 38 | return function unsubscribe() { 39 | remove(subscribers, cb) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/view.js: -------------------------------------------------------------------------------- 1 | /* eslint no-invalid-this: 0, complexity:[2, 9] */ 2 | import {qs, qsa, $on, $parent, $delegate} from './helpers' 3 | 4 | /** 5 | * View that abstracts away the browser's DOM completely. 6 | * It has two simple entry points: 7 | * 8 | * - bind(eventName, handler) 9 | * Takes a todo application event and registers the handler 10 | * - render(command, parameterObject) 11 | * Renders the given command with the options 12 | */ 13 | export default class View { 14 | constructor(template) { 15 | this.template = template 16 | 17 | this.ENTER_KEY = 13 18 | this.ESCAPE_KEY = 27 19 | 20 | this.$todoList = qs('.todo-list') 21 | this.$todoItemCounter = qs('.todo-count') 22 | this.$clearCompleted = qs('.clear-completed') 23 | this.$main = qs('.main') 24 | this.$footer = qs('.footer') 25 | this.$toggleAll = qs('.toggle-all') 26 | this.$newTodo = qs('.new-todo') 27 | } 28 | 29 | _removeItem(id) { 30 | var elem = qs('[data-id="' + id + '"]') 31 | 32 | if (elem) { 33 | this.$todoList.removeChild(elem) 34 | } 35 | } 36 | 37 | _clearCompletedButton(completedCount, visible) { 38 | this.$clearCompleted.innerHTML = this.template.clearCompletedButton(completedCount) 39 | this.$clearCompleted.style.display = visible ? 'block' : 'none' 40 | } 41 | 42 | _editItemDone(id, title) { 43 | var listItem = qs('[data-id="' + id + '"]') 44 | 45 | if (!listItem) { 46 | return 47 | } 48 | 49 | var input = qs('input.edit', listItem) 50 | listItem.removeChild(input) 51 | 52 | listItem.className = listItem.className.replace('editing', '') 53 | 54 | qsa('label', listItem).forEach(function(label) { 55 | label.textContent = title 56 | }) 57 | } 58 | 59 | render(viewCmd, parameter) { 60 | var that = this 61 | var viewCommands = { 62 | showEntries: function() { 63 | that.$todoList.innerHTML = that.template.show(parameter) 64 | }, 65 | removeItem: function() { 66 | that._removeItem(parameter) 67 | }, 68 | updateElementCount: function() { 69 | that.$todoItemCounter.innerHTML = that.template.itemCounter(parameter) 70 | }, 71 | clearCompletedButton: function() { 72 | that._clearCompletedButton(parameter.completed, parameter.visible) 73 | }, 74 | contentBlockVisibility: function() { 75 | that.$main.style.display = that.$footer.style.display = parameter.visible ? 'block' : 'none' 76 | }, 77 | toggleAll: function() { 78 | that.$toggleAll.checked = parameter.checked 79 | }, 80 | setFilter: function() { 81 | _setFilter(parameter) 82 | }, 83 | clearNewTodo: function() { 84 | that.$newTodo.value = '' 85 | }, 86 | elementComplete: function() { 87 | _elementComplete(parameter.id, parameter.completed) 88 | }, 89 | editItem: function() { 90 | _editItem(parameter.id, parameter.title) 91 | }, 92 | editItemDone: function() { 93 | that._editItemDone(parameter.id, parameter.title) 94 | } 95 | } 96 | 97 | viewCommands[viewCmd]() 98 | } 99 | 100 | _bindItemEditDone(handler) { 101 | var that = this 102 | $delegate(that.$todoList, 'li .edit', 'blur', function() { 103 | if (!this.dataset.iscanceled) { 104 | handler({ 105 | id: _itemId(this), 106 | title: this.value 107 | }) 108 | } 109 | }) 110 | 111 | $delegate(that.$todoList, 'li .edit', 'keypress', function(event) { 112 | if (event.keyCode === that.ENTER_KEY) { 113 | // Remove the cursor from the input when you hit enter just like if it 114 | // were a real form 115 | this.blur() 116 | } 117 | }) 118 | } 119 | 120 | _bindItemEditCancel(handler) { 121 | var that = this 122 | $delegate(that.$todoList, 'li .edit', 'keyup', function(event) { 123 | if (event.keyCode === that.ESCAPE_KEY) { 124 | this.dataset.iscanceled = true 125 | this.blur() 126 | 127 | handler({id: _itemId(this)}) 128 | } 129 | }) 130 | } 131 | 132 | bind(event, handler) { 133 | var that = this 134 | if (event === 'newTodo') { 135 | $on(that.$newTodo, 'change', function() { 136 | handler(that.$newTodo.value) 137 | }) 138 | 139 | } else if (event === 'removeCompleted') { 140 | $on(that.$clearCompleted, 'click', function() { 141 | handler() 142 | }) 143 | 144 | } else if (event === 'toggleAll') { 145 | $on(that.$toggleAll, 'click', function() { 146 | handler({completed: this.checked}) 147 | }) 148 | 149 | } else if (event === 'itemEdit') { 150 | $delegate(that.$todoList, 'li label', 'dblclick', function() { 151 | handler({id: _itemId(this)}) 152 | }) 153 | 154 | } else if (event === 'itemRemove') { 155 | $delegate(that.$todoList, '.destroy', 'click', function() { 156 | handler({id: _itemId(this)}) 157 | }) 158 | 159 | } else if (event === 'itemToggle') { 160 | $delegate(that.$todoList, '.toggle', 'click', function() { 161 | handler({ 162 | id: _itemId(this), 163 | completed: this.checked 164 | }) 165 | }) 166 | 167 | } else if (event === 'itemEditDone') { 168 | that._bindItemEditDone(handler) 169 | 170 | } else if (event === 'itemEditCancel') { 171 | that._bindItemEditCancel(handler) 172 | } 173 | } 174 | } 175 | 176 | function _setFilter(currentPage) { 177 | qs('.filters .selected').className = '' 178 | qs('.filters [href="#/' + currentPage + '"]').className = 'selected' 179 | } 180 | 181 | function _elementComplete(id, completed) { 182 | var listItem = qs('[data-id="' + id + '"]') 183 | 184 | if (!listItem) { 185 | return 186 | } 187 | 188 | listItem.className = completed ? 'completed' : '' 189 | 190 | // In case it was toggled from an event and not by clicking the checkbox 191 | qs('input', listItem).checked = completed 192 | } 193 | 194 | function _editItem(id, title) { 195 | var listItem = qs('[data-id="' + id + '"]') 196 | 197 | if (!listItem) { 198 | return 199 | } 200 | 201 | listItem.className = listItem.className + ' editing' 202 | 203 | var input = document.createElement('input') 204 | input.className = 'edit' 205 | 206 | listItem.appendChild(input) 207 | input.focus() 208 | input.value = title 209 | } 210 | 211 | function _itemId(element) { 212 | var li = $parent(element, 'li') 213 | return parseInt(li.dataset.id, 10) 214 | } 215 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:"off" */ 2 | const {resolve} = require('path') 3 | const webpack = require('webpack') 4 | const ProgressBarPlugin = require('progress-bar-webpack-plugin') 5 | const HtmlWebpackPlugin = require('html-webpack-plugin') 6 | const InlineManifestWebpackPlugin = require('inline-manifest-webpack-plugin') 7 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 8 | const webpackValidator = require('webpack-validator') 9 | const {getIfUtils, removeEmpty} = require('webpack-config-utils') 10 | const OfflinePlugin = require('offline-plugin') 11 | 12 | module.exports = env => { 13 | const {ifProd, ifNotProd} = getIfUtils(env) 14 | const config = webpackValidator({ 15 | context: resolve('src'), 16 | entry: { 17 | app: './bootstrap.js', 18 | vendor: ['todomvc-app-css/index.css'], 19 | }, 20 | output: { 21 | filename: ifProd('bundle.[name].[chunkhash].js', 'bundle.[name].js'), 22 | path: resolve('dist'), 23 | pathinfo: ifNotProd(), 24 | }, 25 | devtool: ifProd('source-map', 'eval'), 26 | module: { 27 | loaders: [ 28 | {test: /\.js$/, loaders: ['babel'], exclude: /node_modules/}, 29 | { 30 | test: /\.css$/, 31 | loader: ExtractTextPlugin.extract({ 32 | fallbackLoader: 'style', 33 | loader: 'css', 34 | }) 35 | }, 36 | ], 37 | }, 38 | plugins: removeEmpty([ 39 | new ProgressBarPlugin(), 40 | new ExtractTextPlugin(ifProd('styles.[name].[chunkhash].css', 'styles.[name].css')), 41 | ifProd(new InlineManifestWebpackPlugin()), 42 | ifProd(new webpack.optimize.CommonsChunkPlugin({ 43 | names: ['vendor', 'manifest'], 44 | })), 45 | new HtmlWebpackPlugin({ 46 | template: './index.html', 47 | inject: 'head', 48 | }), 49 | new OfflinePlugin(), 50 | new webpack.DefinePlugin({ 51 | 'process.env': { 52 | NODE_ENV: ifProd('"production"', '"development"') 53 | } 54 | }), 55 | ]), 56 | }) 57 | if (env.debug) { 58 | console.log(config) 59 | debugger // eslint-disable-line 60 | } 61 | return config 62 | } 63 | --------------------------------------------------------------------------------