├── LICENSE ├── README.md ├── index.html ├── script.js ├── script.min.js └── style.css /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tania Rascia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔱 MVC.js 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 3 | 4 | Simple todo MVC application in plain JavaScript 5 | 6 | ### [Read the tutorial](https://www.taniarascia.com/javascript-mvc-todo-app) | [View the demo](https://taniarascia.github.io/mvc) 7 | 8 | ## Purpose 9 | 10 | Learn the MVC pattern by building a small app! 11 | 12 | - **Model** - manages the data of an application 13 | - **View** - a visual representation of the model 14 | - **Controller** - links the user and the system 15 | 16 | This application consists of `index.html`, `script.js`, and `style.css`. This means that there are no frameworks or dependencies getting in the way of learning the MVC concept. 17 | 18 | ## Author 19 | 20 | - [Tania Rascia](https://www.taniarascia.com) 21 | 22 | ## License 23 | 24 | This project is open source and available under the [MIT License](LICENSE). 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Todo App 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Model 3 | * 4 | * Manages the data of the application. 5 | */ 6 | class Model { 7 | constructor() { 8 | this.todos = JSON.parse(localStorage.getItem('todos')) || [] 9 | } 10 | 11 | bindTodoListChanged(callback) { 12 | this.onTodoListChanged = callback 13 | } 14 | 15 | _commit(todos) { 16 | this.onTodoListChanged(todos) 17 | localStorage.setItem('todos', JSON.stringify(todos)) 18 | } 19 | 20 | addTodo(todoText) { 21 | const todo = { 22 | id: this.todos.length > 0 ? this.todos[this.todos.length - 1].id + 1 : 1, 23 | text: todoText, 24 | complete: false, 25 | } 26 | 27 | this.todos.push(todo) 28 | 29 | this._commit(this.todos) 30 | } 31 | 32 | editTodo(id, updatedText) { 33 | this.todos = this.todos.map(todo => 34 | todo.id === id ? { id: todo.id, text: updatedText, complete: todo.complete } : todo 35 | ) 36 | 37 | this._commit(this.todos) 38 | } 39 | 40 | deleteTodo(id) { 41 | this.todos = this.todos.filter(todo => todo.id !== id) 42 | 43 | this._commit(this.todos) 44 | } 45 | 46 | toggleTodo(id) { 47 | this.todos = this.todos.map(todo => 48 | todo.id === id ? { id: todo.id, text: todo.text, complete: !todo.complete } : todo 49 | ) 50 | 51 | this._commit(this.todos) 52 | } 53 | } 54 | 55 | /** 56 | * @class View 57 | * 58 | * Visual representation of the model. 59 | */ 60 | class View { 61 | constructor() { 62 | this.app = this.getElement('#root') 63 | this.form = this.createElement('form') 64 | this.input = this.createElement('input') 65 | this.input.type = 'text' 66 | this.input.placeholder = 'Add todo' 67 | this.input.name = 'todo' 68 | this.submitButton = this.createElement('button') 69 | this.submitButton.textContent = 'Submit' 70 | this.form.append(this.input, this.submitButton) 71 | this.title = this.createElement('h1') 72 | this.title.textContent = 'Todos' 73 | this.todoList = this.createElement('ul', 'todo-list') 74 | this.app.append(this.title, this.form, this.todoList) 75 | 76 | this._temporaryTodoText = '' 77 | this._initLocalListeners() 78 | } 79 | 80 | get _todoText() { 81 | return this.input.value 82 | } 83 | 84 | _resetInput() { 85 | this.input.value = '' 86 | } 87 | 88 | createElement(tag, className) { 89 | const element = document.createElement(tag) 90 | 91 | if (className) element.classList.add(className) 92 | 93 | return element 94 | } 95 | 96 | getElement(selector) { 97 | const element = document.querySelector(selector) 98 | 99 | return element 100 | } 101 | 102 | displayTodos(todos) { 103 | // Delete all nodes 104 | while (this.todoList.firstChild) { 105 | this.todoList.removeChild(this.todoList.firstChild) 106 | } 107 | 108 | // Show default message 109 | if (todos.length === 0) { 110 | const p = this.createElement('p') 111 | p.textContent = 'Nothing to do! Add a task?' 112 | this.todoList.append(p) 113 | } else { 114 | // Create nodes 115 | todos.forEach(todo => { 116 | const li = this.createElement('li') 117 | li.id = todo.id 118 | 119 | const checkbox = this.createElement('input') 120 | checkbox.type = 'checkbox' 121 | checkbox.checked = todo.complete 122 | 123 | const span = this.createElement('span') 124 | span.contentEditable = true 125 | span.classList.add('editable') 126 | 127 | if (todo.complete) { 128 | const strike = this.createElement('s') 129 | strike.textContent = todo.text 130 | span.append(strike) 131 | } else { 132 | span.textContent = todo.text 133 | } 134 | 135 | const deleteButton = this.createElement('button', 'delete') 136 | deleteButton.textContent = 'Delete' 137 | li.append(checkbox, span, deleteButton) 138 | 139 | // Append nodes 140 | this.todoList.append(li) 141 | }) 142 | } 143 | 144 | // Debugging 145 | console.log(todos) 146 | } 147 | 148 | _initLocalListeners() { 149 | this.todoList.addEventListener('input', event => { 150 | if (event.target.className === 'editable') { 151 | this._temporaryTodoText = event.target.innerText 152 | } 153 | }) 154 | } 155 | 156 | bindAddTodo(handler) { 157 | this.form.addEventListener('submit', event => { 158 | event.preventDefault() 159 | 160 | if (this._todoText) { 161 | handler(this._todoText) 162 | this._resetInput() 163 | } 164 | }) 165 | } 166 | 167 | bindDeleteTodo(handler) { 168 | this.todoList.addEventListener('click', event => { 169 | if (event.target.className === 'delete') { 170 | const id = parseInt(event.target.parentElement.id) 171 | 172 | handler(id) 173 | } 174 | }) 175 | } 176 | 177 | bindEditTodo(handler) { 178 | this.todoList.addEventListener('focusout', event => { 179 | if (this._temporaryTodoText) { 180 | const id = parseInt(event.target.parentElement.id) 181 | 182 | handler(id, this._temporaryTodoText) 183 | this._temporaryTodoText = '' 184 | } 185 | }) 186 | } 187 | 188 | bindToggleTodo(handler) { 189 | this.todoList.addEventListener('change', event => { 190 | if (event.target.type === 'checkbox') { 191 | const id = parseInt(event.target.parentElement.id) 192 | 193 | handler(id) 194 | } 195 | }) 196 | } 197 | } 198 | 199 | /** 200 | * @class Controller 201 | * 202 | * Links the user input and the view output. 203 | * 204 | * @param model 205 | * @param view 206 | */ 207 | class Controller { 208 | constructor(model, view) { 209 | this.model = model 210 | this.view = view 211 | 212 | // Explicit this binding 213 | this.model.bindTodoListChanged(this.onTodoListChanged) 214 | this.view.bindAddTodo(this.handleAddTodo) 215 | this.view.bindEditTodo(this.handleEditTodo) 216 | this.view.bindDeleteTodo(this.handleDeleteTodo) 217 | this.view.bindToggleTodo(this.handleToggleTodo) 218 | 219 | // Display initial todos 220 | this.onTodoListChanged(this.model.todos) 221 | } 222 | 223 | onTodoListChanged = todos => { 224 | this.view.displayTodos(todos) 225 | } 226 | 227 | handleAddTodo = todoText => { 228 | this.model.addTodo(todoText) 229 | } 230 | 231 | handleEditTodo = (id, todoText) => { 232 | this.model.editTodo(id, todoText) 233 | } 234 | 235 | handleDeleteTodo = id => { 236 | this.model.deleteTodo(id) 237 | } 238 | 239 | handleToggleTodo = id => { 240 | this.model.toggleTodo(id) 241 | } 242 | } 243 | 244 | const app = new Controller(new Model(), new View()) 245 | -------------------------------------------------------------------------------- /script.min.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 4 | 5 | function _instanceof(left, right) { if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) { return !!right[Symbol.hasInstance](left); } else { return left instanceof right; } } 6 | 7 | function _classCallCheck(instance, Constructor) { if (!_instanceof(instance, Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 8 | 9 | function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } 10 | 11 | function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } 12 | 13 | /** 14 | * @class Model 15 | * 16 | * Manages the data of the application. 17 | */ 18 | var Model = 19 | /*#__PURE__*/ 20 | function () { 21 | function Model() { 22 | _classCallCheck(this, Model); 23 | 24 | this.todos = JSON.parse(localStorage.getItem('todos')) || []; 25 | } 26 | 27 | _createClass(Model, [{ 28 | key: "bindTodoListChanged", 29 | value: function bindTodoListChanged(callback) { 30 | this.onTodoListChanged = callback; 31 | } 32 | }, { 33 | key: "_commit", 34 | value: function _commit(todos) { 35 | this.onTodoListChanged(todos); 36 | localStorage.setItem('todos', JSON.stringify(todos)); 37 | } 38 | }, { 39 | key: "addTodo", 40 | value: function addTodo(todoText) { 41 | var todo = { 42 | id: this.todos.length > 0 ? this.todos[this.todos.length - 1].id + 1 : 1, 43 | text: todoText, 44 | complete: false 45 | }; 46 | this.todos.push(todo); 47 | 48 | this._commit(this.todos); 49 | } 50 | }, { 51 | key: "editTodo", 52 | value: function editTodo(id, updatedText) { 53 | this.todos = this.todos.map(function (todo) { 54 | return todo.id === id ? { 55 | id: todo.id, 56 | text: updatedText, 57 | complete: todo.complete 58 | } : todo; 59 | }); 60 | 61 | this._commit(this.todos); 62 | } 63 | }, { 64 | key: "deleteTodo", 65 | value: function deleteTodo(id) { 66 | this.todos = this.todos.filter(function (todo) { 67 | return todo.id !== id; 68 | }); 69 | 70 | this._commit(this.todos); 71 | } 72 | }, { 73 | key: "toggleTodo", 74 | value: function toggleTodo(id) { 75 | this.todos = this.todos.map(function (todo) { 76 | return todo.id === id ? { 77 | id: todo.id, 78 | text: todo.text, 79 | complete: !todo.complete 80 | } : todo; 81 | }); 82 | 83 | this._commit(this.todos); 84 | } 85 | }]); 86 | 87 | return Model; 88 | }(); 89 | /** 90 | * @class View 91 | * 92 | * Visual representation of the model. 93 | */ 94 | 95 | 96 | var View = 97 | /*#__PURE__*/ 98 | function () { 99 | function View() { 100 | _classCallCheck(this, View); 101 | 102 | this.app = this.getElement('#root'); 103 | this.form = this.createElement('form'); 104 | this.input = this.createElement('input'); 105 | this.input.type = 'text'; 106 | this.input.placeholder = 'Add todo'; 107 | this.input.name = 'todo'; 108 | this.submitButton = this.createElement('button'); 109 | this.submitButton.textContent = 'Submit'; 110 | this.form.append(this.input, this.submitButton); 111 | this.title = this.createElement('h1'); 112 | this.title.textContent = 'Todos'; 113 | this.todoList = this.createElement('ul', 'todo-list'); 114 | this.app.append(this.title, this.form, this.todoList); 115 | this._temporaryTodoText = ''; 116 | 117 | this._initLocalListeners(); 118 | } 119 | 120 | _createClass(View, [{ 121 | key: "_resetInput", 122 | value: function _resetInput() { 123 | this.input.value = ''; 124 | } 125 | }, { 126 | key: "createElement", 127 | value: function createElement(tag, className) { 128 | var element = document.createElement(tag); 129 | if (className) element.classList.add(className); 130 | return element; 131 | } 132 | }, { 133 | key: "getElement", 134 | value: function getElement(selector) { 135 | var element = document.querySelector(selector); 136 | return element; 137 | } 138 | }, { 139 | key: "displayTodos", 140 | value: function displayTodos(todos) { 141 | var _this = this; 142 | 143 | // Delete all nodes 144 | while (this.todoList.firstChild) { 145 | this.todoList.removeChild(this.todoList.firstChild); 146 | } // Show default message 147 | 148 | 149 | if (todos.length === 0) { 150 | var p = this.createElement('p'); 151 | p.textContent = 'Nothing to do! Add a task?'; 152 | this.todoList.append(p); 153 | } else { 154 | // Create nodes 155 | todos.forEach(function (todo) { 156 | var li = _this.createElement('li'); 157 | 158 | li.id = todo.id; 159 | 160 | var checkbox = _this.createElement('input'); 161 | 162 | checkbox.type = 'checkbox'; 163 | checkbox.checked = todo.complete; 164 | 165 | var span = _this.createElement('span'); 166 | 167 | span.contentEditable = true; 168 | span.classList.add('editable'); 169 | 170 | if (todo.complete) { 171 | var strike = _this.createElement('s'); 172 | 173 | strike.textContent = todo.text; 174 | span.append(strike); 175 | } else { 176 | span.textContent = todo.text; 177 | } 178 | 179 | var deleteButton = _this.createElement('button', 'delete'); 180 | 181 | deleteButton.textContent = 'Delete'; 182 | li.append(checkbox, span, deleteButton); // Append nodes 183 | 184 | _this.todoList.append(li); 185 | }); 186 | } // Debugging 187 | 188 | 189 | console.log(todos); 190 | } 191 | }, { 192 | key: "_initLocalListeners", 193 | value: function _initLocalListeners() { 194 | var _this2 = this; 195 | 196 | this.todoList.addEventListener('input', function (event) { 197 | if (event.target.className === 'editable') { 198 | _this2._temporaryTodoText = event.target.innerText; 199 | } 200 | }); 201 | } 202 | }, { 203 | key: "bindAddTodo", 204 | value: function bindAddTodo(handler) { 205 | var _this3 = this; 206 | 207 | this.form.addEventListener('submit', function (event) { 208 | event.preventDefault(); 209 | 210 | if (_this3._todoText) { 211 | handler(_this3._todoText); 212 | 213 | _this3._resetInput(); 214 | } 215 | }); 216 | } 217 | }, { 218 | key: "bindDeleteTodo", 219 | value: function bindDeleteTodo(handler) { 220 | this.todoList.addEventListener('click', function (event) { 221 | if (event.target.className === 'delete') { 222 | var id = parseInt(event.target.parentElement.id); 223 | handler(id); 224 | } 225 | }); 226 | } 227 | }, { 228 | key: "bindEditTodo", 229 | value: function bindEditTodo(handler) { 230 | var _this4 = this; 231 | 232 | this.todoList.addEventListener('focusout', function (event) { 233 | if (_this4._temporaryTodoText) { 234 | var id = parseInt(event.target.parentElement.id); 235 | handler(id, _this4._temporaryTodoText); 236 | _this4._temporaryTodoText = ''; 237 | } 238 | }); 239 | } 240 | }, { 241 | key: "bindToggleTodo", 242 | value: function bindToggleTodo(handler) { 243 | this.todoList.addEventListener('change', function (event) { 244 | if (event.target.type === 'checkbox') { 245 | var id = parseInt(event.target.parentElement.id); 246 | handler(id); 247 | } 248 | }); 249 | } 250 | }, { 251 | key: "_todoText", 252 | get: function get() { 253 | return this.input.value; 254 | } 255 | }]); 256 | 257 | return View; 258 | }(); 259 | /** 260 | * @class Controller 261 | * 262 | * Links the user input and the view output. 263 | * 264 | * @param model 265 | * @param view 266 | */ 267 | 268 | 269 | var Controller = function Controller(model, view) { 270 | var _this5 = this; 271 | 272 | _classCallCheck(this, Controller); 273 | 274 | _defineProperty(this, "onTodoListChanged", function (todos) { 275 | _this5.view.displayTodos(todos); 276 | }); 277 | 278 | _defineProperty(this, "handleAddTodo", function (todoText) { 279 | _this5.model.addTodo(todoText); 280 | }); 281 | 282 | _defineProperty(this, "handleEditTodo", function (id, todoText) { 283 | _this5.model.editTodo(id, todoText); 284 | }); 285 | 286 | _defineProperty(this, "handleDeleteTodo", function (id) { 287 | _this5.model.deleteTodo(id); 288 | }); 289 | 290 | _defineProperty(this, "handleToggleTodo", function (id) { 291 | _this5.model.toggleTodo(id); 292 | }); 293 | 294 | this.model = model; 295 | this.view = view; // Explicit this binding 296 | 297 | this.model.bindTodoListChanged(this.onTodoListChanged); 298 | this.view.bindAddTodo(this.handleAddTodo); 299 | this.view.bindEditTodo(this.handleEditTodo); 300 | this.view.bindDeleteTodo(this.handleDeleteTodo); 301 | this.view.bindToggleTodo(this.handleToggleTodo); // Display initial todos 302 | 303 | this.onTodoListChanged(this.model.todos); 304 | }; 305 | 306 | var app = new Controller(new Model(), new View()); -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box 5 | } 6 | 7 | html { 8 | font-family: sans-serif; 9 | font-size: 1rem; 10 | color: #444; 11 | } 12 | 13 | #root { 14 | max-width: 450px; 15 | margin: 2rem auto; 16 | padding: 0 1rem; 17 | } 18 | 19 | form { 20 | display: flex; 21 | margin-bottom: 2rem; 22 | } 23 | 24 | [type="text"], 25 | button { 26 | display: inline-block; 27 | -webkit-appearance: none; 28 | padding: .5rem 1rem; 29 | font-size: 1rem; 30 | border: 2px solid #ccc; 31 | border-radius: 4px; 32 | } 33 | 34 | button { 35 | cursor: pointer; 36 | background: #007bff; 37 | color: white; 38 | border: 2px solid #007bff; 39 | margin: 0 .5rem; 40 | } 41 | 42 | [type="text"] { 43 | width: 100%; 44 | } 45 | 46 | [type="text"]:active, 47 | [type="text"]:focus { 48 | outline: 0; 49 | border: 2px solid #007bff; 50 | } 51 | 52 | [type="checkbox"] { 53 | margin-right: 1rem; 54 | font-size: 2rem; 55 | } 56 | 57 | h1 { 58 | color: #222; 59 | } 60 | 61 | ul { 62 | padding: 0; 63 | } 64 | 65 | li { 66 | display: flex; 67 | align-items: center; 68 | padding: 1rem; 69 | margin-bottom: 1rem; 70 | background: #f4f4f4; 71 | border-radius: 4px; 72 | } 73 | 74 | li span { 75 | display: inline-block; 76 | padding: .5rem; 77 | width: 250px; 78 | border-radius: 4px; 79 | border: 2px solid transparent; 80 | } 81 | 82 | li span:hover { 83 | background: rgba(179, 215, 255, 0.52); 84 | } 85 | 86 | li span:focus { 87 | outline: 0; 88 | border: 2px solid #007bff; 89 | background: rgba(179, 207, 255, 0.52) 90 | } --------------------------------------------------------------------------------