├── .github ├── dependabot.yml └── workflows │ ├── Semgrep.yml │ └── test.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── cypress.config.js ├── cypress ├── e2e │ └── todo.cy.js ├── plugins │ └── index.js └── support │ └── e2e.js ├── dependencies.yml ├── index.html ├── js ├── app.js ├── controller.js ├── helpers.js ├── model.js ├── store.js ├── template.js └── view.js ├── package-lock.json └── package.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "03:00" 8 | timezone: US/Central 9 | ignore: 10 | - dependency-name: cypress 11 | versions: 12 | - 6.5.0 13 | -------------------------------------------------------------------------------- /.github/workflows/Semgrep.yml: -------------------------------------------------------------------------------- 1 | # Name of this GitHub Actions workflow. 2 | name: Semgrep 3 | 4 | on: 5 | # Scan changed files in PRs (diff-aware scanning): 6 | # The branches below must be a subset of the branches above 7 | pull_request: 8 | branches: ["master", "main"] 9 | push: 10 | branches: ["master", "main"] 11 | schedule: 12 | - cron: '0 6 * * *' 13 | 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | semgrep: 20 | # User definable name of this GitHub Actions job. 21 | permissions: 22 | contents: read # for actions/checkout to fetch code 23 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 24 | name: semgrep/ci 25 | # If you are self-hosting, change the following `runs-on` value: 26 | runs-on: ubuntu-latest 27 | 28 | container: 29 | # A Docker image with Semgrep installed. Do not change this. 30 | image: returntocorp/semgrep 31 | 32 | # Skip any PR created by dependabot to avoid permission issues: 33 | if: (github.actor != 'dependabot[bot]') 34 | 35 | steps: 36 | # Fetch project source with GitHub Actions Checkout. 37 | - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 38 | # Run the "semgrep ci" command on the command line of the docker image. 39 | - run: semgrep ci --sarif --output=semgrep.sarif 40 | env: 41 | # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. 42 | SEMGREP_RULES: p/default # more at semgrep.dev/explore 43 | 44 | - name: Upload SARIF file for GitHub Advanced Security Dashboard 45 | uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 46 | with: 47 | sarif_file: semgrep.sarif 48 | if: always() -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v1 9 | with: 10 | node-version: 14 11 | - uses: actions/cache@v2 12 | with: 13 | path: ~/.npm 14 | key: v1/${{ runner.os }}/node-14/${{ hashFiles('**/package-lock.json') }} 15 | restore-keys: v1/${{ runner.os }}/node-14/ 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Run tests 19 | uses: percy/exec-action@v0.3.1 20 | with: 21 | custom-command: "npm test" 22 | env: 23 | PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} 24 | 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitignore for detailed pattern documentation. 2 | 3 | # Dependencies 4 | /node_modules 5 | 6 | # Cypress test run output 7 | /cypress/videos 8 | /cypress/screenshots 9 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @percy/percy-product-reviewers 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Perceptual Inc. 4 | 5 | Modified from https://github.com/tastejs/todomvc, Copyright (c) Addy Osmani, Sindre Sorhus, Pascal Hartig, Stephen Sawchuk. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # example-percy-cypress 2 | 3 | Example app demonstrating Percy's Cypress integration, used in [Percy's Cypress tutorial](https://docs.percy.io/docs/cypress-tutorial). 4 | 5 | Based on the [TodoMVC](https://github.com/tastejs/todomvc) [VanillaJS](https://github.com/tastejs/todomvc/tree/master/examples/vanillajs) 6 | app, forked at commit 7 | [4e301c7014093505dcf6678c8f97a5e8dee2d250](https://github.com/tastejs/todomvc/tree/4e301c7014093505dcf6678c8f97a5e8dee2d250). 8 | 9 | ## Cypress Tutorial 10 | 11 | The tutorial assumes you're already familiar with JavaScript and 12 | [Cypress](https://cypress.io/) and focuses on using it with Percy. You'll still 13 | be able to follow along if you're not familiar with Cypress, but we won't 14 | spend time introducing Cypress concepts. 15 | 16 | The tutorial also assumes you have [Node 12+ with 17 | npm](https://nodejs.org/en/download/) and 18 | [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed. 19 | 20 | ### Step 1 21 | 22 | Clone the example application and install dependencies: 23 | 24 | ```bash 25 | $ git clone https://github.com/percy/example-percy-cypress.git 26 | $ cd example-percy-cypress 27 | $ npm install 28 | ``` 29 | 30 | The example app and its tests will now be ready to go. You can explore the app 31 | by opening the 32 | [`index.html`](https://github.com/percy/example-percy-cypress/blob/master/index.html) 33 | file in a browser. 34 | 35 | ### Step 2 36 | 37 | Sign in to Percy and create a new project. You can name the project "todo" if you'd like. After 38 | you've created the project, you'll be shown a token environment variable. 39 | 40 | ### Step 3 41 | 42 | In the shell window you're working in, export the token environment variable: 43 | 44 | **Unix** 45 | 46 | ``` shell 47 | $ export PERCY_TOKEN="" 48 | ``` 49 | 50 | **Windows** 51 | 52 | ``` shell 53 | $ set PERCY_TOKEN="" 54 | 55 | # PowerShell 56 | $ $Env:PERCY_TOKEN="" 57 | ``` 58 | 59 | Note: Usually this would only be set up in your CI environment, but to keep things simple we'll 60 | configure it in your shell so that Percy is enabled in your local environment. 61 | 62 | ### Step 4 63 | 64 | Check out a new branch for your work in this tutorial (we'll call this branch 65 | `tutorial-example`), then run tests & take snapshots: 66 | 67 | ``` shell 68 | $ git checkout -b tutorial-example 69 | $ npm run test 70 | ``` 71 | 72 | This will run the app's Cypress tests, which contain calls to create Percy snapshots. The snapshots 73 | will then be uploaded to Percy for comparison. Percy will use the Percy token you used in **Step 2** 74 | to know which organization and project to upload the snapshots to. 75 | 76 | You can view the screenshots in Percy now if you want, but there will be no visual comparisons 77 | yet. You'll see that Percy shows you that these snapshots come from your `tutorial-example` branch. 78 | 79 | ### Step 5 80 | 81 | Use your text editor to edit `index.html` and introduce some visual changes. For example, you can 82 | add inline CSS to bold the "Clear completed" button on line 32. After the change, that line looks 83 | like this: 84 | 85 | ``` html 86 | 87 | ``` 88 | 89 | ### Step 6 90 | 91 | Commit the change: 92 | 93 | ``` shell 94 | $ git commit -am "Emphasize 'Clear completed' button" 95 | ``` 96 | 97 | ### Step 7 98 | 99 | Run the tests with snapshots again: 100 | 101 | ``` shell 102 | $ npm run test 103 | ``` 104 | 105 | This will run the tests again and take new snapshots of our modified application. The new snapshots 106 | will be uploaded to Percy and compared with the previous snapshots, showing any visual diffs. 107 | 108 | At the end of the test run output, you will see logs from Percy confirming that the snapshots were 109 | successfully uploaded and giving you a direct URL to check out any visual diffs. 110 | 111 | ### Step 8 112 | 113 | Visit your project in Percy and you'll see a new build with the visual comparisons between the two 114 | runs. Click anywhere on the Build 2 row. You can see the original snapshots on the left, and the new 115 | snapshots on the right. 116 | 117 | Percy has highlighted what's changed visually in the app! Snapshots with the largest changes are 118 | shown first You can click on the highlight to reveal the underlying screenshot. 119 | 120 | If you scroll down, you'll see that no other test cases were impacted by our changes to the 'Clear 121 | completed' button. The unchanged snapshots appear grouped together at the bottom of the list. 122 | 123 | ### Finished! 😀 124 | 125 | From here, you can try making your own changes to the app and tests, if you like. If you do, re-run 126 | the tests and you'll see any visual changes reflected in Percy. 127 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | video: false, 5 | e2e: { 6 | setupNodeEvents(on, config) { 7 | return require('./cypress/plugins/index.js')(on, config) 8 | }, 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /cypress/e2e/todo.cy.js: -------------------------------------------------------------------------------- 1 | describe('TodoMVC', function() { 2 | beforeEach(function() { 3 | // Load our app before starting each test case 4 | cy.visit('localhost:8000') 5 | }) 6 | 7 | it('Loads the TodoMVC app', function() { 8 | cy.get('.todoapp').should('exist') 9 | cy.percySnapshot() 10 | }) 11 | 12 | it('With no todos, hides main section and footer', function() { 13 | cy.get('.main').should('not.be.visible'); 14 | cy.get('.footer').should('not.be.visible'); 15 | }) 16 | 17 | it('Accepts a new todo', function() { 18 | // Before adding a todo, we should have none. 19 | cy.get('.todo-count').should('contain', '0 items left') 20 | cy.get('.todo-list').children('li').should('have.length', 0) 21 | 22 | // Add a new todo item. 23 | cy.get('.new-todo').should('exist') 24 | cy.get('.new-todo').type('New fancy todo {enter}') 25 | // Take a Percy snapshot with different browser widths. 26 | cy.percySnapshot('New todo test') 27 | 28 | // We should have 1 todo item showing in the todo list and the footer. 29 | cy.get('.todo-list').children('li').should('have.length', 1) 30 | cy.get('.todo-count').should('contain', '1 item left') 31 | }) 32 | 33 | it('Lets you check off a todo', function() { 34 | // Enter a new todo. 35 | cy.get('.new-todo').type('A thing to accomplish {enter}') 36 | cy.get('.todo-count').should('contain', '1 item left') 37 | 38 | // Click it off -- it should be marked as completed. 39 | cy.get('.toggle').click() 40 | cy.get('.todo-count').should('contain', '0 items left') 41 | cy.percySnapshot() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | }; 18 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | // import './commands' 18 | import '@percy/cypress' 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | -------------------------------------------------------------------------------- /dependencies.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | dependencies: 3 | - type: js 4 | path: ./ 5 | settings: 6 | # set the NODE_ENV env variable 7 | # default: development 8 | node_env: production 9 | 10 | # by default we'll collect the package.json versions under the "latest" dist-tag (default npm behavior) 11 | # if you want to follow a specific dist-tag (like "next" or "unstable"), then you 12 | # can specify that here by the package name 13 | # default: none 14 | dist_tags: 15 | semantic-release: next 16 | 17 | # github options 18 | github_labels: # list of label names 19 | - dependencies 20 | 21 | github_assignees: # list of usernames 22 | - djones 23 | 24 | related_pr_behavior: close 25 | 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VanillaJS • TodoMVC 6 | 7 | 8 | 9 |
10 |
11 |

