├── .gitignore ├── FUTURE.md ├── README.md └── webapp ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── src ├── app │ ├── app.tsx │ ├── components │ │ └── components.tsx │ ├── service │ │ ├── routing.ts │ │ └── todoService.ts │ ├── state │ │ └── appState.ts │ └── todomvc │ │ └── css.ts ├── common │ └── types.ts └── server │ └── server.ts ├── templates └── index.html ├── tsconfig.json ├── tsconfig.server.json ├── tsconfig.webpack.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .alm/ 3 | .vscode/ 4 | node_modules 5 | -------------------------------------------------------------------------------- /FUTURE.md: -------------------------------------------------------------------------------- 1 | # Future Ideas 2 | 3 | ## Cypress Runner Dom Snapshots 4 | * Takes snapshots at each command execution 5 | * Takes before and after snapshots for things like XHR requests 6 | * Inspect snapshot with chorme-dev-tools 7 | * Use "select an element" toold in chrome-dev-tools. 8 | * Click a snapshot to pin it 9 | 10 | ## Mock Network requests and responses 11 | 12 | ## Test MobX application state 13 | https://github.com/basarat/typescript-book/issues/441 14 | 15 | ## Breakpoints 16 | * Open devtools 17 | 18 | Two ways 19 | * `debugger` statement in application code 20 | * Debugging test code 21 | * Debug command: https://docs.cypress.io/api/commands/debug.html# 22 | * `.then(()=> debugger)` 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maintainable web application testing with cypress 2 | > Testing how it should be 3 | 4 | Docs / Code to an accompanying video course on cypress web application testing. 5 | 6 | ## Setup 7 | Setup the `webapp`: 8 | * cd webapp 9 | * `npm install` 10 | * `npm start` 11 | 12 | For any lesson: 13 | * cd `01` (e.g) 14 | * `npm install` 15 | * `npm start` 16 | 17 | ## Course 18 | * https://egghead.io/courses/maintainable-web-application-testing-with-cypress 19 | 20 | ## Lessons 21 | * Set up Cypress and TypeScript - Cypress can easily be integrated into any web application in its own dedicated folder. In this lesson we cover how to add cypress with TypeScript support into an existing application without touching any of the existing code. This provides you with a reproducible pattern that you can carry out in your web applications along with a copy pasteable starting point so you don’t need to repeat these steps again and again. 22 | 23 | * Command - Execution Separation in Cypress - Cypress works on top of commands. In this lesson we look at command / run separation along with best practices for chaining cypress commands. Cypress commands greatly improve your debugging experience with automatic logs and dom snapshots. 24 | 25 | * Implicit Assertions and Automatic Retries in Cypress - We also cover implicit assertions and automatic retries which further decrease the noise in your test code and simultaneously increase test stability. 26 | 27 | * Assert Behaviour with Cypress should Command - The should command is your key way to add assertions to cypress tests. It can be used to carry out chai / chai jquery assertions using chainers. In this lesson we take a look at this `.should` command. 28 | 29 | * Reuse Application Config in a Cypress Test - You can use any of your browser application code in cypress tests. This is because cypress tests run the same way your browser code runs. In this lesson we cover how to create reusable config modules that remove the brittle coupling between conventional E2E tests and application code. 30 | 31 | * Unit Test Application Code with Cypress - Cypress can also be used for unit testing. In this lesson we demonstrate testing a piece of utility application logic in isolation (without navigating to a url) in a cypress test. 32 | 33 | * Create and Interact with Reusable Page Objects in Cypress - A common testing convention is creating objects that provide a convenient handle for all the interactions that various tests need to do with a page. In this lesson we create a page object for cypress and then interact with it in tests. 34 | 35 | * Execute Multiple Cypress Commands in a Loop - In this lesson we look at cypress `each` command which can be used to carry out additional cypress commands in a loop. 36 | -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | 4 | # Build assets 5 | public/build 6 | public/index.html 7 | lib/ 8 | db.json 9 | 10 | # IDE 11 | .alm 12 | .vscode 13 | -------------------------------------------------------------------------------- /webapp/README.md: -------------------------------------------------------------------------------- 1 | # Webapp 2 | 3 | ## Setup 4 | * `npm install` 5 | 6 | ## Dev 7 | * `npm start` 8 | 9 | And visit `http://localhost:8080` 10 | 11 | # Contributing 12 | Guidance on depdendencies: 13 | * Transpiler: 14 | * typescript 15 | * Dev Frontend: 16 | * webpack 17 | * webpack-dev-server 18 | * ts-loader 19 | * html-webpack-plugin 20 | * clean-webpack-plugin 21 | * Dev Backend: 22 | * ts-node-dev 23 | * Dev both: 24 | * concurrently 25 | * Exec frontend: 26 | * react 27 | * react-dom 28 | * mobx 29 | * mobx-react 30 | * typestyle 31 | * csstips 32 | * Exec backend: 33 | * express 34 | * lowdb 35 | * Exec both: 36 | * axios 37 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-typescript", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/basarat/react-typescript.git" 8 | }, 9 | "scripts": { 10 | "clean": "rm -rf ./node_modules package-lock.json && npm install", 11 | "build:check": "tsc -p .", 12 | "build:server": "tsc -p ./tsconfig.server.json", 13 | "build:webpack": "webpack -p", 14 | "build": "npm run build:check && npm run build:server && npm run build:webpack", 15 | "start:server": "ts-node-dev src/server/server.ts", 16 | "start:webpack": "webpack-dev-server -d --content-base ./public", 17 | "start": "concurrently \"npm run start:server\" \"npm run start:webpack\"" 18 | }, 19 | "dependencies": { 20 | "@types/express": "4.16.0", 21 | "@types/lowdb": "1.0.6", 22 | "@types/react": "16.7.13", 23 | "@types/react-dom": "16.0.11", 24 | "axios": "0.18.0", 25 | "clean-webpack-plugin": "1.0.0", 26 | "concurrently": "4.1.0", 27 | "cors": "2.8.5", 28 | "express": "4.16.4", 29 | "formstate": "1.0.2", 30 | "html-webpack-plugin": "3.2.0", 31 | "lowdb": "1.0.0", 32 | "mobx": "4.3.1", 33 | "mobx-react": "5.4.2", 34 | "react": "16.6.3", 35 | "react-dom": "16.6.3", 36 | "takeme": "0.11.1", 37 | "ts-loader": "5.3.1", 38 | "ts-node-dev": "1.0.0-pre.31", 39 | "typescript": "3.2.1", 40 | "typestyle": "2.0.1", 41 | "uuid": "3.3.2", 42 | "webpack": "4.27.1", 43 | "webpack-cli": "3.1.2", 44 | "webpack-dev-server": "^3.1.14" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /webapp/src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import { loadTodoMVCCSS } from './todomvc/css'; 5 | import { App } from './components/components'; 6 | 7 | loadTodoMVCCSS(); 8 | 9 | ReactDOM.render( 10 | , 11 | document.getElementById("root") 12 | ); 13 | -------------------------------------------------------------------------------- /webapp/src/app/components/components.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import { FieldState } from 'formstate'; 4 | import { classNames } from '../todomvc/css'; 5 | import { appState } from '../state/appState'; 6 | import { routerState, link, routes } from '../service/routing'; 7 | 8 | export const App: React.SFC<{}> = observer((props) => { 9 | return ( 10 | <> 11 |
12 |
13 | {appState.hasTodos &&
} 14 | {appState.hasTodos &&
} 15 |
16 | 17 | 18 | ); 19 | }); 20 | 21 | export const Header: React.SFC<{ 22 | }> = observer(() => { 23 | const fieldState = appState.current; 24 | 25 | return (
26 |

todos

27 | fieldState.onChange(e.target.value)} 33 | onKeyDown={e => { 34 | if (e.keyCode == 13) { 35 | appState.addCurrentItem(); 36 | } 37 | }} 38 | /> 39 |
); 40 | }); 41 | 42 | export const Main: React.SFC<{}> = observer(() => { 43 | return ( 44 |
45 | 46 | 47 |
    48 | {appState.visibleList.map(item => { 49 | return ( 50 |
  • 56 |
    57 | appState.toggle(item)} 60 | /> 61 | 62 |
    65 | {!!appState.editingTodoMessage && 66 | appState.editingTodoMessage.onChange(e.target.value)} 69 | onKeyDown={e => { 70 | if (e.keyCode == 13) { 71 | appState.submitEditing(); 72 | } 73 | else if (e.keyCode == 27) { 74 | appState.cancelEditing(); 75 | } 76 | }} 77 | onBlur={() => appState.cancelEditing()} 78 | autoFocus={true} 79 | /> 80 | } 81 |
  • 82 | ); 83 | })} 84 |
85 |
86 | ); 87 | }); 88 | 89 | export const Footer: React.SFC<{}> = observer(() => { 90 | return ( 91 |
92 | {appState.todoCount} {appState.todoCount == 1 ? 'item' : 'items'} left 93 | 100 | { 101 | appState.todoCount > 0 && 102 | 103 | } 104 |
105 | ); 106 | }); 107 | 108 | export const Info = () => { 109 | return ( 110 |
111 |

Double-click to edit a todo

112 |

Created by @basarat

113 |
114 | ); 115 | } -------------------------------------------------------------------------------- /webapp/src/app/service/routing.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | 3 | export const routes = { 4 | all: '/', 5 | active: '/active', 6 | completed: '/completed' 7 | } 8 | 9 | export type Routes = 10 | |'all' 11 | | 'active' 12 | | 'completed'; 13 | 14 | export class RouterState { 15 | @observable 16 | route: Routes = 'all'; 17 | } 18 | 19 | export const routerState = new RouterState(); 20 | 21 | import { Router } from 'takeme'; 22 | export { navigate, link } from 'takeme'; 23 | 24 | new Router([ 25 | { 26 | $: routes.active, 27 | enter: () => routerState.route = 'active' 28 | }, 29 | { 30 | $: routes.completed, 31 | enter: () => routerState.route = 'completed' 32 | }, 33 | { 34 | $: routes.all, 35 | enter: () => routerState.route = 'all', 36 | } 37 | ]).init(); 38 | -------------------------------------------------------------------------------- /webapp/src/app/service/todoService.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { TodoItem, API } from '../../common/types'; 3 | 4 | export const apiRoot = 'http://localhost:3000/api'; 5 | 6 | export const create = (body: API.create.Request) => { 7 | return axios 8 | .post(apiRoot + API.create.endpoint, body) 9 | .then(res => res.data); 10 | } 11 | 12 | export const getAll = () => { 13 | return axios.get(apiRoot + API.getAll.endpoint) 14 | .then(res => res.data); 15 | } 16 | 17 | export const setAll = (body: API.setAll.Request) => { 18 | return axios.put<{}>(apiRoot + API.setAll.endpoint, body) 19 | .then(res => res.data); 20 | } 21 | -------------------------------------------------------------------------------- /webapp/src/app/state/appState.ts: -------------------------------------------------------------------------------- 1 | import { observable, action, computed } from 'mobx'; 2 | import { FieldState } from 'formstate'; 3 | import { TodoItem } from '../../common/types'; 4 | import { getAll, create, setAll } from '../service/todoService'; 5 | import { routerState } from '../service/routing'; 6 | 7 | 8 | class AppState { 9 | constructor() { 10 | this.loadItems(); 11 | } 12 | 13 | @observable 14 | items: TodoItem[] = []; 15 | 16 | @observable 17 | current = new FieldState(''); 18 | 19 | @computed 20 | get hasTodos() { 21 | return this.items.length !== 0; 22 | } 23 | 24 | @computed 25 | get todoCount() { 26 | return this.items.filter(i => i.completed == false).length; 27 | } 28 | 29 | @computed 30 | get visibleList() { 31 | return routerState.route == 'all' 32 | ? this.items 33 | : routerState.route == 'active' 34 | ? this.items.filter(i => i.completed == false) 35 | : this.items.filter(i => i.completed == true) 36 | } 37 | 38 | @action 39 | async addCurrentItem() { 40 | if (this.current.value.trim() === '') return; 41 | const { id } = await create({ message: this.current.value }); 42 | this.items.push({ 43 | id, 44 | completed: false, 45 | message: this.current.value 46 | }); 47 | this.current.onChange(''); 48 | } 49 | 50 | @action 51 | async loadItems() { 52 | const { todos } = await getAll(); 53 | this.items = todos; 54 | } 55 | 56 | @action 57 | async toggle(item: TodoItem) { 58 | item.completed = !item.completed; 59 | setAll({ todos: this.items }); 60 | } 61 | 62 | @action 63 | async destroy(item: TodoItem) { 64 | this.items = this.items.filter(i => i.id !== item.id); 65 | setAll({ todos: this.items }); 66 | } 67 | 68 | @action 69 | clearCompleted(): void { 70 | this.items = this.items.filter(i => i.completed == false); 71 | setAll({ todos: this.items }); 72 | } 73 | 74 | @observable 75 | editingId: string | null = null; 76 | @observable 77 | editingTodoMessage: null | FieldState = null; 78 | 79 | @action 80 | setEditing(item: TodoItem) { 81 | this.editingId = item.id; 82 | this.editingTodoMessage = new FieldState(item.message); 83 | } 84 | 85 | @action 86 | cancelEditing() { 87 | this.editingId = null; 88 | this.editingTodoMessage = null; 89 | } 90 | 91 | @action 92 | async submitEditing() { 93 | const todo = this.items.find(i => i.id === this.editingId); 94 | todo.message = this.editingTodoMessage.value; 95 | setAll({ todos: this.items }); 96 | this.cancelEditing(); 97 | } 98 | } 99 | 100 | export const appState = new AppState(); 101 | -------------------------------------------------------------------------------- /webapp/src/app/todomvc/css.ts: -------------------------------------------------------------------------------- 1 | 2 | import { cssRaw } from 'typestyle'; 3 | /** 4 | * https://raw.githubusercontent.com/tastejs/todomvc-app-css/master/index.css 5 | */ 6 | export function loadTodoMVCCSS() { 7 | cssRaw(` 8 | html, 9 | body { 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | button { 15 | margin: 0; 16 | padding: 0; 17 | border: 0; 18 | background: none; 19 | font-size: 100%; 20 | vertical-align: baseline; 21 | font-family: inherit; 22 | font-weight: inherit; 23 | color: inherit; 24 | -webkit-appearance: none; 25 | appearance: none; 26 | -webkit-font-smoothing: antialiased; 27 | -moz-osx-font-smoothing: grayscale; 28 | } 29 | 30 | body { 31 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 32 | line-height: 1.4em; 33 | background: #f5f5f5; 34 | color: #4d4d4d; 35 | min-width: 230px; 36 | max-width: 550px; 37 | margin: 0 auto; 38 | -webkit-font-smoothing: antialiased; 39 | -moz-osx-font-smoothing: grayscale; 40 | font-weight: 300; 41 | } 42 | 43 | :focus { 44 | outline: 0; 45 | } 46 | 47 | .hidden { 48 | display: none; 49 | } 50 | 51 | .todoapp { 52 | background: #fff; 53 | margin: 130px 0 40px 0; 54 | position: relative; 55 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 56 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 57 | } 58 | 59 | .todoapp input::-webkit-input-placeholder { 60 | font-style: italic; 61 | font-weight: 300; 62 | color: #e6e6e6; 63 | } 64 | 65 | .todoapp input::-moz-placeholder { 66 | font-style: italic; 67 | font-weight: 300; 68 | color: #e6e6e6; 69 | } 70 | 71 | .todoapp input::input-placeholder { 72 | font-style: italic; 73 | font-weight: 300; 74 | color: #e6e6e6; 75 | } 76 | 77 | .todoapp h1 { 78 | position: absolute; 79 | top: -155px; 80 | width: 100%; 81 | font-size: 100px; 82 | font-weight: 100; 83 | text-align: center; 84 | color: rgba(175, 47, 47, 0.15); 85 | -webkit-text-rendering: optimizeLegibility; 86 | -moz-text-rendering: optimizeLegibility; 87 | text-rendering: optimizeLegibility; 88 | } 89 | 90 | .new-todo, 91 | .edit { 92 | position: relative; 93 | margin: 0; 94 | width: 100%; 95 | font-size: 24px; 96 | font-family: inherit; 97 | font-weight: inherit; 98 | line-height: 1.4em; 99 | border: 0; 100 | color: inherit; 101 | padding: 6px; 102 | border: 1px solid #999; 103 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 104 | box-sizing: border-box; 105 | -webkit-font-smoothing: antialiased; 106 | -moz-osx-font-smoothing: grayscale; 107 | } 108 | 109 | .new-todo { 110 | padding: 16px 16px 16px 60px; 111 | border: none; 112 | background: rgba(0, 0, 0, 0.003); 113 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 114 | } 115 | 116 | .main { 117 | position: relative; 118 | z-index: 2; 119 | border-top: 1px solid #e6e6e6; 120 | } 121 | 122 | .toggle-all { 123 | width: 1px; 124 | height: 1px; 125 | border: none; /* Mobile Safari */ 126 | opacity: 0; 127 | position: absolute; 128 | right: 100%; 129 | bottom: 100%; 130 | } 131 | 132 | .toggle-all + label { 133 | width: 60px; 134 | height: 34px; 135 | font-size: 0; 136 | position: absolute; 137 | top: -52px; 138 | left: -13px; 139 | -webkit-transform: rotate(90deg); 140 | transform: rotate(90deg); 141 | } 142 | 143 | .toggle-all + label:before { 144 | content: '❯'; 145 | font-size: 22px; 146 | color: #e6e6e6; 147 | padding: 10px 27px 10px 27px; 148 | } 149 | 150 | .toggle-all:checked + label:before { 151 | color: #737373; 152 | } 153 | 154 | .todo-list { 155 | margin: 0; 156 | padding: 0; 157 | list-style: none; 158 | } 159 | 160 | .todo-list li { 161 | position: relative; 162 | font-size: 24px; 163 | border-bottom: 1px solid #ededed; 164 | } 165 | 166 | .todo-list li:last-child { 167 | border-bottom: none; 168 | } 169 | 170 | .todo-list li.editing { 171 | border-bottom: none; 172 | padding: 0; 173 | } 174 | 175 | .todo-list li.editing .edit { 176 | display: block; 177 | width: 506px; 178 | padding: 12px 16px; 179 | margin: 0 0 0 43px; 180 | } 181 | 182 | .todo-list li.editing .view { 183 | display: none; 184 | } 185 | 186 | .todo-list li .toggle { 187 | text-align: center; 188 | width: 40px; 189 | /* auto, since non-WebKit browsers doesn't support input styling */ 190 | height: auto; 191 | position: absolute; 192 | top: 0; 193 | bottom: 0; 194 | margin: auto 0; 195 | border: none; /* Mobile Safari */ 196 | -webkit-appearance: none; 197 | appearance: none; 198 | } 199 | 200 | .todo-list li .toggle { 201 | opacity: 0; 202 | } 203 | 204 | .todo-list li .toggle + label { 205 | /* 206 | Firefox requires '#' to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 207 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the '#' - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 208 | */ 209 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 210 | background-repeat: no-repeat; 211 | background-position: center left; 212 | } 213 | 214 | .todo-list li .toggle:checked + label { 215 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); 216 | } 217 | 218 | .todo-list li label { 219 | word-break: break-all; 220 | padding: 15px 15px 15px 60px; 221 | display: block; 222 | line-height: 1.2; 223 | transition: color 0.4s; 224 | } 225 | 226 | .todo-list li.completed label { 227 | color: #d9d9d9; 228 | text-decoration: line-through; 229 | } 230 | 231 | .todo-list li .destroy { 232 | display: none; 233 | position: absolute; 234 | top: 0; 235 | right: 10px; 236 | bottom: 0; 237 | width: 40px; 238 | height: 40px; 239 | margin: auto 0; 240 | font-size: 30px; 241 | color: #cc9a9a; 242 | margin-bottom: 11px; 243 | transition: color 0.2s ease-out; 244 | } 245 | 246 | .todo-list li .destroy:hover { 247 | color: #af5b5e; 248 | } 249 | 250 | .todo-list li .destroy:after { 251 | content: '×'; 252 | } 253 | 254 | .todo-list li:hover .destroy { 255 | display: block; 256 | } 257 | 258 | .todo-list li .edit { 259 | display: none; 260 | } 261 | 262 | .todo-list li.editing:last-child { 263 | margin-bottom: -1px; 264 | } 265 | 266 | .footer { 267 | color: #777; 268 | padding: 10px 15px; 269 | height: 20px; 270 | text-align: center; 271 | border-top: 1px solid #e6e6e6; 272 | } 273 | 274 | .footer:before { 275 | content: ''; 276 | position: absolute; 277 | right: 0; 278 | bottom: 0; 279 | left: 0; 280 | height: 50px; 281 | overflow: hidden; 282 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 283 | 0 8px 0 -3px #f6f6f6, 284 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 285 | 0 16px 0 -6px #f6f6f6, 286 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 287 | } 288 | 289 | .todo-count { 290 | float: left; 291 | text-align: left; 292 | } 293 | 294 | .todo-count strong { 295 | font-weight: 300; 296 | } 297 | 298 | .filters { 299 | margin: 0; 300 | padding: 0; 301 | list-style: none; 302 | position: absolute; 303 | right: 0; 304 | left: 0; 305 | } 306 | 307 | .filters li { 308 | display: inline; 309 | } 310 | 311 | .filters li a { 312 | color: inherit; 313 | margin: 3px; 314 | padding: 3px 7px; 315 | text-decoration: none; 316 | border: 1px solid transparent; 317 | border-radius: 3px; 318 | } 319 | 320 | .filters li a:hover { 321 | border-color: rgba(175, 47, 47, 0.1); 322 | } 323 | 324 | .filters li a.selected { 325 | border-color: rgba(175, 47, 47, 0.2); 326 | } 327 | 328 | .clear-completed, 329 | html .clear-completed:active { 330 | float: right; 331 | position: relative; 332 | line-height: 20px; 333 | text-decoration: none; 334 | cursor: pointer; 335 | } 336 | 337 | .clear-completed:hover { 338 | text-decoration: underline; 339 | } 340 | 341 | .info { 342 | margin: 65px auto 0; 343 | color: #bfbfbf; 344 | font-size: 10px; 345 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 346 | text-align: center; 347 | } 348 | 349 | .info p { 350 | line-height: 1; 351 | } 352 | 353 | .info a { 354 | color: inherit; 355 | text-decoration: none; 356 | font-weight: 400; 357 | } 358 | 359 | .info a:hover { 360 | text-decoration: underline; 361 | } 362 | 363 | /* 364 | Hack to remove background from Mobile Safari. 365 | Can't use it globally since it destroys checkboxes in Firefox 366 | */ 367 | @media screen and (-webkit-min-device-pixel-ratio:0) { 368 | .toggle-all, 369 | .todo-list li .toggle { 370 | background: none; 371 | } 372 | 373 | .todo-list li .toggle { 374 | height: 40px; 375 | } 376 | } 377 | 378 | @media (max-width: 430px) { 379 | .footer { 380 | height: 50px; 381 | } 382 | 383 | .filters { 384 | bottom: 10px; 385 | } 386 | } 387 | `); 388 | } 389 | 390 | 391 | /** 392 | * Provided by the css above 393 | * Relevant html in https://github.com/tastejs/todomvc-app-template/blob/master/index.html 394 | */ 395 | export const classNames = { 396 | /** root */ 397 | app: 'todoapp', 398 | 399 | /** root:header */ 400 | header: 'header', 401 | newTodo: 'new-todo', 402 | 403 | /** root:main */ 404 | main: 'main', 405 | toggleAll: 'toggle-all', 406 | todoList: 'todo-list', 407 | completed: 'completed', 408 | editing: 'editing', 409 | view: 'view', 410 | toggle: 'toggle', 411 | destroy: 'destroy', 412 | edit: 'edit', 413 | 414 | /** root:footer */ 415 | footer: 'footer', 416 | todoCount: 'todo-count', 417 | clearCompleted: 'clear-completed', 418 | 419 | /** root:footer:filters */ 420 | filters: 'filters', 421 | selected: 'selected', 422 | 423 | /** root:info */ 424 | info: 'info' 425 | } 426 | -------------------------------------------------------------------------------- /webapp/src/common/types.ts: -------------------------------------------------------------------------------- 1 | export type TodoItem = { 2 | id: string, 3 | completed: boolean, 4 | message: string, 5 | } 6 | 7 | 8 | export namespace API { 9 | export namespace create { 10 | export const endpoint = '/add'; 11 | export type Request = { 12 | message: string 13 | } 14 | export type Response = { 15 | id: string 16 | } 17 | } 18 | 19 | export namespace getAll { 20 | export const endpoint = '/get-all'; 21 | export type Response = { 22 | todos: TodoItem[] 23 | } 24 | } 25 | 26 | export namespace setAll { 27 | export const endpoint = '/set-all'; 28 | export type Request = { 29 | todos: TodoItem[] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /webapp/src/server/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import low from 'lowdb'; 4 | import uuid from 'uuid/v4'; 5 | import FileSync from 'lowdb/adapters/FileSync'; 6 | import { TodoItem, API } from '../common/types'; 7 | 8 | /** 9 | * Setup db 10 | */ 11 | type DBSchema = { 12 | items: TodoItem[] 13 | } 14 | const adapter = new FileSync('db.json', { 15 | defaultValue: { 16 | items: [] 17 | } 18 | }); 19 | const db = low(adapter); 20 | 21 | /** 22 | * API server 23 | */ 24 | const app = express(); 25 | const api = express.Router(); 26 | 27 | api.use(cors(), express.json()); 28 | api.get(API.getAll.endpoint, (_, res) => { 29 | res.send({ todos: db.get('items') }); 30 | }); 31 | api.post(API.create.endpoint, (req, res: express.Response) => { 32 | const id = uuid(); 33 | const request: API.create.Request = req.body; 34 | db.get('items') 35 | .push({ 36 | id: id, 37 | completed: false, 38 | message: request.message 39 | }) 40 | .write(); 41 | res.send({ id }); 42 | }); 43 | api.put(API.setAll.endpoint, (req, res: express.Response) => { 44 | const request: API.setAll.Request = req.body; 45 | db.set('items', request.todos) 46 | .write(); 47 | res.send({}); 48 | }); 49 | 50 | app.use('/api', api); 51 | 52 | /** Start */ 53 | app.listen(3000, '0.0.0.0'); 54 | -------------------------------------------------------------------------------- /webapp/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /webapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src" 4 | ], 5 | "compileOnSave": false, 6 | "compilerOptions": { 7 | "noEmit": true, 8 | "sourceMap": true, 9 | "module": "commonjs", 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "experimentalDecorators": true, 13 | "target": "es5", 14 | "jsx": "react", 15 | "lib": [ 16 | "dom", 17 | "es6" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /webapp/tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "src/app" 5 | ], 6 | "compilerOptions": { 7 | "noEmit": false, 8 | "outDir": "./lib" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /webapp/tsconfig.webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false 5 | } 6 | } -------------------------------------------------------------------------------- /webapp/webpack.config.js: -------------------------------------------------------------------------------- 1 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './src/app/app.tsx', 6 | plugins: [ 7 | new CleanWebpackPlugin(['public/build']), 8 | new HtmlWebpackPlugin({ 9 | template: 'templates/index.html' 10 | }), 11 | ], 12 | output: { 13 | path: __dirname + '/public', 14 | filename: 'build/[name].[contenthash].js' 15 | }, 16 | resolve: { 17 | extensions: ['.ts', '.tsx', '.js'] 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.tsx?$/, 23 | loader: 'ts-loader', 24 | options: { 25 | configFile: 'tsconfig.webpack.json' 26 | } 27 | } 28 | ] 29 | } 30 | } 31 | --------------------------------------------------------------------------------