├── .babelrc ├── .github └── workflows │ └── valid.yml ├── .gitignore ├── LICENSE ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── todos.json ├── integration │ ├── app-init.spec.js │ ├── footer.spec.js │ ├── input-form.spec.js │ ├── list-items.spec.js │ └── smoke-tests.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── db.json ├── json-server.json ├── package-lock.json ├── package.json ├── routes.json ├── src ├── components │ ├── Footer.js │ ├── TodoApp.js │ ├── TodoForm.js │ └── TodoList.js ├── index.html ├── index.js ├── lib │ ├── service.js │ └── utils.js └── styles.css └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets":[ 3 | "env", "react" 4 | ], 5 | "plugins": [ 6 | "transform-object-rest-spread" 7 | ] 8 | } -------------------------------------------------------------------------------- /.github/workflows/valid.yml: -------------------------------------------------------------------------------- 1 | name: Validate initial app state 2 | 3 | on: push 4 | 5 | jobs: 6 | single-run: 7 | runs-on: ubuntu-16.04 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | - name: Cypress run 12 | uses: cypress-io/github-action@v1 13 | with: 14 | build: npm run build 15 | start: npm run serve -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | .env 10 | 11 | build 12 | 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 avanslaars 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build and test an application using Cypress 2 | 3 | This repository is the starting point for an official Cypress tutorial. We encourage you to clone this repo and follow along. 4 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3030" 3 | } 4 | -------------------------------------------------------------------------------- /cypress/fixtures/todos.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Buy Milk", 5 | "isComplete": false 6 | }, 7 | { 8 | "id": 2, 9 | "name": "Buy Eggs", 10 | "isComplete": true 11 | }, 12 | { 13 | "id": 3, 14 | "name": "Buy Bread", 15 | "isComplete": false 16 | }, 17 | { 18 | "id": 4, 19 | "name": "Make French Toast", 20 | "isComplete": false 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /cypress/integration/app-init.spec.js: -------------------------------------------------------------------------------- 1 | describe('App initialization', () => { 2 | it('Loads todos on page load', () => { 3 | cy.seedAndVisit() 4 | 5 | cy.get('.todo-list li') 6 | .should('have.length', 4) 7 | }) 8 | 9 | it('Displays an error on failure', () => { 10 | cy.server() 11 | cy.route({ 12 | url: '/api/todos', 13 | method: 'GET', 14 | status: 500, 15 | response: {} 16 | }) 17 | cy.visit('/') 18 | 19 | cy.get('.todo-list li') 20 | .should('not.exist') 21 | 22 | cy.get('.error') 23 | .should('be.visible') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /cypress/integration/footer.spec.js: -------------------------------------------------------------------------------- 1 | describe('Footer', () => { 2 | context('with a single todo', () => { 3 | it('displays a singular todo in count', () => { 4 | cy.seedAndVisit([{id: 1, name: 'Buy milk', isComplete: false}]) 5 | cy.get('.todo-count') 6 | .should('contain', '1 todo left') 7 | }) 8 | }) 9 | 10 | context('with multiple todos', () => { 11 | beforeEach(() => { 12 | cy.seedAndVisit() 13 | }) 14 | 15 | it('displays plural todos in count', () => { 16 | cy.get('.todo-count') 17 | .should('contain', '3 todos left') 18 | }) 19 | 20 | it('Handles filter links', () => { 21 | const filters = [ 22 | {link: 'Active', expectedLength: 3}, 23 | {link: 'Completed', expectedLength: 1}, 24 | {link: 'All', expectedLength: 4} 25 | ] 26 | cy.wrap(filters) 27 | .each(filter => { 28 | cy.contains(filter.link) 29 | .click() 30 | 31 | cy.get('.todo-list li') 32 | .should('have.length', filter.expectedLength) 33 | }) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /cypress/integration/input-form.spec.js: -------------------------------------------------------------------------------- 1 | describe('Input form', () => { 2 | beforeEach(() => { 3 | cy.seedAndVisit([]) 4 | }) 5 | 6 | it('focuses input on load', () => { 7 | cy.focused() 8 | .should('have.class', 'new-todo') 9 | }) 10 | 11 | it('accepts input', () => { 12 | const typedText = 'Buy Milk' 13 | 14 | cy.get('.new-todo') 15 | .type(typedText) 16 | .should('have.value', typedText) 17 | }) 18 | 19 | context('Form submission', () => { 20 | beforeEach(() => { 21 | cy.server() 22 | }) 23 | 24 | it('Adds a new todo on submit', () => { 25 | const itemText = 'Buy eggs' 26 | cy.route('POST', '/api/todos', { 27 | name: itemText, 28 | id: 1, 29 | isComplete: false 30 | }) 31 | 32 | cy.get('.new-todo') 33 | .type(itemText) 34 | .type('{enter}') 35 | .should('have.value', '') 36 | 37 | cy.get('.todo-list li') 38 | .should('have.length', 1) 39 | .and('contain', itemText) 40 | }) 41 | 42 | it('Shows an error message on a failed submission', () => { 43 | cy.route({ 44 | url: '/api/todos', 45 | method: 'POST', 46 | status: 500, 47 | response: {} 48 | }) 49 | 50 | cy.get('.new-todo') 51 | .type('test{enter}') 52 | 53 | cy.get('.todo-list li') 54 | .should('not.exist') 55 | 56 | cy.get('.error') 57 | .should('be.visible') 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /cypress/integration/list-items.spec.js: -------------------------------------------------------------------------------- 1 | describe('List items', () => { 2 | beforeEach(() => { 3 | cy.seedAndVisit() 4 | }) 5 | 6 | it('properly displays completed items', () => { 7 | cy.get('.todo-list li') 8 | .filter('.completed') 9 | .should('have.length', 1) 10 | .and('contain', 'Eggs') 11 | .find('.toggle') 12 | .should('be.checked') 13 | }) 14 | 15 | it('Shows remaining todos in the footer', () => { 16 | cy.get('.todo-count') 17 | .should('contain', 3) 18 | }) 19 | 20 | it('Removes a todo', () => { 21 | cy.route({ 22 | url: '/api/todos/1', 23 | method: 'DELETE', 24 | status: 200, 25 | response: {} 26 | }) 27 | 28 | cy.get('.todo-list li') 29 | .as('list') 30 | 31 | cy.get('@list') 32 | .first() 33 | .find('.destroy') 34 | .invoke('show') 35 | .click() 36 | 37 | cy.get('@list') 38 | .should('have.length', 3) 39 | .and('not.contain', 'Milk') 40 | }) 41 | 42 | it('Marks an incomplete item complete', () => { 43 | cy.fixture('todos') 44 | .then(todos => { 45 | const target = Cypress._.head(todos) 46 | cy.route( 47 | 'PUT', 48 | `/api/todos/${target.id}`, 49 | Cypress._.merge(target, {isComplete: true}) 50 | ) 51 | }) 52 | 53 | cy.get('.todo-list li') 54 | .first() 55 | .as('first-todo') 56 | 57 | cy.get('@first-todo') 58 | .find('.toggle') 59 | .click() 60 | .should('be.checked') 61 | 62 | cy.get('@first-todo') 63 | .should('have.class', 'completed') 64 | 65 | cy.get('.todo-count') 66 | .should('contain', 2) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /cypress/integration/smoke-tests.spec.js: -------------------------------------------------------------------------------- 1 | describe('Smoke tests', () => { 2 | beforeEach(() => { 3 | cy.request('GET', '/api/todos') 4 | .its('body') 5 | .each(todo => cy.request('DELETE', `/api/todos/${todo.id}`)) 6 | }) 7 | 8 | context('With no todos', () => { 9 | it('Saves new todos', () => { 10 | const items = [ 11 | {text: 'Buy milk', expectedLength: 1}, 12 | {text: 'Buy eggs', expectedLength: 2}, 13 | {text: 'Buy bread', expectedLength: 3} 14 | ] 15 | cy.visit('/') 16 | cy.server() 17 | cy.route('POST', '/api/todos') 18 | .as('create') 19 | 20 | cy.wrap(items) 21 | .each(todo => { 22 | cy.focused() 23 | .type(todo.text) 24 | .type('{enter}') 25 | 26 | cy.wait('@create') 27 | 28 | cy.get('.todo-list li') 29 | .should('have.length', todo.expectedLength) 30 | }) 31 | }) 32 | }) 33 | 34 | context('With active todos', () => { 35 | beforeEach(() => { 36 | cy.fixture('todos') 37 | .each(todo => { 38 | const newTodo = Cypress._.merge(todo, {isComplete: false}) 39 | cy.request('POST', '/api/todos', newTodo) 40 | }) 41 | cy.visit('/') 42 | }) 43 | 44 | it('Loads existing data from the DB', () => { 45 | cy.get('.todo-list li') 46 | .should('have.length', 4) 47 | }) 48 | 49 | it('Deletes todos', () => { 50 | cy.server() 51 | cy.route('DELETE', '/api/todos/*') 52 | .as('delete') 53 | 54 | cy.get('.todo-list li') 55 | .each($el => { 56 | cy.wrap($el) 57 | .find('.destroy') 58 | .invoke('show') 59 | .click() 60 | 61 | cy.wait('@delete') 62 | }) 63 | .should('not.exist') 64 | }) 65 | 66 | it('Toggles todos', () => { 67 | const clickAndWait = ($el) => { 68 | cy.wrap($el) 69 | .as('item') 70 | .find('.toggle') 71 | .click() 72 | 73 | cy.wait('@update') 74 | } 75 | cy.server() 76 | cy.route('PUT', '/api/todos/*') 77 | .as('update') 78 | 79 | cy.get('.todo-list li') 80 | .each($el => { 81 | clickAndWait($el) 82 | cy.get('@item') 83 | .should('have.class', 'completed') 84 | }) 85 | .each($el => { 86 | clickAndWait($el) 87 | cy.get('@item') 88 | .should('not.have.class', 'completed') 89 | }) 90 | }) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /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/commands.js: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('seedAndVisit', (seedData = 'fixture:todos') => { 2 | cy.server() 3 | cy.route('GET', '/api/todos', seedData) 4 | cy.visit('/') 5 | }) 6 | -------------------------------------------------------------------------------- /cypress/support/index.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 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "todos": [ 3 | { 4 | "id": 1, 5 | "name": "Buy Milk", 6 | "isComplete": false 7 | }, 8 | { 9 | "id": 2, 10 | "name": "Buy Eggs", 11 | "isComplete": false 12 | }, 13 | { 14 | "id": 3, 15 | "name": "Buy Bread", 16 | "isComplete": false 17 | }, 18 | { 19 | "id": 4, 20 | "name": "Make French Toast", 21 | "isComplete": false 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /json-server.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3030, 3 | "watch": false, 4 | "static": "./build", 5 | "routes": "./routes.json" 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypress-tutorial-build-todo-starter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "watch": "webpack --watch", 9 | "serve": "json-server db.json", 10 | "dev": "concurrently \"npm run watch\" \"npm run serve\"", 11 | "cypress": "cypress open", 12 | "cypress:all": "cypress run" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "axios": "0.19.2", 19 | "concurrently": "3.6.1", 20 | "json-server": "0.16.1", 21 | "react": "16.13.1", 22 | "react-dom": "16.13.1", 23 | "react-router-dom": "4.2.2" 24 | }, 25 | "devDependencies": { 26 | "babel-core": "6.26.3", 27 | "babel-loader": "7.1.5", 28 | "babel-plugin-transform-object-rest-spread": "6.26.0", 29 | "babel-preset-env": "1.7.0", 30 | "babel-preset-react": "6.24.1", 31 | "css-loader": "0.28.11", 32 | "cypress": "3.5.0", 33 | "html-webpack-plugin": "2.30.1", 34 | "style-loader": "0.19.1", 35 | "webpack": "3.12.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api/*": "/$1" 3 | } -------------------------------------------------------------------------------- /src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Link} from 'react-router-dom' 3 | 4 | export default props => 5 | 18 | -------------------------------------------------------------------------------- /src/components/TodoApp.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {BrowserRouter as Router, Route} from 'react-router-dom' 3 | import TodoForm from './TodoForm' 4 | import TodoList from './TodoList' 5 | import Footer from './Footer' 6 | import {saveTodo, loadTodos, destroyTodo, updateTodo} from '../lib/service' 7 | import {filterTodos} from '../lib/utils' 8 | 9 | 10 | export default class TodoApp extends Component { 11 | constructor(props) { 12 | super(props) 13 | 14 | this.state = { 15 | currentTodo: '', 16 | todos: [] 17 | } 18 | this.handleNewTodoChange = this.handleNewTodoChange.bind(this) 19 | this.handleTodoSubmit = this.handleTodoSubmit.bind(this) 20 | this.handleDelete = this.handleDelete.bind(this) 21 | this.handleToggle = this.handleToggle.bind(this) 22 | } 23 | 24 | componentDidMount () { 25 | loadTodos() 26 | .then(({data}) => this.setState({todos: data})) 27 | .catch(() => this.setState({error: true})) 28 | } 29 | 30 | handleNewTodoChange (evt) { 31 | this.setState({currentTodo: evt.target.value}) 32 | } 33 | 34 | handleDelete (id) { 35 | destroyTodo(id) 36 | .then(() => this.setState({ 37 | todos: this.state.todos.filter(t => t.id !== id) 38 | })) 39 | } 40 | 41 | handleToggle (id) { 42 | const targetTodo = this.state.todos.find(t => t.id === id) 43 | const updated = { 44 | ...targetTodo, 45 | isComplete: !targetTodo.isComplete 46 | } 47 | updateTodo(updated) 48 | .then(({data}) => { 49 | const todos = this.state.todos.map( 50 | t => t.id === data.id ? data : t 51 | ) 52 | this.setState({todos: todos}) 53 | }) 54 | } 55 | 56 | handleTodoSubmit (evt) { 57 | evt.preventDefault() 58 | const newTodo = {name: this.state.currentTodo, isComplete: false} 59 | saveTodo(newTodo) 60 | .then(({data}) => this.setState({ 61 | todos: this.state.todos.concat(data), 62 | currentTodo: '' 63 | })) 64 | .catch(() => this.setState({error: true})) 65 | } 66 | 67 | render () { 68 | const remaining = this.state.todos.filter(t => !t.isComplete).length 69 | return ( 70 | 71 |
72 |
73 |

todos

74 | {this.state.error ? Oh no! : null} 75 | 79 |
80 |
81 | 82 | 86 | } /> 87 |
88 |
90 |
91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/TodoForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default props => 4 |
5 | 12 |
13 | -------------------------------------------------------------------------------- /src/components/TodoList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const TodoItem = props => 4 |
  • 5 |
    6 | props.handleToggle(props.id)}/> 9 | 12 |
    15 |
  • 16 | 17 | export default props => 18 | 23 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React todoMVC with Cypress Tests 5 | 6 | 7 | 8 | 9 |
    10 | 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import TodoApp from './components/TodoApp' 4 | import './styles.css' 5 | 6 | ReactDOM.render(, document.getElementById('app')) -------------------------------------------------------------------------------- /src/lib/service.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const saveTodo = (todo) => 4 | axios.post('http://localhost:3030/api/todos', todo) 5 | 6 | export const loadTodos = () => 7 | axios.get('http://localhost:3030/api/todos') 8 | 9 | export const destroyTodo = (id) => 10 | axios.delete(`http://localhost:3030/api/todos/${id}`) 11 | 12 | export const updateTodo = (todo) => 13 | axios.put(`http://localhost:3030/api/todos/${todo.id}`, todo) 14 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export const filterTodos = (filter, todos) => filter 2 | ? todos.filter(todo => todo.isComplete === (filter === 'completed')) 3 | : todos 4 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | font-weight: inherit; 16 | color: inherit; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-font-smoothing: antialiased; 21 | font-smoothing: antialiased; 22 | } 23 | 24 | body { 25 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 26 | line-height: 1.4em; 27 | background: #f5f5f5; 28 | color: #4d4d4d; 29 | min-width: 230px; 30 | max-width: 550px; 31 | margin: 0 auto; 32 | -webkit-font-smoothing: antialiased; 33 | -moz-font-smoothing: antialiased; 34 | font-smoothing: antialiased; 35 | font-weight: 300; 36 | } 37 | 38 | button, 39 | input[type="checkbox"] { 40 | outline: none; 41 | } 42 | 43 | .hidden { 44 | display: none; 45 | } 46 | 47 | .todoapp { 48 | background: #fff; 49 | margin: 130px 0 40px 0; 50 | position: relative; 51 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 52 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 53 | } 54 | 55 | .todoapp input::-webkit-input-placeholder { 56 | font-style: italic; 57 | font-weight: 300; 58 | color: #e6e6e6; 59 | } 60 | 61 | .todoapp input::-moz-placeholder { 62 | font-style: italic; 63 | font-weight: 300; 64 | color: #e6e6e6; 65 | } 66 | 67 | .todoapp input::input-placeholder { 68 | font-style: italic; 69 | font-weight: 300; 70 | color: #e6e6e6; 71 | } 72 | 73 | .todoapp h1 { 74 | position: absolute; 75 | top: -155px; 76 | width: 100%; 77 | font-size: 100px; 78 | font-weight: 100; 79 | text-align: center; 80 | color: rgba(175, 47, 47, 0.15); 81 | -webkit-text-rendering: optimizeLegibility; 82 | -moz-text-rendering: optimizeLegibility; 83 | text-rendering: optimizeLegibility; 84 | } 85 | 86 | .new-todo, 87 | .edit { 88 | position: relative; 89 | margin: 0; 90 | width: 100%; 91 | font-size: 24px; 92 | font-family: inherit; 93 | font-weight: inherit; 94 | line-height: 1.4em; 95 | border: 0; 96 | outline: none; 97 | color: inherit; 98 | padding: 6px; 99 | border: 1px solid #999; 100 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 101 | box-sizing: border-box; 102 | -webkit-font-smoothing: antialiased; 103 | -moz-font-smoothing: antialiased; 104 | font-smoothing: antialiased; 105 | } 106 | 107 | .new-todo { 108 | padding: 16px 16px 16px 60px; 109 | border: none; 110 | background: rgba(0, 0, 0, 0.003); 111 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 112 | } 113 | 114 | .main { 115 | position: relative; 116 | z-index: 2; 117 | border-top: 1px solid #e6e6e6; 118 | } 119 | 120 | label[for='toggle-all'] { 121 | display: none; 122 | } 123 | 124 | .toggle-all { 125 | position: absolute; 126 | top: -55px; 127 | left: -12px; 128 | width: 60px; 129 | height: 34px; 130 | text-align: center; 131 | border: none; /* Mobile Safari */ 132 | } 133 | 134 | .toggle-all:before { 135 | content: '❯'; 136 | font-size: 22px; 137 | color: #e6e6e6; 138 | padding: 10px 27px 10px 27px; 139 | } 140 | 141 | .toggle-all:checked:before { 142 | color: #737373; 143 | } 144 | 145 | .todo-list { 146 | margin: 0; 147 | padding: 0; 148 | list-style: none; 149 | } 150 | 151 | .todo-list li { 152 | position: relative; 153 | font-size: 24px; 154 | border-bottom: 1px solid #ededed; 155 | } 156 | 157 | .todo-list li:last-child { 158 | border-bottom: none; 159 | } 160 | 161 | .todo-list li.editing { 162 | border-bottom: none; 163 | padding: 0; 164 | } 165 | 166 | .todo-list li.editing .edit { 167 | display: block; 168 | width: 506px; 169 | padding: 13px 17px 12px 17px; 170 | margin: 0 0 0 43px; 171 | } 172 | 173 | .todo-list li.editing .view { 174 | display: none; 175 | } 176 | 177 | .todo-list li .toggle { 178 | text-align: center; 179 | width: 40px; 180 | /* auto, since non-WebKit browsers doesn't support input styling */ 181 | height: auto; 182 | position: absolute; 183 | top: 0; 184 | bottom: 0; 185 | margin: auto 0; 186 | border: none; /* Mobile Safari */ 187 | -webkit-appearance: none; 188 | appearance: none; 189 | } 190 | 191 | .todo-list li .toggle:after { 192 | content: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='-10 -18 100 135'%3E%3Ccircle cx='50' cy='50' r='50' fill='none' stroke='%23ededed' stroke-width='3'/%3E%3C/svg%3E"); 193 | } 194 | 195 | .todo-list li .toggle:checked:after { 196 | content: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='-10 -18 100 135'%3E%3Ccircle cx='50' cy='50' r='50' fill='none' stroke='%23bddad5' stroke-width='3'/%3E%3Cpath fill='%235dc2af' d='M72 25L42 71 27 56l-4 4 20 20 34-52z'/%3E%3C/svg%3E"); 197 | } 198 | 199 | .todo-list li label { 200 | white-space: pre-line; 201 | word-break: break-all; 202 | padding: 15px 60px 15px 15px; 203 | margin-left: 45px; 204 | display: block; 205 | line-height: 1.2; 206 | transition: color 0.4s; 207 | } 208 | 209 | .todo-list li.completed label { 210 | color: #d9d9d9; 211 | text-decoration: line-through; 212 | } 213 | 214 | .todo-list li .destroy { 215 | display: none; 216 | position: absolute; 217 | top: 0; 218 | right: 10px; 219 | bottom: 0; 220 | width: 40px; 221 | height: 40px; 222 | margin: auto 0; 223 | font-size: 30px; 224 | color: #cc9a9a; 225 | margin-bottom: 11px; 226 | transition: color 0.2s ease-out; 227 | } 228 | 229 | .todo-list li .destroy:hover { 230 | color: #af5b5e; 231 | } 232 | 233 | .todo-list li .destroy:after { 234 | content: '×'; 235 | } 236 | 237 | .todo-list li:hover .destroy { 238 | display: block; 239 | } 240 | 241 | span.error { 242 | width: 200px; 243 | position: absolute; 244 | top: 25px; 245 | left: 50%; 246 | margin-left: -100px; 247 | font-size: 2em; 248 | font-weight: bolder; 249 | border: solid 1px #E44234; 250 | border-radius: 5px; 251 | padding-top: 5px; 252 | padding-bottom: 5px; 253 | text-align: center; 254 | background: #E44234; 255 | color: white; 256 | } 257 | 258 | .footer { 259 | color: #777; 260 | padding: 10px 15px; 261 | height: 20px; 262 | text-align: center; 263 | border-top: 1px solid #e6e6e6; 264 | } 265 | 266 | .footer:before { 267 | content: ''; 268 | position: absolute; 269 | right: 0; 270 | bottom: 0; 271 | left: 0; 272 | height: 50px; 273 | overflow: hidden; 274 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 275 | 0 8px 0 -3px #f6f6f6, 276 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 277 | 0 16px 0 -6px #f6f6f6, 278 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 279 | } 280 | 281 | .todo-count { 282 | float: left; 283 | text-align: left; 284 | } 285 | 286 | .todo-count strong { 287 | font-weight: 300; 288 | } 289 | 290 | .filters { 291 | margin: 0; 292 | padding: 0; 293 | list-style: none; 294 | position: absolute; 295 | right: 0; 296 | left: 0; 297 | } 298 | 299 | .filters li { 300 | display: inline; 301 | } 302 | 303 | .filters li a { 304 | color: inherit; 305 | margin: 3px; 306 | padding: 3px 7px; 307 | text-decoration: none; 308 | border: 1px solid transparent; 309 | border-radius: 3px; 310 | } 311 | 312 | .filters li a.selected, 313 | .filters li a:hover { 314 | border-color: rgba(175, 47, 47, 0.1); 315 | } 316 | 317 | .filters li a.selected { 318 | border-color: rgba(175, 47, 47, 0.2); 319 | } 320 | 321 | .info { 322 | margin: 65px auto 0; 323 | color: #bfbfbf; 324 | font-size: 10px; 325 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 326 | text-align: center; 327 | } 328 | 329 | .info p { 330 | line-height: 1; 331 | } 332 | 333 | .info a { 334 | color: inherit; 335 | text-decoration: none; 336 | font-weight: 400; 337 | } 338 | 339 | .info a:hover { 340 | text-decoration: underline; 341 | } 342 | 343 | /* 344 | Hack to remove background from Mobile Safari. 345 | Can't use it globally since it destroys checkboxes in Firefox 346 | */ 347 | @media screen and (-webkit-min-device-pixel-ratio:0) { 348 | .toggle-all, 349 | .todo-list li .toggle { 350 | background: none; 351 | } 352 | 353 | .todo-list li .toggle { 354 | height: 40px; 355 | } 356 | 357 | .toggle-all { 358 | -webkit-transform: rotate(90deg); 359 | transform: rotate(90deg); 360 | -webkit-appearance: none; 361 | appearance: none; 362 | } 363 | } 364 | 365 | @media (max-width: 430px) { 366 | .footer { 367 | height: 50px; 368 | } 369 | 370 | .filters { 371 | bottom: 10px; 372 | } 373 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | const HtmlWebpackPluginConfig = new HtmlWebpackPlugin({ 5 | template: './src/index.html', 6 | filename: 'index.html', 7 | inject: 'body' 8 | }) 9 | 10 | module.exports = { 11 | entry: './src/index.js', 12 | output: { 13 | path: path.join(__dirname, 'build'), 14 | filename: 'app.bundle.js' 15 | }, 16 | module: { 17 | loaders: [ 18 | { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, 19 | { test: /\.css$/, use: ['style-loader', 'css-loader'], exclude: /node_modules/ } 20 | ] 21 | }, 22 | devtool: 'source-map', 23 | plugins: [HtmlWebpackPluginConfig] 24 | } 25 | --------------------------------------------------------------------------------