todos

12 | 13 |
14 |
15 | 16 | 17 |
    18 |
    19 |
    20 | 21 | 32 | 33 |
    34 |
    35 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | /*global app, $on */ 2 | (function () { 3 | 'use strict'; 4 | 5 | /** 6 | * Sets up a brand new Todo list. 7 | * 8 | * @param {string} name The name of your new to do list. 9 | */ 10 | function Todo(name) { 11 | this.storage = new app.Store(name); 12 | this.model = new app.Model(this.storage); 13 | this.template = new app.Template(); 14 | this.view = new app.View(this.template); 15 | this.controller = new app.Controller(this.model, this.view); 16 | } 17 | 18 | var todo = new Todo('todos-vanillajs'); 19 | 20 | function setView() { 21 | todo.controller.setView(document.location.hash); 22 | } 23 | $on(window, 'load', setView); 24 | $on(window, 'hashchange', setView); 25 | })(); 26 | -------------------------------------------------------------------------------- /js/controller.js: -------------------------------------------------------------------------------- 1 | (function (window) { 2 | 'use strict'; 3 | 4 | /** 5 | * Takes a model and view and acts as the controller between them 6 | * 7 | * @constructor 8 | * @param {object} model The model instance 9 | * @param {object} view The view instance 10 | */ 11 | function Controller(model, view) { 12 | var self = this; 13 | self.model = model; 14 | self.view = view; 15 | 16 | self.view.bind('newTodo', function (title) { 17 | self.addItem(title); 18 | }); 19 | 20 | self.view.bind('itemEdit', function (item) { 21 | self.editItem(item.id); 22 | }); 23 | 24 | self.view.bind('itemEditDone', function (item) { 25 | self.editItemSave(item.id, item.title); 26 | }); 27 | 28 | self.view.bind('itemEditCancel', function (item) { 29 | self.editItemCancel(item.id); 30 | }); 31 | 32 | self.view.bind('itemRemove', function (item) { 33 | self.removeItem(item.id); 34 | }); 35 | 36 | self.view.bind('itemToggle', function (item) { 37 | self.toggleComplete(item.id, item.completed); 38 | }); 39 | 40 | self.view.bind('removeCompleted', function () { 41 | self.removeCompletedItems(); 42 | }); 43 | 44 | self.view.bind('toggleAll', function (status) { 45 | self.toggleAll(status.completed); 46 | }); 47 | } 48 | 49 | /** 50 | * Loads and initialises the view 51 | * 52 | * @param {string} '' | 'active' | 'completed' 53 | */ 54 | Controller.prototype.setView = function (locationHash) { 55 | var route = locationHash.split('/')[1]; 56 | var page = route || ''; 57 | this._updateFilterState(page); 58 | }; 59 | 60 | /** 61 | * An event to fire on load. Will get all items and display them in the 62 | * todo-list 63 | */ 64 | Controller.prototype.showAll = function () { 65 | var self = this; 66 | self.model.read(function (data) { 67 | self.view.render('showEntries', data); 68 | }); 69 | }; 70 | 71 | /** 72 | * Renders all active tasks 73 | */ 74 | Controller.prototype.showActive = function () { 75 | var self = this; 76 | self.model.read({ completed: false }, function (data) { 77 | self.view.render('showEntries', data); 78 | }); 79 | }; 80 | 81 | /** 82 | * Renders all completed tasks 83 | */ 84 | Controller.prototype.showCompleted = function () { 85 | var self = this; 86 | self.model.read({ completed: true }, function (data) { 87 | self.view.render('showEntries', data); 88 | }); 89 | }; 90 | 91 | /** 92 | * An event to fire whenever you want to add an item. Simply pass in the event 93 | * object and it'll handle the DOM insertion and saving of the new item. 94 | */ 95 | Controller.prototype.addItem = function (title) { 96 | var self = this; 97 | 98 | if (title.trim() === '') { 99 | return; 100 | } 101 | 102 | self.model.create(title, function () { 103 | self.view.render('clearNewTodo'); 104 | self._filter(true); 105 | }); 106 | }; 107 | 108 | /* 109 | * Triggers the item editing mode. 110 | */ 111 | Controller.prototype.editItem = function (id) { 112 | var self = this; 113 | self.model.read(id, function (data) { 114 | self.view.render('editItem', {id: id, title: data[0].title}); 115 | }); 116 | }; 117 | 118 | /* 119 | * Finishes the item editing mode successfully. 120 | */ 121 | Controller.prototype.editItemSave = function (id, title) { 122 | var self = this; 123 | title = title.trim(); 124 | 125 | if (title.length !== 0) { 126 | self.model.update(id, {title: title}, function () { 127 | self.view.render('editItemDone', {id: id, title: title}); 128 | }); 129 | } else { 130 | self.removeItem(id); 131 | } 132 | }; 133 | 134 | /* 135 | * Cancels the item editing mode. 136 | */ 137 | Controller.prototype.editItemCancel = function (id) { 138 | var self = this; 139 | self.model.read(id, function (data) { 140 | self.view.render('editItemDone', {id: id, title: data[0].title}); 141 | }); 142 | }; 143 | 144 | /** 145 | * By giving it an ID it'll find the DOM element matching that ID, 146 | * remove it from the DOM and also remove it from storage. 147 | * 148 | * @param {number} id The ID of the item to remove from the DOM and 149 | * storage 150 | */ 151 | Controller.prototype.removeItem = function (id) { 152 | var self = this; 153 | self.model.remove(id, function () { 154 | self.view.render('removeItem', id); 155 | }); 156 | 157 | self._filter(); 158 | }; 159 | 160 | /** 161 | * Will remove all completed items from the DOM and storage. 162 | */ 163 | Controller.prototype.removeCompletedItems = function () { 164 | var self = this; 165 | self.model.read({ completed: true }, function (data) { 166 | data.forEach(function (item) { 167 | self.removeItem(item.id); 168 | }); 169 | }); 170 | 171 | self._filter(); 172 | }; 173 | 174 | /** 175 | * Give it an ID of a model and a checkbox and it will update the item 176 | * in storage based on the checkbox's state. 177 | * 178 | * @param {number} id The ID of the element to complete or uncomplete 179 | * @param {object} checkbox The checkbox to check the state of complete 180 | * or not 181 | * @param {boolean|undefined} silent Prevent re-filtering the todo items 182 | */ 183 | Controller.prototype.toggleComplete = function (id, completed, silent) { 184 | var self = this; 185 | self.model.update(id, { completed: completed }, function () { 186 | self.view.render('elementComplete', { 187 | id: id, 188 | completed: completed 189 | }); 190 | }); 191 | 192 | if (!silent) { 193 | self._filter(); 194 | } 195 | }; 196 | 197 | /** 198 | * Will toggle ALL checkboxes' on/off state and completeness of models. 199 | * Just pass in the event object. 200 | */ 201 | Controller.prototype.toggleAll = function (completed) { 202 | var self = this; 203 | self.model.read({ completed: !completed }, function (data) { 204 | data.forEach(function (item) { 205 | self.toggleComplete(item.id, completed, true); 206 | }); 207 | }); 208 | 209 | self._filter(); 210 | }; 211 | 212 | /** 213 | * Updates the pieces of the page which change depending on the remaining 214 | * number of todos. 215 | */ 216 | Controller.prototype._updateCount = function () { 217 | var self = this; 218 | self.model.getCount(function (todos) { 219 | self.view.render('updateElementCount', todos.active); 220 | self.view.render('clearCompletedButton', { 221 | completed: todos.completed, 222 | visible: todos.completed > 0 223 | }); 224 | 225 | self.view.render('toggleAll', {checked: todos.completed === todos.total}); 226 | self.view.render('contentBlockVisibility', {visible: todos.total > 0}); 227 | }); 228 | }; 229 | 230 | /** 231 | * Re-filters the todo items, based on the active route. 232 | * @param {boolean|undefined} force forces a re-painting of todo items. 233 | */ 234 | Controller.prototype._filter = function (force) { 235 | var activeRoute = this._activeRoute.charAt(0).toUpperCase() + this._activeRoute.substr(1); 236 | 237 | // Update the elements on the page, which change with each completed todo 238 | this._updateCount(); 239 | 240 | // If the last active route isn't "All", or we're switching routes, we 241 | // re-create the todo item elements, calling: 242 | // this.show[All|Active|Completed](); 243 | if (force || this._lastActiveRoute !== 'All' || this._lastActiveRoute !== activeRoute) { 244 | this['show' + activeRoute](); 245 | } 246 | 247 | this._lastActiveRoute = activeRoute; 248 | }; 249 | 250 | /** 251 | * Simply updates the filter nav's selected states 252 | */ 253 | Controller.prototype._updateFilterState = function (currentPage) { 254 | // Store a reference to the active route, allowing us to re-filter todo 255 | // items as they are marked complete or incomplete. 256 | this._activeRoute = currentPage; 257 | 258 | if (currentPage === '') { 259 | this._activeRoute = 'All'; 260 | } 261 | 262 | this._filter(); 263 | 264 | this.view.render('setFilter', currentPage); 265 | }; 266 | 267 | // Export to window 268 | window.app = window.app || {}; 269 | window.app.Controller = Controller; 270 | })(window); 271 | -------------------------------------------------------------------------------- /js/helpers.js: -------------------------------------------------------------------------------- 1 | /*global NodeList */ 2 | (function (window) { 3 | 'use strict'; 4 | 5 | // Get element(s) by CSS selector: 6 | window.qs = function (selector, scope) { 7 | return (scope || document).querySelector(selector); 8 | }; 9 | window.qsa = function (selector, scope) { 10 | return (scope || document).querySelectorAll(selector); 11 | }; 12 | 13 | // addEventListener wrapper: 14 | window.$on = function (target, type, callback, useCapture) { 15 | target.addEventListener(type, callback, !!useCapture); 16 | }; 17 | 18 | // Attach a handler to event for all elements that match the selector, 19 | // now or in the future, based on a root element 20 | window.$delegate = function (target, selector, type, handler) { 21 | function dispatchEvent(event) { 22 | var targetElement = event.target; 23 | var potentialElements = window.qsa(selector, target); 24 | var hasMatch = Array.prototype.indexOf.call(potentialElements, targetElement) >= 0; 25 | 26 | if (hasMatch) { 27 | handler.call(targetElement, event); 28 | } 29 | } 30 | 31 | // https://developer.mozilla.org/en-US/docs/Web/Events/blur 32 | var useCapture = type === 'blur' || type === 'focus'; 33 | 34 | window.$on(target, type, dispatchEvent, useCapture); 35 | }; 36 | 37 | // Find the element's parent with the given tag name: 38 | // $parent(qs('a'), 'div'); 39 | window.$parent = function (element, tagName) { 40 | if (!element.parentNode) { 41 | return; 42 | } 43 | if (element.parentNode.tagName.toLowerCase() === tagName.toLowerCase()) { 44 | return element.parentNode; 45 | } 46 | return window.$parent(element.parentNode, tagName); 47 | }; 48 | 49 | // Allow for looping on nodes by chaining: 50 | // qsa('.foo').forEach(function () {}) 51 | NodeList.prototype.forEach = Array.prototype.forEach; 52 | })(window); 53 | -------------------------------------------------------------------------------- /js/model.js: -------------------------------------------------------------------------------- 1 | (function (window) { 2 | 'use strict'; 3 | 4 | /** 5 | * Creates a new Model instance and hooks up the storage. 6 | * 7 | * @constructor 8 | * @param {object} storage A reference to the client side storage class 9 | */ 10 | function Model(storage) { 11 | this.storage = storage; 12 | } 13 | 14 | /** 15 | * Creates a new todo model 16 | * 17 | * @param {string} [title] The title of the task 18 | * @param {function} [callback] The callback to fire after the model is created 19 | */ 20 | Model.prototype.create = function (title, callback) { 21 | title = title || ''; 22 | callback = callback || function () {}; 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 36 | * against. 37 | * 38 | * @param {string|number|object} [query] A query to match models against 39 | * @param {function} [callback] The callback to fire after the model is found 40 | * 41 | * @example 42 | * model.read(1, func); // Will find the model with an ID of 1 43 | * model.read('1'); // Same as above 44 | * //Below will find a model with foo equalling bar and hello equalling world. 45 | * model.read({ foo: 'bar', hello: 'world' }); 46 | */ 47 | Model.prototype.read = function (query, callback) { 48 | var queryType = typeof query; 49 | callback = callback || function () {}; 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 | }; 61 | 62 | /** 63 | * Updates a model by giving it an ID, data to update, and a callback to fire when 64 | * the update is complete. 65 | * 66 | * @param {number} id The id of the model to update 67 | * @param {object} data The properties to update and their new value 68 | * @param {function} callback The callback to fire when the update is complete. 69 | */ 70 | Model.prototype.update = function (id, data, callback) { 71 | this.storage.save(data, callback, id); 72 | }; 73 | 74 | /** 75 | * Removes a model from storage 76 | * 77 | * @param {number} id The ID of the model to remove 78 | * @param {function} callback The callback to fire when the removal is complete. 79 | */ 80 | Model.prototype.remove = function (id, callback) { 81 | this.storage.remove(id, callback); 82 | }; 83 | 84 | /** 85 | * WARNING: Will remove ALL data from storage. 86 | * 87 | * @param {function} callback The callback to fire when the storage is wiped. 88 | */ 89 | Model.prototype.removeAll = function (callback) { 90 | this.storage.drop(callback); 91 | }; 92 | 93 | /** 94 | * Returns a count of all todos 95 | */ 96 | Model.prototype.getCount = function (callback) { 97 | var todos = { 98 | active: 0, 99 | completed: 0, 100 | total: 0 101 | }; 102 | 103 | this.storage.findAll(function (data) { 104 | data.forEach(function (todo) { 105 | if (todo.completed) { 106 | todos.completed++; 107 | } else { 108 | todos.active++; 109 | } 110 | 111 | todos.total++; 112 | }); 113 | callback(todos); 114 | }); 115 | }; 116 | 117 | // Export to window 118 | window.app = window.app || {}; 119 | window.app.Model = Model; 120 | })(window); 121 | -------------------------------------------------------------------------------- /js/store.js: -------------------------------------------------------------------------------- 1 | /*jshint eqeqeq:false */ 2 | (function (window) { 3 | 'use strict'; 4 | 5 | /** 6 | * Creates a new client side storage object and will create an empty 7 | * collection if no collection already exists. 8 | * 9 | * @param {string} name The name of our DB we want to use 10 | * @param {function} callback Our fake DB uses callbacks because in 11 | * real life you probably would be making AJAX calls 12 | */ 13 | function Store(name, callback) { 14 | callback = callback || function () {}; 15 | 16 | this._dbName = name; 17 | 18 | if (!localStorage.getItem(name)) { 19 | var todos = []; 20 | 21 | localStorage.setItem(name, JSON.stringify(todos)); 22 | } 23 | 24 | callback.call(this, JSON.parse(localStorage.getItem(name))); 25 | } 26 | 27 | /** 28 | * Finds items based on a query given as a JS object 29 | * 30 | * @param {object} query The query to match against (i.e. {foo: 'bar'}) 31 | * @param {function} callback The callback to fire when the query has 32 | * completed running 33 | * 34 | * @example 35 | * db.find({foo: 'bar', hello: 'world'}, function (data) { 36 | * // data will return any items that have foo: bar and 37 | * // hello: world in their properties 38 | * }); 39 | */ 40 | Store.prototype.find = function (query, callback) { 41 | if (!callback) { 42 | return; 43 | } 44 | 45 | var todos = JSON.parse(localStorage.getItem(this._dbName)); 46 | 47 | callback.call(this, todos.filter(function (todo) { 48 | for (var q in query) { 49 | if (query[q] !== todo[q]) { 50 | return false; 51 | } 52 | } 53 | return true; 54 | })); 55 | }; 56 | 57 | /** 58 | * Will retrieve all data from the collection 59 | * 60 | * @param {function} callback The callback to fire upon retrieving data 61 | */ 62 | Store.prototype.findAll = function (callback) { 63 | callback = callback || function () {}; 64 | callback.call(this, JSON.parse(localStorage.getItem(this._dbName))); 65 | }; 66 | 67 | /** 68 | * Will save the given data to the DB. If no item exists it will create a new 69 | * item, otherwise it'll simply update an existing item's properties 70 | * 71 | * @param {object} updateData The data to save back into the DB 72 | * @param {function} callback The callback to fire after saving 73 | * @param {number} id An optional param to enter an ID of an item to update 74 | */ 75 | Store.prototype.save = function (updateData, callback, id) { 76 | var todos = JSON.parse(localStorage.getItem(this._dbName)) || []; 77 | 78 | callback = callback || function() {}; 79 | 80 | // If an ID was actually given, find the item and update each property 81 | if (id) { 82 | for (var i = 0; i < todos.length; i++) { 83 | if (todos[i].id === id) { 84 | for (var key in updateData) { 85 | todos[i][key] = updateData[key]; 86 | } 87 | break; 88 | } 89 | } 90 | 91 | localStorage.setItem(this._dbName, JSON.stringify(todos)); 92 | callback.call(this, todos); 93 | } else { 94 | // Generate an ID 95 | updateData.id = new Date().getTime(); 96 | 97 | todos.push(updateData); 98 | localStorage.setItem(this._dbName, JSON.stringify(todos)); 99 | callback.call(this, [updateData]); 100 | } 101 | }; 102 | 103 | /** 104 | * Will remove an item from the Store based on its ID 105 | * 106 | * @param {number} id The ID of the item you want to remove 107 | * @param {function} callback The callback to fire after saving 108 | */ 109 | Store.prototype.remove = function (id, callback) { 110 | var todos = JSON.parse(localStorage.getItem(this._dbName)); 111 | 112 | for (var i = 0; i < todos.length; i++) { 113 | if (todos[i].id == id) { 114 | todos.splice(i, 1); 115 | break; 116 | } 117 | } 118 | 119 | localStorage.setItem(this._dbName, JSON.stringify(todos)); 120 | callback.call(this, todos); 121 | }; 122 | 123 | /** 124 | * Will drop all storage and start fresh 125 | * 126 | * @param {function} callback The callback to fire after dropping the data 127 | */ 128 | Store.prototype.drop = function (callback) { 129 | var todos = []; 130 | localStorage.setItem(this._dbName, JSON.stringify(todos)); 131 | callback.call(this, todos); 132 | }; 133 | 134 | // Export to window 135 | window.app = window.app || {}; 136 | window.app.Store = Store; 137 | })(window); 138 | -------------------------------------------------------------------------------- /js/template.js: -------------------------------------------------------------------------------- 1 | /*jshint laxbreak:true */ 2 | (function (window) { 3 | 'use strict'; 4 | 5 | var htmlEscapes = { 6 | '&': '&', 7 | '<': '<', 8 | '>': '>', 9 | '"': '"', 10 | '\'': ''', 11 | '`': '`' 12 | }; 13 | 14 | var escapeHtmlChar = function (chr) { 15 | return htmlEscapes[chr]; 16 | }; 17 | 18 | var reUnescapedHtml = /[&<>"'`]/g; 19 | var reHasUnescapedHtml = new RegExp(reUnescapedHtml.source); 20 | 21 | var escape = function (string) { 22 | return (string && reHasUnescapedHtml.test(string)) 23 | ? string.replace(reUnescapedHtml, escapeHtmlChar) 24 | : string; 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 | * Creates an
  • HTML string and returns it for placement in your app. 45 | * 46 | * NOTE: In real life you should be using a templating engine such as Mustache 47 | * or Handlebars, however, this is a vanilla JS example. 48 | * 49 | * @param {object} data The object containing keys you want to find in the 50 | * template to replace. 51 | * @returns {string} HTML String of an
  • element 52 | * 53 | * @example 54 | * view.show({ 55 | * id: 1, 56 | * title: "Hello World", 57 | * completed: 0, 58 | * }); 59 | */ 60 | Template.prototype.show = function (data) { 61 | var i, l; 62 | var view = ''; 63 | 64 | for (i = 0, l = data.length; i < l; i++) { 65 | var template = this.defaultTemplate; 66 | var completed = ''; 67 | var checked = ''; 68 | 69 | if (data[i].completed) { 70 | completed = 'completed'; 71 | checked = 'checked'; 72 | } 73 | 74 | template = template.replace('{{id}}', data[i].id); 75 | template = template.replace('{{title}}', escape(data[i].title)); 76 | template = template.replace('{{completed}}', completed); 77 | template = template.replace('{{checked}}', checked); 78 | 79 | view = view + template; 80 | } 81 | 82 | return view; 83 | }; 84 | 85 | /** 86 | * Displays a counter of how many to dos are left to complete 87 | * 88 | * @param {number} activeTodos The number of active todos. 89 | * @returns {string} String containing the count 90 | */ 91 | Template.prototype.itemCounter = function (activeTodos) { 92 | var plural = activeTodos === 1 ? '' : 's'; 93 | 94 | return '' + activeTodos + ' item' + plural + ' left'; 95 | }; 96 | 97 | /** 98 | * Updates the text within the "Clear completed" button 99 | * 100 | * @param {[type]} completedTodos The number of completed todos. 101 | * @returns {string} String containing the count 102 | */ 103 | Template.prototype.clearCompletedButton = function (completedTodos) { 104 | if (completedTodos > 0) { 105 | return 'Clear completed'; 106 | } else { 107 | return ''; 108 | } 109 | }; 110 | 111 | // Export to window 112 | window.app = window.app || {}; 113 | window.app.Template = Template; 114 | })(window); 115 | -------------------------------------------------------------------------------- /js/view.js: -------------------------------------------------------------------------------- 1 | /*global qs, qsa, $on, $parent, $delegate */ 2 | 3 | (function (window) { 4 | 'use strict'; 5 | 6 | /** 7 | * View that abstracts away the browser's DOM completely. 8 | * It has two simple entry points: 9 | * 10 | * - bind(eventName, handler) 11 | * Takes a todo application event and registers the handler 12 | * - render(command, parameterObject) 13 | * Renders the given command with the options 14 | */ 15 | function View(template) { 16 | this.template = template; 17 | 18 | this.ENTER_KEY = 13; 19 | this.ESCAPE_KEY = 27; 20 | 21 | this.$todoList = qs('.todo-list'); 22 | this.$todoItemCounter = qs('.todo-count'); 23 | this.$clearCompleted = qs('.clear-completed'); 24 | this.$main = qs('.main'); 25 | this.$footer = qs('.footer'); 26 | this.$toggleAll = qs('.toggle-all'); 27 | this.$newTodo = qs('.new-todo'); 28 | } 29 | 30 | View.prototype._removeItem = function (id) { 31 | var elem = qs('[data-id="' + id + '"]'); 32 | 33 | if (elem) { 34 | this.$todoList.removeChild(elem); 35 | } 36 | }; 37 | 38 | View.prototype._clearCompletedButton = function (completedCount, visible) { 39 | this.$clearCompleted.style.display = visible ? 'block' : 'none'; 40 | }; 41 | 42 | View.prototype._setFilter = function (currentPage) { 43 | qs('.filters .selected').className = ''; 44 | qs('.filters [href="#/' + currentPage + '"]').className = 'selected'; 45 | }; 46 | 47 | View.prototype._elementComplete = function (id, completed) { 48 | var listItem = qs('[data-id="' + id + '"]'); 49 | 50 | if (!listItem) { 51 | return; 52 | } 53 | 54 | listItem.className = completed ? 'completed' : ''; 55 | 56 | // In case it was toggled from an event and not by clicking the checkbox 57 | qs('input', listItem).checked = completed; 58 | }; 59 | 60 | View.prototype._editItem = function (id, title) { 61 | var listItem = qs('[data-id="' + id + '"]'); 62 | 63 | if (!listItem) { 64 | return; 65 | } 66 | 67 | listItem.className = listItem.className + ' editing'; 68 | 69 | var input = document.createElement('input'); 70 | input.className = 'edit'; 71 | 72 | listItem.appendChild(input); 73 | input.focus(); 74 | input.value = title; 75 | }; 76 | 77 | View.prototype._editItemDone = function (id, title) { 78 | var listItem = qs('[data-id="' + id + '"]'); 79 | 80 | if (!listItem) { 81 | return; 82 | } 83 | 84 | var input = qs('input.edit', listItem); 85 | listItem.removeChild(input); 86 | 87 | listItem.className = listItem.className.replace('editing', ''); 88 | 89 | qsa('label', listItem).forEach(function (label) { 90 | label.textContent = title; 91 | }); 92 | }; 93 | 94 | View.prototype.render = function (viewCmd, parameter) { 95 | var self = this; 96 | var viewCommands = { 97 | showEntries: function () { 98 | self.$todoList.innerHTML = self.template.show(parameter); 99 | }, 100 | removeItem: function () { 101 | self._removeItem(parameter); 102 | }, 103 | updateElementCount: function () { 104 | self.$todoItemCounter.innerHTML = self.template.itemCounter(parameter); 105 | }, 106 | clearCompletedButton: function () { 107 | self._clearCompletedButton(parameter.completed, parameter.visible); 108 | }, 109 | contentBlockVisibility: function () { 110 | self.$main.style.display = self.$footer.style.display = parameter.visible ? 'block' : 'none'; 111 | }, 112 | toggleAll: function () { 113 | self.$toggleAll.checked = parameter.checked; 114 | }, 115 | setFilter: function () { 116 | self._setFilter(parameter); 117 | }, 118 | clearNewTodo: function () { 119 | self.$newTodo.value = ''; 120 | }, 121 | elementComplete: function () { 122 | self._elementComplete(parameter.id, parameter.completed); 123 | }, 124 | editItem: function () { 125 | self._editItem(parameter.id, parameter.title); 126 | }, 127 | editItemDone: function () { 128 | self._editItemDone(parameter.id, parameter.title); 129 | } 130 | }; 131 | 132 | viewCommands[viewCmd](); 133 | }; 134 | 135 | View.prototype._itemId = function (element) { 136 | var li = $parent(element, 'li'); 137 | return parseInt(li.dataset.id, 10); 138 | }; 139 | 140 | View.prototype._bindItemEditDone = function (handler) { 141 | var self = this; 142 | $delegate(self.$todoList, 'li .edit', 'blur', function () { 143 | if (!this.dataset.iscanceled) { 144 | handler({ 145 | id: self._itemId(this), 146 | title: this.value 147 | }); 148 | } 149 | }); 150 | 151 | $delegate(self.$todoList, 'li .edit', 'keypress', function (event) { 152 | if (event.keyCode === self.ENTER_KEY) { 153 | // Remove the cursor from the input when you hit enter just like if it 154 | // were a real form 155 | this.blur(); 156 | } 157 | }); 158 | }; 159 | 160 | View.prototype._bindItemEditCancel = function (handler) { 161 | var self = this; 162 | $delegate(self.$todoList, 'li .edit', 'keyup', function (event) { 163 | if (event.keyCode === self.ESCAPE_KEY) { 164 | this.dataset.iscanceled = true; 165 | this.blur(); 166 | 167 | handler({id: self._itemId(this)}); 168 | } 169 | }); 170 | }; 171 | 172 | View.prototype.bind = function (event, handler) { 173 | var self = this; 174 | if (event === 'newTodo') { 175 | $on(self.$newTodo, 'change', function () { 176 | handler(self.$newTodo.value); 177 | }); 178 | 179 | } else if (event === 'removeCompleted') { 180 | $on(self.$clearCompleted, 'click', function () { 181 | handler(); 182 | }); 183 | 184 | } else if (event === 'toggleAll') { 185 | $on(self.$toggleAll, 'click', function () { 186 | handler({completed: this.checked}); 187 | }); 188 | 189 | } else if (event === 'itemEdit') { 190 | $delegate(self.$todoList, 'li label', 'dblclick', function () { 191 | handler({id: self._itemId(this)}); 192 | }); 193 | 194 | } else if (event === 'itemRemove') { 195 | $delegate(self.$todoList, '.destroy', 'click', function () { 196 | handler({id: self._itemId(this)}); 197 | }); 198 | 199 | } else if (event === 'itemToggle') { 200 | $delegate(self.$todoList, '.toggle', 'click', function () { 201 | handler({ 202 | id: self._itemId(this), 203 | completed: this.checked 204 | }); 205 | }); 206 | 207 | } else if (event === 'itemEditDone') { 208 | self._bindItemEditDone(handler); 209 | 210 | } else if (event === 'itemEditCancel') { 211 | self._bindItemEditCancel(handler); 212 | } 213 | }; 214 | 215 | // Export to window 216 | window.app = window.app || {}; 217 | window.app.View = View; 218 | }(window)); 219 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "percy:cypress": "percy exec -- cypress run", 5 | "start:server": "serve -l 8000 .", 6 | "test": "start-server-and-test start:server 8000 percy:cypress" 7 | }, 8 | "engines": { 9 | "node": ">=14.0.0" 10 | }, 11 | "dependencies": { 12 | "todomvc-app-css": "^2.4.2" 13 | }, 14 | "devDependencies": { 15 | "@percy/cli": "^1.12.0", 16 | "@percy/cypress": "^3.1.2", 17 | "cypress": "^10.11.0", 18 | "serve": "^14.0.1", 19 | "start-server-and-test": "1.14.0" 20 | } 21 | } 22 | --------------------------------------------------------------------------------