// src/controller.js 'use strict'; class Controller { /** * Take a model & view, then act as controller between them * @param {object} model The model instance * @param {object} view The view instance */ constructor(model, view) { this.model = model; this.view = view; this.view.bind('newTodo', title => this.addItem(title)); this.view.bind('itemEdit', item => this.editItem(item.id)); this.view.bind('itemEditDone', item => this.editItemSave(item.id, item.title)); this.view.bind('itemEditCancel', item => this.editItemCancel(item.id)); this.view.bind('itemRemove', item => this.removeItem(item.id)); this.view.bind('itemToggle', item => this.toggleComplete(item.id, item.completed)); this.view.bind('removeCompleted', () => this.removeCompletedItems()); this.view.bind('toggleAll', status => this.toggleAll(status.completed)); } /** * Load & Initialize the view * @param {string} '' | 'active' | 'completed' */ setView(hash){ let route = hash.split('/')[1]; let page = route || ''; this._updateFilter(page); } /** * Event fires on load. Gets all items & displays them */ showAll(){ this.model.read(data => this.view.render('showEntries', data)); } /** * Renders all active tasks */ showActive(){ this.model.read({completed: false}, data => this.view.render('showEntries', data)); } /** * Renders all completed tasks */ showCompleted(){ this.model.read({completed: true}, data => this.view.render('showEntries', data)); } /** * An event to fire whenever you want to add an item. Simply pass in the event * object and it'll handle the DOM insertion and saving of the new item. */ addItem(title){ if (title.trim() === '') { return; } this.model.create(title, () => { this.view.render('clearNewTodo'); this._filter(true); }); } /* * Triggers the item editing mode. */ editItem(id){ this.model.read(id, data => { let title = data[0].title; this.view.render('editItem', {id, title}); }); } /* * Finishes the item editing mode successfully. */ editItemSave(id, title){ title = title.trim(); if (title.length !== 0) { this.model.update(id, {title}, () => { this.view.render('editItemDone', {id, title}); }); } else { this.removeItem(id); } } /* * Cancels the item editing mode. */ editItemCancel(id){ this.model.read(id, data => { let title = data[0].title; this.view.render('editItemDone', {id, title}); }); } /** * Find the DOM element with given ID, * Then remove it from DOM & Storage */ removeItem(id){ this.model.remove(id, () => this.view.render('removeItem', id)); this._filter(); } /** * Will remove all completed items from the DOM and storage. */ removeCompletedItems(){ this.model.read({completed: true}, data => { for (let item of data) { this.removeItem(item.id); } }); this._filter(); } /** * Give it an ID of a model and a checkbox and it will update the item * in storage based on the checkbox's state. * * @param {number} id The ID of the element to complete or uncomplete * @param {object} checkbox The checkbox to check the state of complete * or not * @param {boolean|undefined} silent Prevent re-filtering the todo items */ toggleComplete(id, completed, silent){ this.model.update(id, {completed}, () => { this.view.render('elementComplete', {id, completed}); }); if (!silent) { this._filter(); } } /** * Will toggle ALL checkboxes' on/off state and completeness of models. * Just pass in the event object. */ toggleAll(completed){ this.model.read({completed: !completed}, data => { for (let item of data) { this.toggleComplete(item.id, completed, true); } }); this._filter(); } /** * Updates the pieces of the page which change depending on the remaining * number of todos. */ _updateCount(){ this.model.getCount(todos => { const completed = todos.completed; const visible = completed > 0; const checked = completed === todos.total; this.view.render('updateElementCount', todos.active); this.view.render('clearCompletedButton', {completed, visible}); this.view.render('toggleAll', {checked}); this.view.render('contentBlockVisibility', {visible: todos.total > 0}); }); } /** * Re-filters the todo items, based on the active route. * @param {boolean|undefined} force forces a re-painting of todo items. */ _filter(force){ let active = this._activeRoute; const activeRoute = active.charAt(0).toUpperCase() + active.substr(1); // Update the elements on the page, which change with each completed todo this._updateCount(); // If the last active route isn't "All", or we're switching routes, we // re-create the todo item elements, calling: // this.show[All|Active|Completed]() if (force || this._lastActiveRoute !== 'All' || this._lastActiveRoute !== activeRoute) { this['show' + activeRoute](); } this._lastActiveRoute = activeRoute; } /** * Simply updates the filter nav's selected states */ _updateFilter(currentPage){ // Store a reference to the active route, allowing us to re-filter todo // items as they are marked complete or incomplete. this._activeRoute = currentPage; if (currentPage === '') { this._activeRoute = 'All'; } this._filter(); this.view.render('setFilter', currentPage); } } // src/helpers.js 'use strict'; // Allow for looping on nodes by chaining: // qsa('.foo').forEach(function () {}) NodeList.prototype.forEach = Array.prototype.forEach; // Get element(s) by CSS selector: function qs(selector, scope) { return (scope || document).querySelector(selector); } function qsa(selector, scope) { return (scope || document).querySelectorAll(selector); } // addEventListener wrapper: function $on(target, type, callback, useCapture) { target.addEventListener(type, callback, !!useCapture); } // Attach a handler to event for all elements that match the selector, // now or in the future, based on a root element function $delegate(target, selector, type, handler) { let dispatchEvent = event => { const targetElement = event.target; const potentialElements = qsa(selector, target); const hasMatch = Array.prototype.indexOf.call(potentialElements, targetElement) >= 0; if (hasMatch) { handler.call(targetElement, event); } }; // https://developer.mozilla.org/en-US/docs/Web/Events/blur const useCapture = type === 'blur' || type === 'focus'; $on(target, type, dispatchEvent, useCapture); } // Find the element's parent with the given tag name: // $parent(qs('a'), 'div') function $parent(element, tagName) { if (!element.parentNode) { return; } if (element.parentNode.tagName.toLowerCase() === tagName.toLowerCase()) { return element.parentNode; } return $parent(element.parentNode, tagName); } // src/template.js 'use strict'; const htmlEscapes = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''', '`': '`' }; const reUnescapedHtml = /[&<>"'`]/g; const reHasUnescapedHtml = new RegExp(reUnescapedHtml.source); let escape = str => (str && reHasUnescapedHtml.test(str)) ? str.replace(reUnescapedHtml, escapeHtmlChar) : str; let escapeHtmlChar = chr => htmlEscapes[chr]; class Template { constructor() { this.defaultTemplate = `
  • `; } /** * Creates an
  • HTML string and returns it for placement in your app. * * NOTE: In real life you should be using a templating engine such as Mustache * or Handlebars, however, this is a vanilla JS example. * * @param {object} data The object containing keys you want to find in the * template to replace. * @returns {string} HTML String of an
  • element * * @example * view.show({ * id: 1, * title: "Hello World", * completed: 0, * }) */ show(data){ let i = 0; let view = ''; const len = data.length; for (i; i < len; i++) { let completed = ''; let checked = ''; let template = this.defaultTemplate; if (data[i].completed) { completed = 'completed'; checked = 'checked'; } template = template.replace('{{id}}', data[i].id); template = template.replace('{{title}}', escape(data[i].title)); template = template.replace('{{completed}}', completed); template = template.replace('{{checked}}', checked); view += template; } return view; } /** * Displays a counter of how many to dos are left to complete * * @param {number} activeTodos The number of active todos. * @returns {string} String containing the count */ itemCounter(activeTodos){ let plural = activeTodos === 1 ? '' : 's'; return `${activeTodos} item${plural} left`; } /** * Updates the text within the "Clear completed" button * * @param {[type]} completedTodos The number of completed todos. * @returns {string} String containing the count */ clearCompletedButton(completedTodos){ return (completedTodos > 0) ? 'Clear completed' : ''; } } // src/store.js /*jshint eqeqeq:false */ 'use strict'; /** * Creates a new client side storage object and will create an empty * collection if no collection already exists. * * @param {string} name The name of our DB we want to use * @param {function} callback Our fake DB uses callbacks because in * real life you probably would be making AJAX calls */ class Store { constructor(name, callback) { this._dbName = name; this.memoryStorage = this.memoryStorage || {}; if (!this.memoryStorage[name]) { let data = { todos: [] }; this.memoryStorage[name] = JSON.stringify(data); } if (callback) { callback.call(this, JSON.parse(this.memoryStorage[name])); } } /** * Finds items based on a query given as a JS object * * @param {object} query The query to match against (i.e. {foo: 'bar'}) * @param {function} callback The callback to fire when the query has * completed running * * @example * db.find({foo: 'bar', hello: 'world'}, function (data) { * // data will return any items that have foo: bar and * // hello: world in their properties * }) */ find(query, callback){ if (!callback) { return; } let todos = JSON.parse(this.memoryStorage[this._dbName]).todos; callback.call(this, todos.filter(todo => { for (let q in query) { if (query[q] !== todo[q]) { return false; } } return true; })); } /** * Will retrieve all data from the collection * * @param {function} callback The callback to fire upon retrieving data */ findAll(callback){ if (callback) { callback.call(this, JSON.parse(this.memoryStorage[this._dbName]).todos); } } /** * Will save the given data to the DB. If no item exists it will create a new * item, otherwise it'll simply update an existing item's properties * * @param {object} updateData The data to save back into the DB * @param {function} callback The callback to fire after saving * @param {number} id An optional param to enter an ID of an item to update */ save(updateData, callback, id){ const data = JSON.parse(this.memoryStorage[this._dbName]); let todos = data.todos; const len = todos.length; // If an ID was actually given, find the item and update each property if (id) { for (let i = 0; i < len; i++) { if (todos[i].id === id) { for (let key in updateData) { todos[i][key] = updateData[key]; } break; } } this.memoryStorage[this._dbName] = JSON.stringify(data); if (callback) { callback.call(this, JSON.parse(this.memoryStorage[this._dbName]).todos); } } else { // Generate an ID updateData.id = new Date().getTime(); todos.push(updateData); this.memoryStorage[this._dbName] = JSON.stringify(data); if (callback) { callback.call(this, [updateData]); } } } /** * Will remove an item from the Store based on its ID * * @param {number} id The ID of the item you want to remove * @param {function} callback The callback to fire after saving */ remove(id, callback){ const data = JSON.parse(this.memoryStorage[this._dbName]); let todos = data.todos; const len = todos.length; for (let i = 0; i < todos.length; i++) { if (todos[i].id == id) { todos.splice(i, 1); break; } } this.memoryStorage[this._dbName] = JSON.stringify(data); if (callback) { callback.call(this, JSON.parse(this.memoryStorage[this._dbName]).todos); } } /** * Will drop all storage and start fresh * * @param {function} callback The callback to fire after dropping the data */ drop(callback){ this.memoryStorage[this._dbName] = JSON.stringify({todos: []}); if (callback) { callback.call(this, JSON.parse(this.memoryStorage[this._dbName]).todos); } } } // src/model.js 'use strict'; /** * Creates a new Model instance and hooks up the storage. * @constructor * @param {object} storage A reference to the client side storage class */ class Model { constructor(storage) { this.storage = storage; } /** * Creates a new todo model * * @param {string} [title] The title of the task * @param {function} [callback] The callback to fire after the model is created */ create(title, callback){ title = title || ''; let newItem = { title: title.trim(), completed: false }; this.storage.save(newItem, callback); } /** * Finds and returns a model in storage. If no query is given it'll simply * return everything. If you pass in a string or number it'll look that up as * the ID of the model to find. Lastly, you can pass it an object to match * against. * * @param {string|number|object} [query] A query to match models against * @param {function} [callback] The callback to fire after the model is found * * @example * model.read(1, func) // Will find the model with an ID of 1 * model.read('1') // Same as above * //Below will find a model with foo equalling bar and hello equalling world. * model.read({ foo: 'bar', hello: 'world' }) */ read(query, callback){ const queryType = typeof query; if (queryType === 'function') { callback = query; this.storage.findAll(callback); } else if (queryType === 'string' || queryType === 'number') { query = parseInt(query, 10); this.storage.find({id: query}, callback); } else { this.storage.find(query, callback); } } /** * Updates a model by giving it an ID, data to update, and a callback to fire when * the update is complete. * * @param {number} id The id of the model to update * @param {object} data The properties to update and their new value * @param {function} callback The callback to fire when the update is complete. */ update(id, data, callback){ this.storage.save(data, callback, id); } /** * Removes a model from storage * * @param {number} id The ID of the model to remove * @param {function} callback The callback to fire when the removal is complete. */ remove(id, callback){ this.storage.remove(id, callback); } /** * WARNING: Will remove ALL data from storage. * * @param {function} callback The callback to fire when the storage is wiped. */ removeAll(callback){ this.storage.drop(callback); } /** * Returns a count of all todos */ getCount(callback){ let todos = { active: 0, completed: 0, total: 0 }; this.storage.findAll(data => { for (let todo of data) { if (todo.completed) { todos.completed++; } else { todos.active++; } todos.total++; } if (callback) { callback(todos); } }); } } // src/view.js 'use strict'; // import {qs, qsa, $on, $parent, $delegate} from './helpers'; let _itemId = element => parseInt($parent(element, 'li').dataset.id, 10); let _setFilter = currentPage => { qs('.filters .selected').className = ''; qs(`.filters [href="#/${currentPage}"]`).className = 'selected'; }; let _elementComplete = (id, completed) => { let listItem = qs(`[data-id="${id}"]`); if (!listItem) { return; } listItem.className = completed ? 'completed' : ''; // In case it was toggled from an event and not by clicking the checkbox qs('input', listItem).checked = completed; }; let _editItem = (id, title) => { let listItem = qs(`[data-id="${id}"]`); if (!listItem) { return; } listItem.className += ' editing'; let input = document.createElement('input'); input.className = 'edit'; listItem.appendChild(input); input.focus(); input.value = title; }; /** * View that abstracts away the browser's DOM completely. * It has two simple entry points: * * - bind(eventName, handler) * Takes a todo application event and registers the handler * - render(command, parameterObject) * Renders the given command with the options */ class View { constructor(template) { this.template = template; this.ENTER_KEY = 13; this.ESCAPE_KEY = 27; this.$todoList = qs('.todo-list'); this.$todoItemCounter = qs('.todo-count'); this.$clearCompleted = qs('.clear-completed'); this.$main = qs('.main'); this.$footer = qs('.footer'); this.$toggleAll = qs('.toggle-all'); this.$newTodo = qs('.new-todo'); this.viewCommands = { showEntries: parameter => this.$todoList.innerHTML = this.template.show(parameter), removeItem: parameter => this._removeItem(parameter), updateElementCount: parameter => this.$todoItemCounter.innerHTML = this.template.itemCounter(parameter), clearCompletedButton: parameter => this._clearCompletedButton(parameter.completed, parameter.visible), contentBlockVisibility: parameter => this.$main.style.display = this.$footer.style.display = parameter.visible ? 'block' : 'none', toggleAll: parameter => this.$toggleAll.checked = parameter.checked, setFilter: parameter => _setFilter(parameter), clearNewTodo: parameter => this.$newTodo.value = '', elementComplete: parameter => _elementComplete(parameter.id, parameter.completed), editItem: parameter => _editItem(parameter.id, parameter.title), editItemDone: parameter => this._editItemDone(parameter.id, parameter.title), }; } _removeItem(id) { let elem = qs(`[data-id="${id}"]`); if (elem) { this.$todoList.removeChild(elem); } } _clearCompletedButton(completedCount, visible) { this.$clearCompleted.innerHTML = this.template.clearCompletedButton(completedCount); this.$clearCompleted.style.display = visible ? 'block' : 'none'; } _editItemDone(id, title) { let listItem = qs(`[data-id="${id}"]`); if (!listItem) { return; } let input = qs('input.edit', listItem); listItem.removeChild(input); listItem.className = listItem.className.replace(' editing', ''); qsa('label', listItem).forEach(label => label.textContent = title); } render(viewCmd, parameter) { this.viewCommands[viewCmd](parameter); } _bindItemEditDone(handler) { let self = this; $delegate(self.$todoList, 'li .edit', 'blur', function () { if (!this.dataset.iscanceled) { handler({ id: _itemId(this), title: this.value }); } }); // Remove the cursor from the input when you hit enter just like if it were a real form $delegate(self.$todoList, 'li .edit', 'keypress', function (event) { if (event.keyCode === self.ENTER_KEY) { this.blur(); } }); } _bindItemEditCancel(handler) { let self = this; $delegate(self.$todoList, 'li .edit', 'keyup', function (event) { if (event.keyCode === self.ESCAPE_KEY) { let id = _itemId(this); this.dataset.iscanceled = true; this.blur(); handler({ id }); } }); } bind(event, handler) { if (event === 'newTodo') { $on(this.$newTodo, 'change', () => handler(this.$newTodo.value)); } else if (event === 'removeCompleted') { $on(this.$clearCompleted, 'click', handler); } else if (event === 'toggleAll') { $on(this.$toggleAll, 'click', function(){ handler({completed: this.checked}); }); } else if (event === 'itemEdit') { $delegate(this.$todoList, 'li label', 'dblclick', function(){ handler({id: _itemId(this)}); }); } else if (event === 'itemRemove') { $delegate(this.$todoList, '.destroy', 'click', function(){ handler({id: _itemId(this)}); }); } else if (event === 'itemToggle') { $delegate(this.$todoList, '.toggle', 'click', function(){ handler({ id: _itemId(this), completed: this.checked }); }); } else if (event === 'itemEditDone') { this._bindItemEditDone(handler); } else if (event === 'itemEditCancel') { this._bindItemEditCancel(handler); } } } // src/app.js 'use strict'; let todo; const setView = () => todo.controller.setView(document.location.hash); class Todo { /** * Init new Todo List * @param {string} The name of your list */ constructor(name) { this.storage = new Store(name); this.model = new Model(this.storage); this.template = new Template(); this.view = new View(this.template); this.controller = new Controller(this.model, this.view); } } $on(window, 'load', () => { todo = new Todo('todos-vanillajs'); setView(); }); $on(window, 'hashchange', setView);