/** * @file Todo application with a tiny MVC Framework. * @author Felix Yang */ /* eslint-disable-next-line */ jQuery(function($) { 'use strict' var Global = window || {} var Router = Global.Router || {} var Handlebars = Global.Handlebars || {} var KEYBOARD = { ENTER_KEY: 13, ESCAPE_KEY: 27 } /** * Create application instance with tiny MVC. * @param {object} options * @returns {object} */ var createMvcApp = (function () { var App = { version: '1.0.0' } var util = { pluralize: function (count, word) { return count === 1 ? word : word + 's' } } /** * Create a MVC Model. * @class * @param {object} options */ App.Model = function mvcModel (options) { this.options = options || {} this.init() } App.Model.prototype = { constructor: App.Model, init: function () { this.data = this.options.data || {} }, getTodos: function () { return this.data.todos }, setTodos: function (todos) { this.data.todos = todos }, getFilter: function () { return this.data.filter }, setFilter: function (filter) { this.data.filter = filter }, addTodo: function (todo) { todo = $.extend( { id: this.genUuid() }, todo ) this.data.todos.push(todo) }, getFilteredTodos: function () { var filter = this.getFilter() if (filter === 'active') { return this.getActiveTodos() } if (filter === 'completed') { return this.getCompletedTodos() } return this.data.todos }, getActiveTodos: function () { return this.data.todos.filter(function (todo) { return !todo.completed }) }, getCompletedTodos: function () { return this.data.todos.filter(function (todo) { return todo.completed }) }, getTodoById: function (id) { return this.data.todos.find(function (todo) { return todo.id === id }) }, updateTodo: function (id, title) { var todo = this.getTodoById(id) if (todo) { todo.title = title } }, toggle: function (id) { var todo = this.getTodoById(id) if (todo) { todo.completed = !todo.completed } }, toggleAll: function (completed) { this.data.todos.forEach(function (todo) { todo.completed = completed }) }, destroy: function (id) { this.data.todos = this.data.todos.filter(function (todo) { return todo.id !== id }) }, destroyCompleted: function () { this.setTodos(this.getActiveTodos()) }, genUuid: function () { var i, random var uuid = '' for (i = 0; i < 32; i++) { random = (Math.random() * 16) | 0 if (i === 8 || i === 12 || i === 16 || i === 20) { uuid += '-' } uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random ).toString(16) } return uuid } } /** * Create a MVC View. * @class * @param {object} options */ App.View = function mvcView (options) { this.options = options || {} this.init() } App.View.prototype = { constructor: App.View, init: function () { this.$el = $(this.options.el) this.todoTemplate = this.compile('#todo-template') this.footerTemplate = this.compile('#footer-template') this.bindEvents() }, on: function (event, selector, fn) { if (arguments.length > 2) { this.$el.on(event, selector, fn) } else { fn = selector this.$el.on(event, fn) } return this }, find: function (selector) { return this.$el.find(selector) }, compile: function (selector) { return Handlebars.compile($(selector).html()) }, render: function () { var self = this var model = self.controller.getModel() var todos = model.getFilteredTodos() self.find('.todo-list').html(self.todoTemplate(todos)) self.find('.main').toggle(todos.length > 0) self .find('.toggle-all') .prop('checked', model.getActiveTodos().length === 0) self.find('.new-todo').focus() self.renderFooter() }, renderFooter: function () { var self = this var model = self.controller.getModel() var todoCount = model.getTodos().length var activeTodoCount = model.getActiveTodos().length var template = self.footerTemplate({ activeTodoCount: activeTodoCount, activeTodoWord: util.pluralize(activeTodoCount, 'item'), completedTodos: todoCount - activeTodoCount, filter: model.getFilter() }) self .find('.footer') .toggle(todoCount > 0) .html(template) }, bindEvents: function () { var self = this self.on('keyup', '.new-todo', this.create.bind(this)) self.on('change', '.toggle-all', this.toggleAll.bind(this)) self.on('click', '.clear-completed', this.destroyCompleted.bind(this)) self .on('change', '.toggle', this.toggle.bind(this)) .on('dblclick', '.title', this.editTodo.bind(this)) .on('keyup', '.edit', this.editKeyup.bind(this)) .on('focusout', '.edit', this.update.bind(this)) .on('click', '.destroy', this.destroy.bind(this)) }, toggleAll: function (e) { var self = this var completed = $(e.target).prop('checked') self.controller.getModel().toggleAll(completed) self.render() }, destroyCompleted: function () { var self = this self.controller.getModel().destroyCompleted() self.render() }, create: function (e) { var self = this var $element = $(e.target) var todoTask = $element.val().trim() if (e.which !== KEYBOARD.ENTER_KEY || !todoTask) { return } self.controller.getModel().addTodo({ title: todoTask, completed: false }) $element.val('') self.render() }, toggle: function (e) { var self = this var $el = $(e.target) var id = $el.closest('li').data('id') self.controller.getModel().toggle(id) self.render() }, editTodo: function (e) { var $input = $(e.target) .closest('li') .addClass('editing') .find('.edit') var tempValue = $input.val() $input .val('') .val(tempValue) .focus() }, editKeyup: function (e) { if (e.which === KEYBOARD.ENTER_KEY) { e.target.blur() } if (e.which === KEYBOARD.ESCAPE_KEY) { $(e.target) .data('abort', true) .blur() } }, update: function (e) { var self = this var model = self.controller.getModel() var $el = $(e.target) var id = $el.closest('li').data('id') var todoTask = $el.val().trim() if ($el.data('abort')) { $el.data('abort', false) } else if (!todoTask) { model.destroy(id) return } else { model.updateTodo(id, todoTask) } self.render() }, destroy: function (e) { var self = this var $el = $(e.target) var id = $el.closest('li').data('id') self.controller.getModel().destroy(id) self.render() } } /** * Create a MVC Controller. * @class * @param {object} options */ App.Controller = function mvcController (options) { this.options = options || {} this.view = this.options.view this.model = this.options.model this.init(options) } App.Controller.prototype = { constructor: App.Controller, init: function () { this.view.controller = this.model.controller = this this.router = new Router({ '/:filter': function (filter) { this.model.setFilter(filter) this.view.render() }.bind(this) }).init('/all') }, getView: function () { return this.view }, getModel: function () { return this.model } } return function (options) { var app = {} app.view = new App.View(options) app.model = new App.Model(options) app.controller = new App.Controller( $.extend( { view: app.view, model: app.model }, options ) ) return app } })() Handlebars.registerHelper('eq', function (a, b, options) { return a === b ? options.fn(this) : options.inverse(this) }) // run application createMvcApp({ el: '.todoapp', data: { todos: [ { id: 'todo-001', title: 'Do Some X' }, { id: 'todo-002', title: 'Do Some Y', completed: true }, { id: 'todo-003', title: 'Do Some Z' } ] } }) })
<!DOCTYPE html> <html lang="en" data-framework="jquery"> <head> <meta charset="utf-8" /> <title>Formotor • TodoMVC</title> <link rel="stylesheet" href="css/app.css" /> </head> <body> <section class="todoapp"> <header class="header"> <h1>todos</h1> <input class="new-todo" placeholder="What needs to be done?" autofocus /> </header> <section class="main"> <input id="toggle-all" class="toggle-all" type="checkbox" /> <label for="toggle-all">Mark all as complete</label> <ul class="todo-list"></ul> </section> <footer class="footer"></footer> </section> <footer class="info"> <p>Double-click to edit a todo</p> <p>Created by <a href="https://felixpy.com">Felix Yang</a></p> <p>Part of <a href="https://felixpy.github.io/formotor">Formotor</a></p> </footer> <script id="todo-template" type="text/x-handlebars-template"> {{#this}} <li {{#if completed}}class="completed"{{/if}} data-id="{{id}}"> <div class="view"> <input class="toggle" type="checkbox" {{#if completed}}checked{{/if}}> <label class="title">{{title}}</label> <button class="destroy"></button> </div> <input class="edit" value="{{title}}"> </li> {{/this}} </script> <script id="footer-template" type="text/x-handlebars-template"> <span class="todo-count"><strong>{{activeTodoCount}}</strong> {{activeTodoWord}} left</span> <ul class="filters"> <li> <a {{#eq filter 'all'}}class="selected"{{/eq}} href="#/all">All</a> </li> <li> <a {{#eq filter 'active'}}class="selected"{{/eq}}href="#/active">Active</a> </li> <li> <a {{#eq filter 'completed'}}class="selected"{{/eq}}href="#/completed">Completed</a> </li> </ul> {{#if completedTodos}}<button class="clear-completed">Clear completed</button>{{/if}} </script> <script src="//cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script> <script src="//cdn.bootcss.com/handlebars.js/4.0.12/handlebars.min.js"></script> <script src="//cdn.bootcss.com/Director/1.2.8/director.min.js"></script> <script src="//unpkg.com/formotor"></script> </body> </html>