├── LICENSE ├── README.md ├── app.js ├── controllers └── user.controller.js ├── index.html ├── models └── user.model.js ├── services └── user.service.js ├── style.css └── views └── user.view.js /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 | ### [Read the tutorial](https://carloscaballero.io/understanding-mvc-for-frontend-vanillajs/) 2 | 3 | # Introduction 4 | 5 | This post is the first in a series of three posts that will understand how the MVC architecture works to create frontend applications. The objective of this series of posts is to understand how to structure a frontend application by evolving a web page in which JavaScript is used as a scripting language towards an application in which JavaScript is used as an object-oriented language. 6 | 7 | In this first post, the application will be built using VanillaJS. Therefore, this article is where the largest amount of code related to the DOM will be developed. However, it is very important to understand how all the parts of the application are related and how it is structured. 8 | 9 | In the second article, we will reinforce the JavaScript code by transforming it into its TypeScript version. 10 | 11 | Finally, in the last article we will transform our code to integrate it with the Angular framework. 12 | 13 | 14 | 15 | * [Part 1. Understanding MVC-Services for Frontend: VanillaJS](build-your-pokedex-part1-introduction-ngrx) 16 | * Part 2. Understanding MVC-Services for Frontend: TypeScript 17 | * Part 3. Understanding MVC-Services for Frontend: Angular 18 | --- 19 | 20 | 21 | 22 | # Project Architecture 23 | There is nothing more valuable than an image to understand what we are going to build, there is a GIF below in which the application we are going to build is illustrated. 24 | 25 | 26 | ![demo](https://miro.medium.com/max/640/1*AdRdnVeheeydi2vrXepO-Q.gif) 27 | 28 | This application can be built using a single JavaScript file which modifies the DOM of the document and performs all operations, but this is a strongly coupled code and is not what we intend to apply in this post. 29 | 30 | What is the MVC architecture? MVC is an architecture with 3 layers / parts: 31 | 32 | - **Models** - Manage the data of an application. The models will be anemic (they will lack functionalities) since they will be referred to the services. 33 | - **Views** - A visual representation of the models. 34 | - **Controllers** - Links between services and views. 35 | 36 | Below, we show the file structure that we will have in our problem domain: 37 | 38 | ![folders](/content/images/2019/10/folders.png) 39 | 40 | The ```index.html``` file will act as a canvas on which the entire application will be dynamically built using the ```root``` element. In addition, this file will act as a loader of all the files since they will be linked in the html file itself. 41 | 42 | Finally, our file architecture is composed of the following JavaScript files: 43 | 44 | - **user.model.js** - The attributes (the model) of a user. 45 | - **user.controller.js** - The one in charge of joining the service and the view. 46 | - **user.service.js** - Manage all operations on users. 47 | - **user.views.js** - Responsible for refreshing and changing the display screen. 48 | 49 | The HTML file is the one shown below: 50 | 51 | 52 | 53 | ```html 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | User App 62 | 63 | 64 | 65 | 66 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | ``` 76 | 77 | ---- 78 | # Models (anemic) 79 | The first class built in this example is the application model, ```user.model.js```, which consists of the class attributes, and a private method that is generating random IDs (these id's could come from a database in the server). 80 | 81 | The models will have the following fields: 82 | - **id**. Unique value. 83 | - **name**. The name of the users. 84 | - **age**. The age of the users. 85 | - **complete**. Boolean that lets you know whether we can cross the user off the list. 86 | 87 | The ```user.model.js``` is shown below: 88 | 89 | ```javascript 90 | /** 91 | * @class Model 92 | * 93 | * Manages the data of the application. 94 | */ 95 | 96 | class User { 97 | constructor({ name, age, complete } = { complete: false }) { 98 | this.id = this.uuidv4(); 99 | this.name = name; 100 | this.age = age; 101 | this.complete = complete; 102 | } 103 | 104 | uuidv4() { 105 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => 106 | ( 107 | c ^ 108 | (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) 109 | ).toString(16) 110 | ); 111 | } 112 | } 113 | ``` 114 | ---- 115 | # Services 116 | The operations performed on users are carried out in the service. The service is what allows the models to be anemic, since all the logic load is in them. In this specific case, we will use an array to store all users and build the four methods associated with reading, modifying, creating and deleting (CRUD) users. You should note that the service makes use of the model, instantiating the objects that are extracted from ```LocalStorage``` to the ```User class```. This is because ```LocalStorage``` only stores data and not prototypes of stored data. The same happens with the data that travels from the backend to the frontend, they do not have their classes instantiated. 117 | 118 | The constructor of our class is as follows: 119 | 120 | ```javascript 121 | constructor() { 122 | const users = JSON.parse(localStorage.getItem('users')) || []; 123 | this.users = users.map(user => new User(user)); 124 | } 125 | ``` 126 | 127 | Note that we have defined a class variable called ```users``` that stores all users once they have been transformed from a flat object to a prototyped object of the ```User``` class. 128 | 129 | The next thing we must define in the service will be each of the operations we want to develop. These operations are shown below using ECMAScript, without using a single line in TypeScript: 130 | 131 | ```javascript 132 | add(user) { 133 | this.users.push(new User(user)); 134 | 135 | this._commit(this.users); 136 | } 137 | 138 | edit(id, userToEdit) { 139 | this.users = this.users.map(user => 140 | user.id === id 141 | ? new User({ 142 | ...user, 143 | ...userToEdit 144 | }) 145 | : user 146 | ); 147 | 148 | this._commit(this.users); 149 | } 150 | 151 | delete(_id) { 152 | this.users = this.users.filter(({ id }) => id !== _id); 153 | 154 | this._commit(this.users); 155 | } 156 | 157 | toggle(_id) { 158 | this.users = this.users.map(user => 159 | user.id === _id ? new User({ ...user, complete: !user.complete }) : user 160 | ); 161 | 162 | this._commit(this.users); 163 | } 164 | ``` 165 | 166 | It remains to be defined the ```commit``` method that is responsible for storing the operation performed in our data store (in our case ```LocalStorage```). 167 | 168 | ```javascript 169 | bindUserListChanged(callback) { 170 | this.onUserListChanged = callback; 171 | } 172 | 173 | _commit(users) { 174 | this.onUserListChanged(users); 175 | localStorage.setItem('users', JSON.stringify(users)); 176 | } 177 | ``` 178 | 179 | 180 | This method invokes a ```callback``` function that has been binded when creating the Service, as it can be seen in the definition of the ```bindUserListChanged```` method. I can already tell you that this callback is the function that comes from the view and is responsible for refreshing the list of users on the screen. 181 | 182 | The file ```user.service.js``` is as follows: 183 | 184 | ```javascript 185 | /** 186 | * @class Service 187 | * 188 | * Manages the data of the application. 189 | */ 190 | class UserService { 191 | constructor() { 192 | const users = JSON.parse(localStorage.getItem('users')) || []; 193 | this.users = users.map(user => new User(user)); 194 | } 195 | 196 | bindUserListChanged(callback) { 197 | this.onUserListChanged = callback; 198 | } 199 | 200 | _commit(users) { 201 | this.onUserListChanged(users); 202 | localStorage.setItem('users', JSON.stringify(users)); 203 | } 204 | 205 | add(user) { 206 | this.users.push(new User(user)); 207 | 208 | this._commit(this.users); 209 | } 210 | 211 | edit(id, userToEdit) { 212 | this.users = this.users.map(user => 213 | user.id === id 214 | ? new User({ 215 | ...user, 216 | ...userToEdit 217 | }) 218 | : user 219 | ); 220 | 221 | this._commit(this.users); 222 | } 223 | 224 | delete(_id) { 225 | this.users = this.users.filter(({ id }) => id !== _id); 226 | 227 | this._commit(this.users); 228 | } 229 | 230 | toggle(_id) { 231 | this.users = this.users.map(user => 232 | user.id === _id ? new User({ ...user, complete: !user.complete }) : user 233 | ); 234 | 235 | this._commit(this.users); 236 | } 237 | } 238 | ``` 239 | ---- 240 | # Views 241 | The view is the visual representation of the model. Instead of creating HTML content and injecting it (as it is done in many frameworks) we have decided to dynamically create the whole view. The first thing that should be done is to cache all the variables of the view through the DOM methods as shown in the view constructor: 242 | 243 | 244 | ```javascript 245 | constructor() { 246 | this.app = this.getElement('#root'); 247 | 248 | this.form = this.createElement('form'); 249 | this.createInput({ 250 | key: 'inputName', 251 | type: 'text', 252 | placeholder: 'Name', 253 | name: 'name' 254 | }); 255 | this.createInput({ 256 | key: 'inputAge', 257 | type: 'text', 258 | placeholder: 'Age', 259 | name: 'age' 260 | }); 261 | 262 | this.submitButton = this.createElement('button'); 263 | this.submitButton.textContent = 'Submit'; 264 | 265 | this.form.append(this.inputName, this.inputAge, this.submitButton); 266 | 267 | this.title = this.createElement('h1'); 268 | this.title.textContent = 'Users'; 269 | this.userList = this.createElement('ul', 'user-list'); 270 | this.app.append(this.title, this.form, this.userList); 271 | 272 | this._temporaryAgeText = ''; 273 | this._initLocalListeners(); 274 | } 275 | ``` 276 | 277 | The next most relevant point of the view is the union of the view with the service methods (which will be sent through the controller). For example, the ```bindAddUser``` method receives a driver function as a parameter that is the one that will perform the ```addUser``` operation, described in the service. In the ```bindXXX``` methods, the ```EventListener``` of each of the view controls are being defined. Note that from the view we have access to all the data provided by the user from the screen; which are connected through the ```handler``` functions. 278 | 279 | ```javascript 280 | bindAddUser(handler) { 281 | this.form.addEventListener('submit', event => { 282 | event.preventDefault(); 283 | 284 | if (this._nameText) { 285 | handler({ 286 | name: this._nameText, 287 | age: this._ageText 288 | }); 289 | this._resetInput(); 290 | } 291 | }); 292 | } 293 | 294 | bindDeleteUser(handler) { 295 | this.userList.addEventListener('click', event => { 296 | if (event.target.className === 'delete') { 297 | const id = event.target.parentElement.id; 298 | 299 | handler(id); 300 | } 301 | }); 302 | } 303 | 304 | bindEditUser(handler) { 305 | this.userList.addEventListener('focusout', event => { 306 | if (this._temporaryAgeText) { 307 | const id = event.target.parentElement.id; 308 | const key = 'age'; 309 | 310 | handler(id, { [key]: this._temporaryAgeText }); 311 | this._temporaryAgeText = ''; 312 | } 313 | }); 314 | } 315 | 316 | bindToggleUser(handler) { 317 | this.userList.addEventListener('change', event => { 318 | if (event.target.type === 'checkbox') { 319 | const id = event.target.parentElement.id; 320 | 321 | handler(id); 322 | } 323 | }); 324 | } 325 | ``` 326 | 327 | The rest of the code of the view goes through handling the DOM of the document. The file ```user.view.js``` is as follows: 328 | 329 | 330 | ```javascript 331 | /** 332 | * @class View 333 | * 334 | * Visual representation of the model. 335 | */ 336 | class UserView { 337 | constructor() { 338 | this.app = this.getElement('#root'); 339 | 340 | this.form = this.createElement('form'); 341 | this.createInput({ 342 | key: 'inputName', 343 | type: 'text', 344 | placeholder: 'Name', 345 | name: 'name' 346 | }); 347 | this.createInput({ 348 | key: 'inputAge', 349 | type: 'text', 350 | placeholder: 'Age', 351 | name: 'age' 352 | }); 353 | 354 | this.submitButton = this.createElement('button'); 355 | this.submitButton.textContent = 'Submit'; 356 | 357 | this.form.append(this.inputName, this.inputAge, this.submitButton); 358 | 359 | this.title = this.createElement('h1'); 360 | this.title.textContent = 'Users'; 361 | this.userList = this.createElement('ul', 'user-list'); 362 | this.app.append(this.title, this.form, this.userList); 363 | 364 | this._temporaryAgeText = ''; 365 | this._initLocalListeners(); 366 | } 367 | 368 | get _nameText() { 369 | return this.inputName.value; 370 | } 371 | get _ageText() { 372 | return this.inputAge.value; 373 | } 374 | 375 | _resetInput() { 376 | this.inputName.value = ''; 377 | this.inputAge.value = ''; 378 | } 379 | 380 | createInput( 381 | { key, type, placeholder, name } = { 382 | key: 'default', 383 | type: 'text', 384 | placeholder: 'default', 385 | name: 'default' 386 | } 387 | ) { 388 | this[key] = this.createElement('input'); 389 | this[key].type = type; 390 | this[key].placeholder = placeholder; 391 | this[key].name = name; 392 | } 393 | 394 | createElement(tag, className) { 395 | const element = document.createElement(tag); 396 | 397 | if (className) element.classList.add(className); 398 | 399 | return element; 400 | } 401 | 402 | getElement(selector) { 403 | return document.querySelector(selector); 404 | } 405 | 406 | displayUsers(users) { 407 | // Delete all nodes 408 | while (this.userList.firstChild) { 409 | this.userList.removeChild(this.userList.firstChild); 410 | } 411 | 412 | // Show default message 413 | if (users.length === 0) { 414 | const p = this.createElement('p'); 415 | p.textContent = 'Nothing to do! Add a user?'; 416 | this.userList.append(p); 417 | } else { 418 | // Create nodes 419 | users.forEach(user => { 420 | const li = this.createElement('li'); 421 | li.id = user.id; 422 | 423 | const checkbox = this.createElement('input'); 424 | checkbox.type = 'checkbox'; 425 | checkbox.checked = user.complete; 426 | 427 | const spanUser = this.createElement('span'); 428 | 429 | const spanAge = this.createElement('span'); 430 | spanAge.contentEditable = true; 431 | spanAge.classList.add('editable'); 432 | 433 | if (user.complete) { 434 | const strikeName = this.createElement('s'); 435 | strikeName.textContent = user.name; 436 | spanUser.append(strikeName); 437 | 438 | const strikeAge = this.createElement('s'); 439 | strikeAge.textContent = user.age; 440 | spanAge.append(strikeAge); 441 | } else { 442 | spanUser.textContent = user.name; 443 | spanAge.textContent = user.age; 444 | } 445 | 446 | const deleteButton = this.createElement('button', 'delete'); 447 | deleteButton.textContent = 'Delete'; 448 | li.append(checkbox, spanUser, spanAge, deleteButton); 449 | 450 | // Append nodes 451 | this.userList.append(li); 452 | }); 453 | } 454 | } 455 | 456 | _initLocalListeners() { 457 | this.userList.addEventListener('input', event => { 458 | if (event.target.className === 'editable') { 459 | this._temporaryAgeText = event.target.innerText; 460 | } 461 | }); 462 | } 463 | 464 | bindAddUser(handler) { 465 | this.form.addEventListener('submit', event => { 466 | event.preventDefault(); 467 | 468 | if (this._nameText) { 469 | handler({ 470 | name: this._nameText, 471 | age: this._ageText 472 | }); 473 | this._resetInput(); 474 | } 475 | }); 476 | } 477 | 478 | bindDeleteUser(handler) { 479 | this.userList.addEventListener('click', event => { 480 | if (event.target.className === 'delete') { 481 | const id = event.target.parentElement.id; 482 | 483 | handler(id); 484 | } 485 | }); 486 | } 487 | 488 | bindEditUser(handler) { 489 | this.userList.addEventListener('focusout', event => { 490 | if (this._temporaryAgeText) { 491 | const id = event.target.parentElement.id; 492 | const key = 'age'; 493 | 494 | handler(id, { [key]: this._temporaryAgeText }); 495 | this._temporaryAgeText = ''; 496 | } 497 | }); 498 | } 499 | 500 | bindToggleUser(handler) { 501 | this.userList.addEventListener('change', event => { 502 | if (event.target.type === 'checkbox') { 503 | const id = event.target.parentElement.id; 504 | 505 | handler(id); 506 | } 507 | }); 508 | } 509 | } 510 | ``` 511 | ---- 512 | # Controllers 513 | The last file of this architecture is the controller. The controller receives the two dependencies it has (service and view) by dependency injection (DI). Those dependencies are stored in the controller in private variables. In addition, the constructor makes the explicit connection between view and services since the controller is the only element that has access to both parties. 514 | 515 | The file ```user.controller.js``` is the one shown below: 516 | 517 | 518 | ```javascript 519 | /** 520 | * @class Controller 521 | * 522 | * Links the user input and the view output. 523 | * 524 | * @param model 525 | * @param view 526 | */ 527 | class UserController { 528 | constructor(userService, userView) { 529 | this.userService = userService; 530 | this.userView = userView; 531 | 532 | // Explicit this binding 533 | this.userService.bindUserListChanged(this.onUserListChanged); 534 | this.userView.bindAddUser(this.handleAddUser); 535 | this.userView.bindEditUser(this.handleEditUser); 536 | this.userView.bindDeleteUser(this.handleDeleteUser); 537 | this.userView.bindToggleUser(this.handleToggleUser); 538 | 539 | // Display initial users 540 | this.onUserListChanged(this.userService.users); 541 | } 542 | 543 | onUserListChanged = users => { 544 | this.userView.displayUsers(users); 545 | }; 546 | 547 | handleAddUser = user => { 548 | this.userService.add(user); 549 | }; 550 | 551 | handleEditUser = (id, user) => { 552 | this.userService.edit(id, user); 553 | }; 554 | 555 | handleDeleteUser = id => { 556 | this.userService.delete(id); 557 | }; 558 | 559 | handleToggleUser = id => { 560 | this.userService.toggle(id); 561 | }; 562 | } 563 | ``` 564 | 565 | ---- 566 | 567 | # App.js 568 | The last point of our application is the application launcher. In our case, we have called it ```app.js```. The application is executed through the creation of the different elements: ```UserService```, ```UserView``` and ```UserController```, as shown in the file ```app.js```. 569 | 570 | 571 | ```javascript 572 | const app = new UserController(new UserService(), new UserView()); 573 | ``` 574 | ---- 575 | # Conclusions 576 | 577 | In this first post, we have developed a Web application in which the project has been structured following the MVC architecture in which anemic models are used and the responsibility for the logic lies on the services. 578 | 579 | It is very important to highlight that the didactical of this post is to understand the structuring of the project in different files with different responsibilities and how the view is totally independent of the model/service and the controller. 580 | 581 | In the following article, we will reinforce JavaScript using TypeScript, which will give us a more powerful language to develop Web applications. The fact that we have used JavaScript has caused us to write a lot of verbose and repetitive code for the management of the DOM (this will be minimized using the Angular framework). 582 | 583 | --- 584 | The *GitHub branch* of this post is [https://github.com/Caballerog/VanillaJS-MVC-Users](https://github.com/Caballerog/VanillaJS-MVC-Users) 585 | 586 | 587 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const app = new UserController(new UserService(), new UserView()); 2 | -------------------------------------------------------------------------------- /controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Controller 3 | * 4 | * Links the user input and the view output. 5 | * 6 | * @param model 7 | * @param view 8 | */ 9 | class UserController { 10 | constructor(userService, userView) { 11 | this.userService = userService; 12 | this.userView = userView; 13 | 14 | // Explicit this binding 15 | this.userService.bindUserListChanged(this.onUserListChanged); 16 | this.userView.bindAddUser(this.handleAddUser); 17 | this.userView.bindEditUser(this.handleEditUser); 18 | this.userView.bindDeleteUser(this.handleDeleteUser); 19 | this.userView.bindToggleUser(this.handleToggleUser); 20 | 21 | // Display initial users 22 | this.onUserListChanged(this.userService.users); 23 | } 24 | 25 | onUserListChanged = users => { 26 | this.userView.displayUsers(users); 27 | }; 28 | 29 | handleAddUser = user => { 30 | this.userService.add(user); 31 | }; 32 | 33 | handleEditUser = (id, user) => { 34 | this.userService.edit(id, user); 35 | }; 36 | 37 | handleDeleteUser = id => { 38 | this.userService.delete(id); 39 | }; 40 | 41 | handleToggleUser = id => { 42 | this.userService.toggle(id); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | User App 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /models/user.model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Model 3 | * 4 | * Manages the data of the application. 5 | */ 6 | 7 | class User { 8 | constructor({ name, age, complete } = { complete: false }) { 9 | this.id = this.uuidv4(); 10 | this.name = name; 11 | this.age = age; 12 | this.complete = complete; 13 | } 14 | 15 | uuidv4() { 16 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => 17 | ( 18 | c ^ 19 | (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) 20 | ).toString(16) 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /services/user.service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Service 3 | * 4 | * Manages the data of the application. 5 | */ 6 | class UserService { 7 | constructor() { 8 | const users = JSON.parse(localStorage.getItem('users')) || []; 9 | this.users = users.map(user => new User(user)); 10 | } 11 | 12 | bindUserListChanged(callback) { 13 | this.onUserListChanged = callback; 14 | } 15 | 16 | _commit(users) { 17 | this.onUserListChanged(users); 18 | localStorage.setItem('users', JSON.stringify(users)); 19 | } 20 | 21 | add(user) { 22 | this.users.push(new User(user)); 23 | 24 | this._commit(this.users); 25 | } 26 | 27 | edit(id, userToEdit) { 28 | this.users = this.users.map(user => 29 | user.id === id 30 | ? new User({ 31 | ...user, 32 | ...userToEdit 33 | }) 34 | : user 35 | ); 36 | 37 | this._commit(this.users); 38 | } 39 | 40 | delete(_id) { 41 | this.users = this.users.filter(({ id }) => id !== _id); 42 | 43 | this._commit(this.users); 44 | } 45 | 46 | toggle(_id) { 47 | this.users = this.users.map(user => 48 | user.id === _id ? new User({ ...user, complete: !user.complete }) : user 49 | ); 50 | 51 | this._commit(this.users); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /views/user.view.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class View 3 | * 4 | * Visual representation of the model. 5 | */ 6 | class UserView { 7 | constructor() { 8 | this.app = this.getElement('#root'); 9 | 10 | this.form = this.createElement('form'); 11 | this.createInput({ 12 | key: 'inputName', 13 | type: 'text', 14 | placeholder: 'Name', 15 | name: 'name' 16 | }); 17 | this.createInput({ 18 | key: 'inputAge', 19 | type: 'text', 20 | placeholder: 'Age', 21 | name: 'age' 22 | }); 23 | 24 | this.submitButton = this.createElement('button'); 25 | this.submitButton.textContent = 'Submit'; 26 | 27 | this.form.append(this.inputName, this.inputAge, this.submitButton); 28 | 29 | this.title = this.createElement('h1'); 30 | this.title.textContent = 'Users'; 31 | this.userList = this.createElement('ul', 'user-list'); 32 | this.app.append(this.title, this.form, this.userList); 33 | 34 | this._temporaryAgeText = ''; 35 | this._initLocalListeners(); 36 | } 37 | 38 | get _nameText() { 39 | return this.inputName.value; 40 | } 41 | get _ageText() { 42 | return this.inputAge.value; 43 | } 44 | 45 | _resetInput() { 46 | this.inputName.value = ''; 47 | this.inputAge.value = ''; 48 | } 49 | 50 | createInput( 51 | { key, type, placeholder, name } = { 52 | key: 'default', 53 | type: 'text', 54 | placeholder: 'default', 55 | name: 'default' 56 | } 57 | ) { 58 | this[key] = this.createElement('input'); 59 | this[key].type = type; 60 | this[key].placeholder = placeholder; 61 | this[key].name = name; 62 | } 63 | 64 | createElement(tag, className) { 65 | const element = document.createElement(tag); 66 | 67 | if (className) element.classList.add(className); 68 | 69 | return element; 70 | } 71 | 72 | getElement(selector) { 73 | return document.querySelector(selector); 74 | } 75 | 76 | displayUsers(users) { 77 | // Delete all nodes 78 | while (this.userList.firstChild) { 79 | this.userList.removeChild(this.userList.firstChild); 80 | } 81 | 82 | // Show default message 83 | if (users.length === 0) { 84 | const p = this.createElement('p'); 85 | p.textContent = 'Nothing to do! Add a user?'; 86 | this.userList.append(p); 87 | } else { 88 | // Create nodes 89 | users.forEach(user => { 90 | const li = this.createElement('li'); 91 | li.id = user.id; 92 | 93 | const checkbox = this.createElement('input'); 94 | checkbox.type = 'checkbox'; 95 | checkbox.checked = user.complete; 96 | 97 | const spanUser = this.createElement('span'); 98 | 99 | const spanAge = this.createElement('span'); 100 | spanAge.contentEditable = true; 101 | spanAge.classList.add('editable'); 102 | 103 | if (user.complete) { 104 | const strikeName = this.createElement('s'); 105 | strikeName.textContent = user.name; 106 | spanUser.append(strikeName); 107 | 108 | const strikeAge = this.createElement('s'); 109 | strikeAge.textContent = user.age; 110 | spanAge.append(strikeAge); 111 | } else { 112 | spanUser.textContent = user.name; 113 | spanAge.textContent = user.age; 114 | } 115 | 116 | const deleteButton = this.createElement('button', 'delete'); 117 | deleteButton.textContent = 'Delete'; 118 | li.append(checkbox, spanUser, spanAge, deleteButton); 119 | 120 | // Append nodes 121 | this.userList.append(li); 122 | }); 123 | } 124 | } 125 | 126 | _initLocalListeners() { 127 | this.userList.addEventListener('input', event => { 128 | if (event.target.className === 'editable') { 129 | this._temporaryAgeText = event.target.innerText; 130 | } 131 | }); 132 | } 133 | 134 | bindAddUser(handler) { 135 | this.form.addEventListener('submit', event => { 136 | event.preventDefault(); 137 | 138 | if (this._nameText) { 139 | handler({ 140 | name: this._nameText, 141 | age: this._ageText 142 | }); 143 | this._resetInput(); 144 | } 145 | }); 146 | } 147 | 148 | bindDeleteUser(handler) { 149 | this.userList.addEventListener('click', event => { 150 | if (event.target.className === 'delete') { 151 | const id = event.target.parentElement.id; 152 | 153 | handler(id); 154 | } 155 | }); 156 | } 157 | 158 | bindEditUser(handler) { 159 | this.userList.addEventListener('focusout', event => { 160 | if (this._temporaryAgeText) { 161 | const id = event.target.parentElement.id; 162 | const key = 'age'; 163 | 164 | handler(id, { [key]: this._temporaryAgeText }); 165 | this._temporaryAgeText = ''; 166 | } 167 | }); 168 | } 169 | 170 | bindToggleUser(handler) { 171 | this.userList.addEventListener('change', event => { 172 | if (event.target.type === 'checkbox') { 173 | const id = event.target.parentElement.id; 174 | 175 | handler(id); 176 | } 177 | }); 178 | } 179 | } 180 | --------------------------------------------------------------------------------