├── .babelrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── icons ├── icon.svg ├── icon128x128.png ├── icon16x16.png ├── icon256x256.png ├── icon32x32.png ├── icon48x48.png ├── icon512x512.png └── icon60x60.png ├── index.html ├── karma.conf.js ├── manifest.webapp ├── package.json ├── screenshots └── screenshot-1.png ├── scripts ├── auth.js ├── components │ └── App.js ├── index.js ├── models.js └── store.js ├── server.js ├── styles ├── fira.less ├── fonts │ ├── FiraMono-Regular.eot │ ├── FiraSans-Bold.eot │ ├── FiraSans-Bold.woff │ ├── FiraSans-Bold.woff2 │ ├── FiraSans-Regular.eot │ ├── FiraSans-Regular.woff │ └── FiraSans-Regular.woff2 ├── loader.less └── main.less ├── test ├── auth_test.js ├── components │ └── app_test.js ├── models_test.js └── store_test.js ├── tests.webpack.js ├── webpack.config.js └── webpack.prod.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | assets 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - '0.12' 5 | addons: 6 | firefox: "39.0" 7 | before_install: 8 | - export DISPLAY=:99.0 9 | - sh -e /etc/init.d/xvfb start 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased](https://github.com/leplatrem/Routina/tree/HEAD) 4 | 5 | [Full Changelog](https://github.com/leplatrem/Routina/compare/1.2...HEAD) 6 | 7 | Nothing changed yet. 8 | 9 | ## [1.2](https://github.com/leplatrem/Routina/tree/1.1) (2015-10-06) 10 | 11 | **New features** 12 | 13 | - Add link to allow authentication on Firefox OS [\#30](https://github.com/leplatrem/Routina/pulls/30) 14 | - Offer to load samples when list is empty [\#26](https://github.com/leplatrem/Routina/pulls/26) 15 | 16 | **Bug fixes** 17 | 18 | - Fix form fields placeholders [\#25](https://github.com/leplatrem/Routina/pulls/25) 19 | - Fix loader apparence and width 20 | 21 | ## [1.1](https://github.com/leplatrem/Routina/tree/1.1) (2015-08-20) 22 | 23 | **New features** 24 | 25 | - Show permalink to ease sharing of lists [\#19](https://github.com/leplatrem/Routina/pulls/19) 26 | - Add weeks period [\#21](https://github.com/leplatrem/Routina/pulls/21) 27 | - Auto-detect online/offline status [\#22](https://github.com/leplatrem/Routina/pulls/22) 28 | - Auto-sync while online [\#24](https://github.com/leplatrem/Routina/pulls/24) 29 | 30 | **Bug fixes** 31 | 32 | - Fixes in development environment [\#18](https://github.com/leplatrem/Routina/pulls/18) 33 | - Minor fixes in margins and apparence [\#18](https://github.com/leplatrem/Routina/pulls/18) 34 | 35 | ## [1.0](https://github.com/leplatrem/Routina/tree/1.0) (2015-08-18) 36 | 37 | **Initial version** 38 | 39 | - Integration of kinto-react-boilerplate [\#9](https://github.com/leplatrem/Routina/issues/9) 40 | - Basic synchronization on Mozilla Kinto demo server using Basic Authentication 41 | - Routina models [\#1](https://github.com/leplatrem/Routina/issues/1) 42 | - Autorefresh list [\#10](https://github.com/leplatrem/Routina/issues/10) 43 | - Mobile first UI with Bootstrap [\#11](https://github.com/leplatrem/Routina/issues/11) 44 | - Packaging for Firefox OS [\#6](https://github.com/leplatrem/Routina/issues/6) 45 | 46 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 - Mathieu Leplatre 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Routina 2 | 3 | > Periodical tasks, rescheduled when checked. 4 | 5 | [![Build Status](https://travis-ci.org/leplatrem/Routina.svg?branch=master)](https://travis-ci.org/leplatrem/Routina) 6 | 7 | **Routina** is a *todo list* where the tasks have a period and are 8 | automatically rescheduled when marked as done. 9 | 10 | It works offline. 11 | 12 | You can use it [in your browser](https://leplatrem.github.io/Routina/) or [install it as an app](https://leplatrem.github.io/Routina/install.html) (*Firefox Desktop/Android/OS*)! 13 | 14 | ![screenshot](screenshots/screenshot-1.png) 15 | 16 | ## Roadmap 17 | 18 | * Firefox Account [login](https://github.com/Kinto/kinto-react-boilerplate/pull/16) (#2) and [Firefox OS integration](https://developer.mozilla.org/en-US/docs/Firefox-Accounts-on-FirefoxOS) (#13) 19 | * Synchronize automatically when user is online (#4) 20 | * [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) (#16) 21 | * [Alarm API](https://developer.mozilla.org/en-US/docs/Web/API/Alarm_API) notifications (#17) 22 | * Settings page to control remote server and options (#15) 23 | * Encrypt data before sync (#14) 24 | 25 | ## Hack 26 | 27 | Development 28 | 29 | * ``npm install``: Install dependencies 30 | * ``npm start``: Run app locally (with autorefresh) 31 | * ``npm test``: Run tests 32 | 33 | Production 34 | 35 | * ``npm run build``: Package every assets for production 36 | * ``npm run package``: Open Web app package 37 | 38 | ## Credits 39 | 40 | Original idea by [Elisenda](http://github.com/elisenda/). 41 | 42 | ![icon](icons/icon128x128.png) 43 | * Clock by [Micthev](https://commons.wikimedia.org/wiki/File:Clock_02-30.svg), CC-BY-SA 44 | * Mouse by [Xfce Team](https://commons.wikimedia.org/wiki/File:Xfce_logo-footprint.svg), CC-BY-SA 45 | 46 | This application is based on the [Kinto React boilerplate](https://github.com/Kinto/kinto-react-boilerplate). 47 | 48 | Data is synchronized on the [Mozilla Kinto demo server](http://kinto.readthedocs.org). 49 | 50 | ## License 51 | 52 | * Apache License Version 2.0 53 | -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 26 | 30 | 34 | 38 | 42 | 44 | 48 | 52 | 53 | 55 | 63 | 71 | 79 | 87 | 88 | 90 | 97 | 105 | 113 | 120 | 128 | 136 | 137 | 139 | 146 | 154 | 162 | 170 | 171 | 178 | 185 | 192 | 199 | 206 | 213 | 220 | 227 | 234 | 241 | 245 | 249 | 253 | 257 | 261 | 265 | 269 | 273 | 277 | 278 | 286 | 290 | 294 | 295 | 302 | 309 | 316 | 323 | 324 | 348 | 352 | 356 | 357 | 359 | 360 | 362 | image/svg+xml 363 | 365 | 366 | 367 | 368 | 369 | 374 | 379 | 384 | 385 | 389 | 397 | 402 | 407 | 412 | 417 | 418 | 423 | 427 | 432 | 433 | 437 | 442 | 443 | 447 | 452 | 453 | 457 | 462 | 463 | 467 | 472 | 473 | 477 | 482 | 483 | 487 | 492 | 493 | 497 | 502 | 503 | 507 | 512 | 513 | 517 | 522 | 523 | 527 | 532 | 533 | 537 | 542 | 543 | 547 | 552 | 553 | 557 | 562 | 563 | 567 | 572 | 573 | 577 | 582 | 583 | 587 | 592 | 593 | 597 | 602 | 603 | 607 | 612 | 613 | 617 | 622 | 623 | 627 | 632 | 633 | 637 | 642 | 643 | 647 | 652 | 653 | 657 | 662 | 663 | 667 | 672 | 673 | 677 | 682 | 683 | 687 | 692 | 693 | 697 | 702 | 703 | 707 | 712 | 713 | 717 | 722 | 723 | 727 | 732 | 733 | 737 | 742 | 743 | 747 | 752 | 753 | 757 | 762 | 763 | 767 | 772 | 773 | 777 | 782 | 783 | 787 | 792 | 793 | 797 | 802 | 803 | 807 | 812 | 813 | 817 | 822 | 823 | 827 | 832 | 833 | 837 | 842 | 843 | 847 | 852 | 853 | 857 | 862 | 863 | 867 | 872 | 873 | 877 | 882 | 883 | 887 | 892 | 893 | 898 | 903 | 908 | 913 | 918 | 924 | 930 | 934 | 939 | 940 | 941 | 942 | 947 | 950 | 955 | 960 | 965 | 970 | 978 | 983 | 988 | 989 | 990 | 993 | 996 | 1001 | 1006 | 1011 | 1016 | 1024 | 1029 | 1034 | 1035 | 1036 | 1042 | 1048 | 1054 | 1060 | 1066 | 1072 | 1078 | 1079 | 1087 | 1088 | -------------------------------------------------------------------------------- /icons/icon128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon128x128.png -------------------------------------------------------------------------------- /icons/icon16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon16x16.png -------------------------------------------------------------------------------- /icons/icon256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon256x256.png -------------------------------------------------------------------------------- /icons/icon32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon32x32.png -------------------------------------------------------------------------------- /icons/icon48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon48x48.png -------------------------------------------------------------------------------- /icons/icon512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon512x512.png -------------------------------------------------------------------------------- /icons/icon60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon60x60.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Routina 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | browsers: ["Firefox"], 4 | singleRun: true, 5 | frameworks: ["chai", "mocha", "sinon"], 6 | plugins: [ 7 | "karma-firefox-launcher", 8 | "karma-chai", 9 | "karma-mocha", 10 | "karma-sinon", 11 | "karma-sourcemap-loader", 12 | "karma-webpack", 13 | ], 14 | files: [ 15 | "tests.webpack.js" 16 | ], 17 | preprocessors: { 18 | "tests.webpack.js": ["webpack", "sourcemap"] 19 | }, 20 | reporters: ["dots"], 21 | webpack: { 22 | devtool: ["eval", "inline-source-map"], 23 | module: { 24 | loaders: [ 25 | { 26 | test: /\.js$/, 27 | loader: "babel-loader", 28 | exclude: /node_modules/ 29 | } 30 | ] 31 | } 32 | }, 33 | webpackServer: { 34 | noInfo: true 35 | } 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /manifest.webapp: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Routina", 3 | "version": "1.1", 4 | "installs_allowed_from": ["*"], 5 | "description": "Periodical tasks, rescheduled when checked.", 6 | "launch_path": "/index.html", 7 | "default_locale": "en", 8 | "permissions": { 9 | "storage": { 10 | "description": "Required to use the application while offline" 11 | }, 12 | "alarms": { 13 | "description": "Required to schedule upcoming tasks" 14 | }, 15 | "desktop-notification": { 16 | "description": "Required to alert about overdue tasks" 17 | } 18 | }, 19 | "icons": { 20 | "16": "/icons/icon16x16.png", 21 | "32": "/icons/icon32x32.png", 22 | "48": "/icons/icon48x48.png", 23 | "60": "/icons/icon60x60.png", 24 | "128": "/icons/icon128x128.png", 25 | "256": "/icons/icon256x256.png", 26 | "512": "/icons/icon512x512.png" 27 | }, 28 | "developer": { 29 | "name": "Mathieu Leplatre", 30 | "url": "http://mathieu-leplatre.info" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routina", 3 | "version": "1.1.0", 4 | "description": "Periodical tasks, rescheduled when checked", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "test": "node_modules/.bin/mocha --compilers js:babel/register 'test/*_test.js' && node_modules/.bin/karma start", 9 | "build": "NODE_ENV=production node_modules/.bin/webpack --optimize-minimize --config webpack.prod.config.js", 10 | "package": "npm run build && zip -r routina.zip assets icons index.html manifest.webapp" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/leplatrem/Routina.git" 15 | }, 16 | "keywords": [ 17 | "kinto", 18 | "react", 19 | "todolist" 20 | ], 21 | "author": "Mathieu Leplatre ", 22 | "license": "APL-2.0", 23 | "bugs": { 24 | "url": "https://github.com/leplatrem/Routina/issues" 25 | }, 26 | "homepage": "https://github.com/leplatrem/Routina#readme", 27 | "dependencies": { 28 | "bootstrap": "^3.3.5", 29 | "btoa": "^1.1.2", 30 | "kinto": "^1.0.0-rc.5", 31 | "moment": "^2.10.6", 32 | "react": "^0.13.3", 33 | "uuid": "^2.0.1" 34 | }, 35 | "devDependencies": { 36 | "babel": "^5.8.20", 37 | "babel-loader": "^5.3.2", 38 | "chai": "^3.2.0", 39 | "css-loader": "^0.15.6", 40 | "file-loader": "^0.8.4", 41 | "karma": "^0.13.3", 42 | "karma-chai": "^0.1.0", 43 | "karma-cli": "^0.1.0", 44 | "karma-firefox-launcher": "^0.1.6", 45 | "karma-mocha": "^0.2.0", 46 | "karma-sinon": "^1.0.4", 47 | "karma-sourcemap-loader": "^0.3.5", 48 | "karma-webpack": "^1.7.0", 49 | "less": "^2.5.1", 50 | "less-loader": "^2.2.0", 51 | "mocha": "^2.2.5", 52 | "react-hot-loader": "^1.2.8", 53 | "sinon": "git://github.com/uberVU/Sinon.JS.git#0aaf834c9f", 54 | "style-loader": "^0.12.3", 55 | "url-loader": "^0.5.6", 56 | "webpack": "^1.10.5", 57 | "webpack-dev-server": "^1.10.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /screenshots/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/screenshots/screenshot-1.png -------------------------------------------------------------------------------- /scripts/auth.js: -------------------------------------------------------------------------------- 1 | import btoa from "btoa"; 2 | import { v4 as uuid4 } from "uuid"; 3 | import { EventEmitter } from "events"; 4 | 5 | 6 | export class Auth extends EventEmitter { 7 | 8 | constructor(server, store) { 9 | super(); 10 | this.server = server; 11 | this.store = store; 12 | this.headers = {}; 13 | this.token = null; 14 | } 15 | 16 | loginURI(website) { 17 | const login = this.server.replace("v1", "v1/fxa-oauth/login?redirect="); 18 | const currentWebsite = website.replace(/#.*/, ''); 19 | const redirect = encodeURIComponent(currentWebsite + '#fxa:'); 20 | return login + redirect; 21 | } 22 | 23 | authenticate(token='') { 24 | // Take last token from store or generate BasicAuth user with uuid4. 25 | if (!token) { 26 | token = this.store.getItem("lastToken") || uuid4(); 27 | } 28 | this.token = token; 29 | this.store.setItem("lastToken", token); 30 | this.authenticated = false; 31 | 32 | if (token.indexOf('fxa:') === 0) { 33 | // Fxa token passed in URL from redirection. 34 | let bearerToken = token.replace('fxa:', ''); 35 | this.headers.Authorization = 'Bearer ' + bearerToken; 36 | this.authenticated = true; 37 | this.token = ''; // Forget token. 38 | this.userid = ''; // XXX: fetch from profile server. 39 | } 40 | else { 41 | // Token provided via hash, but no FxA. 42 | // Use Basic Auth as before. 43 | let userpass64 = btoa(token + ":s3cr3t"); 44 | this.userid = userpass64; 45 | this.headers.Authorization = 'Basic ' + userpass64; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/components/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import moment from "moment"; 3 | 4 | 5 | import { Routine } from "../../scripts/models"; 6 | 7 | 8 | export class Form extends React.Component { 9 | 10 | constructor(props) { 11 | super(props); 12 | this.state = {record: this.props.record || this.newRoutine()}; 13 | } 14 | 15 | newRoutine() { 16 | return new Routine("", {value: 3, unit: "days"}); 17 | } 18 | 19 | onFormSubmit(event) { 20 | event.preventDefault(); 21 | this.props.saveRecord(this.state.record); 22 | this.setState({record: this.newRoutine()}); 23 | } 24 | 25 | onChange(field, event) { 26 | var value = event.target.value; 27 | switch (field) { 28 | case "value": 29 | value = parseInt(value, 10); 30 | case "unit": 31 | Object.assign(this.state.record.period, {[field]: value}); 32 | break; 33 | default: 34 | Object.assign(this.state.record, {[field]: value}); 35 | } 36 | this.setState({record: this.state.record}); 37 | } 38 | 39 | render() { 40 | const record = this.state.record; 41 | const creation = !record.id; 42 | return ( 43 |
44 | 49 | 53 | 59 | 62 |
63 | ); 64 | } 65 | } 66 | 67 | 68 | export class Item extends React.Component { 69 | 70 | static get defaultProps() { 71 | return { 72 | onEdit: () => {}, 73 | onDelete: () => {}, 74 | onSave: () => {} 75 | }; 76 | } 77 | 78 | onCheck() { 79 | this.props.item.check(); 80 | this.props.onSave(this.props.item); 81 | } 82 | 83 | render() { 84 | if (this.props.editing) { 85 | return ( 86 |
  • 87 | 90 |
    91 |
  • 92 | ); 93 | } 94 | 95 | const shorten = time => time ? time.replace(/minutes?/, "min.") 96 | .replace(/seconds?/, "sec.") : ""; 97 | 98 | const item = this.props.item; 99 | const next = moment(item.next).fromNow().replace("ago", "late"); 100 | const last = item.last ? shorten(moment(item.last).fromNow()) : "Never"; 101 | const unit = shorten(item.period.unit); 102 | 103 | return ( 104 |
  • 105 | 108 |
    109 | {item.label} 110 | {next} 111 |
    112 |
    113 | 114 | Every 115 | {item.period.value} 116 | {unit} 117 | 118 | {last} 119 |
    120 | 123 |
  • 124 | ); 125 | } 126 | } 127 | 128 | 129 | export class List extends React.Component { 130 | 131 | static get defaultProps() { 132 | return { 133 | editItem: () => {}, 134 | updateRecord: () => {}, 135 | deleteRecord: () => {}, 136 | }; 137 | } 138 | 139 | constructor(props) { 140 | super(props); 141 | this.state = {}; 142 | } 143 | 144 | onEdit(index) { 145 | this.setState({current: index}); 146 | this.props.editItem(index); 147 | } 148 | 149 | onSave(record) { 150 | this.setState({current: null}); 151 | this.props.updateRecord(record); 152 | } 153 | 154 | onDelete(record) { 155 | this.setState({current: null}); 156 | this.props.deleteRecord(record); 157 | } 158 | 159 | render() { 160 | return ( 161 | 171 | ); 172 | } 173 | } 174 | 175 | 176 | export default class App extends React.Component { 177 | 178 | constructor(props) { 179 | super(props); 180 | this.state = this.props.store.state; 181 | 182 | this.props.store.load(); 183 | if (this.props.auth) { 184 | this.syncRecords(); 185 | } 186 | } 187 | 188 | componentDidMount() { 189 | this.props.store.on("online", state => { 190 | this.setState({online: state}); 191 | }); 192 | this.props.store.on("busy", state => { 193 | this.setState(Object.assign({busy: state}, state ? {error: ""} : {})); 194 | }); 195 | this.props.store.on("change", state => { 196 | this.props.store.autorefresh = true; 197 | this.setState(state); 198 | }); 199 | this.props.store.on("error", error => { 200 | this.props.store.autorefresh = true; 201 | this.setState({error: error.message}); 202 | }); 203 | } 204 | 205 | addSamples() { 206 | const samples = [ 207 | new Routine("Smile", {value: 40, unit: "seconds"}), 208 | new Routine("Stretch neck", {value: 2, unit: "hours"}), 209 | new Routine("Call mum", {value: 5, unit: "days"}), 210 | new Routine("Gym", {value: 1, unit: "weeks"}), 211 | new Routine("Eat cheesecake", {value: 3, unit: "weeks"}), 212 | new Routine("Change bed sheets", {value: 15, unit: "days"}), 213 | new Routine("Periods", {value: 28, unit: "days"}), 214 | new Routine("Water cactus", {value: 8, unit: "weeks"}), 215 | new Routine("Visit dentist", {value: 6, unit: "months"}), 216 | new Routine("Tree blossom", {value: 1, unit: "years"}), 217 | ]; 218 | samples.map(this.createRecord.bind(this)); 219 | } 220 | 221 | editItem() { 222 | // Stop auto-refresh while item is being edited. 223 | this.props.store.autorefresh = false; 224 | } 225 | 226 | createRecord(record) { 227 | this.props.store.create(record); 228 | } 229 | 230 | deleteRecord(record) { 231 | this.props.store.delete(record); 232 | } 233 | 234 | updateRecord(record) { 235 | this.props.store.update(record); 236 | } 237 | 238 | syncRecords() { 239 | this.props.store.sync(); 240 | } 241 | 242 | render() { 243 | const busy = this.state.busy; 244 | const online = this.state.online; 245 | 246 | const syncDisabled = (busy || !online) ? "disabled" : ""; 247 | const syncIcon = this.state.online ? (busy ? "refresh" : "cloud-upload") : "alert"; 248 | const syncLabel = this.state.online ? (busy ? "Syncing..." : "Sync") : "Offline"; 249 | 250 | var signIn = ''; 251 | var permaLink = ''; 252 | if (this.props.auth) { 253 | if (!this.props.auth.authenticated) { 254 | signIn = ( 255 | 256 | 257 | Sign in 258 | 259 | ); 260 | } 261 | if (this.props.auth.token) { 262 | permaLink = ( 263 | 264 | 265 | Permalink 266 | 267 | ); 268 | } 269 | } 270 | 271 | return ( 272 |
    273 | {busy ?
    : ""} 274 |
    {this.state.error}
    275 | { (this.state.items.length > 0 || busy) ? 276 | : 280 |
    281 |

    No items found.

    282 | 283 |
    284 | } 285 |
    286 | 287 |
    288 |
    289 | 293 | {permaLink} 294 | {signIn} 295 |
    296 |
    297 | ); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /scripts/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Webpack inclusions. 3 | */ 4 | require("bootstrap/less/bootstrap.less"); 5 | require("../styles/main.less"); 6 | 7 | /* 8 | * Routina. 9 | */ 10 | import "babel/polyfill"; 11 | import React from "react"; 12 | import App from "./components/App"; 13 | import { Store } from "./store"; 14 | import { Auth } from "./auth"; 15 | import Kinto from "kinto"; 16 | 17 | const server = "https://kinto.dev.mozaws.net/v1"; 18 | 19 | // Migrate from Routina v1.1. 20 | window.localStorage.setItem("lastToken", (window.localStorage.getItem("lastuser") || 21 | window.localStorage.getItem("lastToken"))); 22 | 23 | // Authenticate using location hash. 24 | const auth = new Auth(server, window.localStorage); 25 | auth.authenticate(window.location.hash.slice(1)); 26 | window.location.hash = auth.token; 27 | 28 | const headers = Object.assign({}, auth.headers); 29 | const kinto = new Kinto({remote: server, dbPrefix: auth.userid, headers: headers}); 30 | 31 | const store = new Store(kinto, "routina-v1"); 32 | store.online = window.navigator.onLine; 33 | window.addEventListener("offline", () => {store.online = false;}); 34 | window.addEventListener("online", () => {store.online = true;}); 35 | 36 | React.render(, document.getElementById("app")); 37 | -------------------------------------------------------------------------------- /scripts/models.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | 4 | export class Routine { 5 | 6 | /** 7 | * A periodic task that is automatically rescheduled 8 | * when checked. 9 | * @param {String} label The task label. 10 | * @return {Object} period An object with two properties 11 | ``value`` and ``unit``. 12 | */ 13 | constructor (label, period={}) { 14 | this.label = label; 15 | this.period = period; 16 | this.occurences = []; 17 | } 18 | 19 | /** 20 | * Status constants. 21 | */ 22 | static get status() { 23 | return { 24 | OK: "ok", 25 | WARNING: "warning", 26 | OVERDUE: "overdue", 27 | CRITICAL: "critical", 28 | }; 29 | } 30 | 31 | /** 32 | * Period unit constants. 33 | */ 34 | static get units() { 35 | return ["seconds", "minutes", "hours", "days", "weeks", "months", "years"]; 36 | } 37 | 38 | /** 39 | * Serialize object with occurences as ISO strings. 40 | * @return {Object} flat mapping. 41 | */ 42 | serialize() { 43 | const serialized = Object.assign({}, this); 44 | serialized.occurences = serialized.occurences.map(d => d.toISOString()); 45 | return serialized; 46 | } 47 | 48 | /** 49 | * Deserialize mapping into a Routine object. 50 | * @param {Object} flat mapping. 51 | * @return {Routine} instantiated object. 52 | */ 53 | static deserialize(serialized) { 54 | const routine = new Routine(); 55 | Object.assign(routine, serialized); 56 | routine.occurences = routine.occurences.map(d => new Date(d)); 57 | return routine; 58 | } 59 | 60 | get _period () { 61 | return moment.duration(this.period.value, this.period.unit); 62 | } 63 | 64 | /** 65 | * Returns the current status, critical for largely missed 66 | * occurence, warning if close, ok if far. 67 | */ 68 | get status() { 69 | let period = this._period.as('seconds'); 70 | let threshold = period * 0.1; 71 | 72 | if (this.timeleft < -period) { 73 | return Routine.status.CRITICAL; 74 | } 75 | if (this.timeleft < -threshold) { 76 | return Routine.status.OVERDUE; 77 | } 78 | if (this.timeleft < threshold) { 79 | return Routine.status.WARNING; 80 | } 81 | return Routine.status.OK; 82 | } 83 | 84 | /** 85 | * Returns the number of seconds since last occurence. 86 | * @return {Integer} 87 | */ 88 | get elapsed () { 89 | return moment() 90 | .diff(moment(this.last), 'seconds'); 91 | } 92 | 93 | /** 94 | * Returns the number of seconds until next occurence. 95 | * @return {Integer} 96 | */ 97 | get timeleft () { 98 | return moment(this.next) 99 | .diff(moment(), 'seconds'); 100 | } 101 | 102 | /** 103 | * Returns the last occurence. 104 | * @return {Date} 105 | */ 106 | get last() { 107 | return this.occurences[this.occurences.length - 1]; 108 | } 109 | 110 | /** 111 | * Returns the next occurence. 112 | * @return {Date} 113 | */ 114 | get next() { 115 | return moment(this.last) 116 | .add(this._period) 117 | .toDate(); 118 | } 119 | 120 | /** 121 | * Mark the routine as done, and reschedule the next one. 122 | */ 123 | check() { 124 | this.occurences.push(new Date()); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /scripts/store.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import Kinto from "kinto"; 3 | 4 | import { Routine } from "./models"; 5 | 6 | 7 | export class Store extends EventEmitter { 8 | 9 | constructor(kinto, collection) { 10 | super(); 11 | this.state = {items: [], online: true, busy: false}; 12 | this.collection = kinto.collection(collection); 13 | } 14 | 15 | deserialize(data) { 16 | return Routine.deserialize(data); 17 | } 18 | 19 | serialize(record) { 20 | return record.serialize(); 21 | } 22 | 23 | set online(state) { 24 | const changed = (this.state.online !== state); 25 | this.state.online = state; 26 | if (changed) this.emit("online", state); 27 | 28 | if (changed && state && this.autorefresh) { 29 | this.sync(); 30 | } 31 | } 32 | 33 | get online () { 34 | return this.state.online; 35 | } 36 | 37 | set busy(state) { 38 | const changed = (this.state.busy !== state); 39 | this.state.busy = state; 40 | if (changed) this.emit("busy", state); 41 | } 42 | 43 | get busy () { 44 | return this.state.busy; 45 | } 46 | 47 | set autorefresh(state) { 48 | if (state) { 49 | if (!this._timer) 50 | this._timer = setInterval(this.emitChange.bind(this), 5000); 51 | } 52 | else if (this._timer) { 53 | this._timer = clearInterval(this._timer); 54 | } 55 | } 56 | 57 | get autorefresh() { 58 | return !!this._timer; 59 | } 60 | 61 | onError(error) { 62 | this.busy = false; 63 | this.emit("error", error); 64 | } 65 | 66 | emitChange(options={autosync: false}) { 67 | const sorted = this.state.items.sort((a, b) => a.timeleft - b.timeleft); 68 | this.emit("change", this.state); 69 | 70 | if (options.autosync && this.autorefresh) { 71 | this.sync(); 72 | } 73 | } 74 | 75 | load() { 76 | return this.collection.list() 77 | .then(res => { 78 | const instances = res.data.map(this.deserialize); 79 | this.state.items = instances; 80 | this.emitChange(); 81 | }) 82 | .catch(this.onError.bind(this)); 83 | } 84 | 85 | create(record) { 86 | return this.collection.create(this.serialize(record)) 87 | .then(res => { 88 | const instance = this.deserialize(res.data); 89 | this.state.items.push(instance); 90 | this.emitChange({autosync: true}); 91 | }) 92 | .catch(this.onError.bind(this)); 93 | } 94 | 95 | update(record) { 96 | return this.collection.update(this.serialize(record)) 97 | .then(res => { 98 | this.state.items = this.state.items.map(item => { 99 | const instance = this.deserialize(res.data); 100 | return item.id === record.id ? instance : item; 101 | }); 102 | this.emitChange({autosync: true}); 103 | }) 104 | .catch(this.onError.bind(this)); 105 | } 106 | 107 | delete(record) { 108 | return this.collection.delete(record.id) 109 | .then(res => { 110 | this.state.items = this.state.items.filter(item => { 111 | return item.id !== record.id; 112 | }); 113 | this.emitChange({autosync: true}); 114 | }) 115 | .catch(this.onError.bind(this)); 116 | } 117 | 118 | sync() { 119 | if (!this.state.online) { 120 | return; 121 | } 122 | if (this.state.busy) { 123 | // XXX: re-schedule another sync. 124 | return; 125 | } 126 | 127 | this.busy = true; 128 | return this.collection.sync({strategy: Kinto.syncStrategy.SERVER_WINS}) 129 | .then((res) => { 130 | this.busy = false; 131 | if (res.ok) { 132 | return this.load(); 133 | } 134 | }) 135 | .catch(this.onError.bind(this)); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | var config = require('./webpack.config'); 4 | var path = require("path"); 5 | 6 | new WebpackDevServer(webpack(config), { 7 | contentBase: path.resolve(__dirname), 8 | publicPath: config.output.publicPath, 9 | hot: true, 10 | historyApiFallback: true 11 | }).listen(3000, '0.0.0.0', function (err, result) { 12 | if (err) { 13 | console.log(err); 14 | } 15 | 16 | console.log('Listening at localhost:3000'); 17 | }); 18 | -------------------------------------------------------------------------------- /styles/fira.less: -------------------------------------------------------------------------------- 1 | @font-face{ 2 | font-family: 'Fira Sans'; 3 | src: url('fonts/FiraSans-Regular.eot'); 4 | src: local('Fira Sans Regular'), 5 | url('fonts/FiraSans-Regular.woff') format('woff'), 6 | url('fonts/FiraSans-Regular.woff2') format('woff2'); 7 | font-weight: 400; 8 | font-style: normal; 9 | } 10 | 11 | 12 | @font-face{ 13 | font-family: 'Fira Sans'; 14 | src: url('fonts/FiraSans-Bold.eot'); 15 | src: local('Fira Sans Bold'), 16 | url('fonts/FiraSans-Bold.woff') format('woff'), 17 | url('fonts/FiraSans-Bold.woff2') format('woff2'); 18 | font-weight: 700; 19 | font-style: normal; 20 | } 21 | -------------------------------------------------------------------------------- /styles/fonts/FiraMono-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraMono-Regular.eot -------------------------------------------------------------------------------- /styles/fonts/FiraSans-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Bold.eot -------------------------------------------------------------------------------- /styles/fonts/FiraSans-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Bold.woff -------------------------------------------------------------------------------- /styles/fonts/FiraSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Bold.woff2 -------------------------------------------------------------------------------- /styles/fonts/FiraSans-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Regular.eot -------------------------------------------------------------------------------- /styles/fonts/FiraSans-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Regular.woff -------------------------------------------------------------------------------- /styles/fonts/FiraSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Regular.woff2 -------------------------------------------------------------------------------- /styles/loader.less: -------------------------------------------------------------------------------- 1 | /* 2 | * source: http://codepen.io/brunjo/pen/XJmbNz 3 | */ 4 | @height: 3px; 5 | @color: #2980b9; 6 | 7 | .loader { 8 | height: 4px; 9 | width: 100%; 10 | left: 0px; 11 | top: 0px; 12 | position: fixed; 13 | overflow: hidden; 14 | background-color: #ddd; 15 | z-index: 999; 16 | 17 | &:before{ 18 | display: block; 19 | position: absolute; 20 | content: ""; 21 | left: -200px; 22 | width: 200px; 23 | height: 4px; 24 | background-color: @color; 25 | animation: loading 2s linear infinite; 26 | } 27 | } 28 | @keyframes loading { 29 | from {left: -200px; width: 30%;} 30 | 50% {width: 30%;} 31 | 70% {width: 70%;} 32 | 80% { left: 50%;} 33 | 95% {left: 120%;} 34 | to {left: 100%;} 35 | } 36 | -------------------------------------------------------------------------------- /styles/main.less: -------------------------------------------------------------------------------- 1 | @import "./fira.less"; 2 | @import "./loader.less"; 3 | 4 | 5 | /** 6 | * Utils 7 | */ 8 | 9 | .nopad { 10 | padding-left: 0px; 11 | padding-right: 0px; 12 | } 13 | 14 | .hbox { 15 | display: flex; 16 | flex-direction: row; 17 | justify-content: space-between; 18 | align-items: center; 19 | 20 | & > *, & > *:first-child { 21 | margin-left: @spacing; 22 | } 23 | & > *:last-child { 24 | margin-right: @spacing; 25 | } 26 | } 27 | 28 | .fit { 29 | flex-grow: 1; 30 | } 31 | 32 | .pull-end { 33 | margin-left: auto; 34 | } 35 | 36 | 37 | /* 38 | * Routina style. 39 | */ 40 | 41 | @font: Fira Sans; 42 | @error: red; 43 | @annotation: #888; 44 | @decoration: #ddd; 45 | 46 | @spacing: 4px; 47 | 48 | @ok: green; 49 | @warning: #ffd800; 50 | @overdue: #FF7E00; 51 | @critical: #d70000; 52 | 53 | 54 | /* Check button Less macro */ 55 | .status (@color) { 56 | box-shadow: inset 4px 0 0 0 @color; 57 | background-color: fadeout(@color, 80%); 58 | color: @color; 59 | } 60 | 61 | 62 | html, body { 63 | width: 100%; 64 | padding: 0px; 65 | margin-bottom: @spacing; 66 | font-family: @font; 67 | } 68 | 69 | .btn.default { 70 | background-color: transparent; 71 | border-color: @decoration; 72 | } 73 | 74 | .error { 75 | color: @error; 76 | } 77 | 78 | li { 79 | 80 | &.hbox { 81 | align-items: stretch; 82 | } 83 | 84 | &.ok button.check { 85 | .status(@ok); 86 | } 87 | 88 | &.warning button.check { 89 | .status(@warning); 90 | } 91 | 92 | &.overdue button.check { 93 | .status(@overdue); 94 | } 95 | 96 | &.critical button.check { 97 | .status(@critical); 98 | } 99 | 100 | .routine { 101 | font-size: 1.2em; 102 | font-weight: bold; 103 | } 104 | 105 | .next { 106 | color: @annotation; 107 | padding-left: 2px; 108 | display: block; 109 | } 110 | 111 | .details { 112 | align-self: center; 113 | 114 | & > span { 115 | display: block; 116 | width: 100%; 117 | text-align: center; 118 | } 119 | 120 | .last { 121 | color: @annotation; 122 | 123 | &::before { 124 | content: "("; 125 | } 126 | &::after { 127 | content: ")"; 128 | } 129 | } 130 | 131 | .period { 132 | .value::before, 133 | .unit::before { 134 | content: "\a0"; 135 | } 136 | } 137 | } 138 | } 139 | 140 | .samples { 141 | h1 { 142 | color: @decoration; 143 | } 144 | text-align: center; 145 | margin-bottom: 1em; 146 | } 147 | 148 | .create { 149 | margin-bottom: 20px; 150 | padding-left: @spacing; 151 | padding-right: @spacing; 152 | 153 | form { 154 | padding: @spacing 0px; 155 | border-radius: 4px; 156 | border: 1px solid @decoration; 157 | } 158 | } 159 | 160 | form { 161 | 162 | &.hbox, &.hbox:last-child { 163 | margin: 0px; 164 | } 165 | 166 | .form-control { 167 | margin-left: @spacing; 168 | padding: 6px 3px; 169 | } 170 | 171 | input[name='label'] { 172 | min-width: 0; 173 | display: inline; 174 | } 175 | 176 | input[name='value'] { 177 | max-width: 4em; 178 | } 179 | 180 | button[type='submit'] { 181 | margin-left: @spacing; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /test/auth_test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import sinon from "sinon"; 3 | 4 | import { Auth } from "../scripts/auth"; 5 | 6 | 7 | class FakeStorage { 8 | setItem() {} 9 | getItem() {} 10 | } 11 | 12 | 13 | describe("Auth", () => { 14 | 15 | var sandbox; 16 | var auth; 17 | var store; 18 | 19 | beforeEach(() => { 20 | sandbox = sinon.sandbox.create(); 21 | store = new FakeStorage(); 22 | auth = new Auth('http://server/v1', store); 23 | }); 24 | 25 | afterEach(() => { 26 | sandbox.restore(); 27 | }); 28 | 29 | 30 | describe("#loginURI", () => { 31 | 32 | it("contains an encoded version of URL with hash", () => { 33 | var result = auth.loginURI("http://routina.com"); 34 | var base = "http://server/v1/fxa-oauth/login?redirect=" 35 | expect(result).to.eql(base + "http%3A%2F%2Froutina.com%23fxa%3A"); 36 | }); 37 | 38 | }); 39 | 40 | 41 | describe("#authenticate()", () => { 42 | 43 | it("takes last token from store to authenticate", () => { 44 | sandbox.stub(store, "getItem").returns('existing'); 45 | auth.authenticate(''); 46 | expect(auth.headers.Authorization).to.eql('Basic ZXhpc3Rpbmc6czNjcjN0'); 47 | }); 48 | 49 | it("generates a basic token if no token in store", () => { 50 | auth.authenticate(''); 51 | expect(auth.headers.Authorization).to.contain('Basic'); 52 | }); 53 | 54 | it("puts the token in store if provided", () => { 55 | sandbox.stub(store, "setItem"); 56 | auth.authenticate('family-tasks'); 57 | sinon.assert.calledWithExactly(store.setItem, 'lastToken', 'family-tasks'); 58 | }); 59 | 60 | it("sets bearer token if token is prefixed with fxa", () => { 61 | auth.authenticate('fxa:1234567'); 62 | expect(auth.headers.Authorization).to.eql('Bearer 1234567'); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/components/app_test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TestUtils from "react/lib/ReactTestUtils"; 3 | import { expect } from "chai"; 4 | import sinon from "sinon"; 5 | import Kinto from "kinto"; 6 | import moment from "moment"; 7 | 8 | import App, { Form, List, Item } from "../../scripts/components/App"; 9 | import { Store } from "../../scripts/store"; 10 | import { Routine } from "../../scripts/models"; 11 | 12 | 13 | describe("App", () => { 14 | 15 | var sandbox; 16 | var store; 17 | 18 | beforeEach(() => { 19 | sandbox = sinon.sandbox.create(); 20 | const kinto = new Kinto(); 21 | store = new Store(kinto, "items"); 22 | }); 23 | 24 | afterEach(() => { 25 | sandbox.restore(); 26 | }); 27 | 28 | describe("Component", () => { 29 | 30 | var rendered; 31 | 32 | beforeEach(() => { 33 | sandbox.stub(store, "create").returns(Promise.resolve({})); 34 | sandbox.stub(store, "delete").returns(Promise.resolve({})); 35 | sandbox.stub(store, "load").returns(Promise.resolve({})); 36 | sandbox.stub(store, "update").returns(Promise.resolve({})); 37 | sandbox.stub(store, "sync").returns(Promise.resolve({})); 38 | rendered = TestUtils.renderIntoDocument(); 39 | }); 40 | 41 | it("loads items from store on mount", () => { 42 | sinon.assert.calledOnce(store.load); 43 | }); 44 | 45 | it("enables autorefresh on load", () => { 46 | expect(store.autorefresh).to.be.false; 47 | store.emit("change", {items: []}); 48 | expect(store.autorefresh).to.be.true; 49 | }); 50 | 51 | it("renders items of store when store changes", () => { 52 | store.emit("change", {items: [new Routine(":)")]}); 53 | let node = React.findDOMNode(rendered); 54 | expect(node.querySelector("li").textContent).to.contain(":)"); 55 | }); 56 | 57 | it("adds item to the store on form submit", () => { 58 | const node = React.findDOMNode(rendered).querySelector("input"); 59 | TestUtils.Simulate.change(node, {target: {value: "Hello, world"}}); 60 | TestUtils.Simulate.submit(node); 61 | var createArg = store.create.lastCall.args[0]; 62 | expect(createArg.label).to.eql("Hello, world"); 63 | }); 64 | 65 | 66 | describe("Load samples", () => { 67 | beforeEach(() => { 68 | store.emit("change", {items: []}); 69 | }); 70 | 71 | it("show load samples button if empty", () => { 72 | let node = React.findDOMNode(rendered); 73 | expect(node.querySelector(".samples .btn")).to.exist; 74 | }); 75 | 76 | it("load samples on click", () => { 77 | sinon.stub(rendered, "createRecord"); 78 | const node = React.findDOMNode(rendered).querySelector(".samples .btn"); 79 | TestUtils.Simulate.click(node); 80 | expect(rendered.createRecord.callCount).to.eql(10); 81 | }); 82 | }); 83 | 84 | 85 | describe("Sync", () => { 86 | it("syncs store on button click", () => { 87 | const node = React.findDOMNode(rendered).querySelector("button.sync"); 88 | TestUtils.Simulate.click(node); 89 | sinon.assert.calledOnce(store.sync); 90 | }); 91 | 92 | it("shows loader while store is busy", () => { 93 | store.emit("busy", true); 94 | const node = React.findDOMNode(rendered); 95 | expect(node.querySelector(".loader")).to.exist; 96 | }); 97 | 98 | it("disables button while store is busy", () => { 99 | store.emit("busy", true); 100 | const selector = "button.sync[disabled]"; 101 | const node = React.findDOMNode(rendered); 102 | expect(node.querySelector(selector)).to.exist; 103 | store.emit("busy", false); 104 | expect(node.querySelector(selector)).to.not.exist; 105 | }); 106 | 107 | it("changes button label and icon while store is busy", () => { 108 | store.emit("busy", true); 109 | const node = React.findDOMNode(rendered).querySelector("button.sync"); 110 | expect(node.textContent).to.contain("Syncing..."); 111 | expect(node.querySelector(".glyphicon-refresh")).to.exist; 112 | }); 113 | 114 | it("shows message when error happens", () => { 115 | store.emit("error", new Error("Failed")); 116 | const node = React.findDOMNode(rendered); 117 | expect(node.querySelector(".error").textContent).to.eql("Failed"); 118 | }); 119 | 120 | it("clears error message when store is busy", () => { 121 | store.emit("error", new Error("Failed")); 122 | store.emit("busy", true); 123 | const node = React.findDOMNode(rendered); 124 | expect(node.querySelector(".error").textContent).to.eql(""); 125 | }); 126 | }); 127 | 128 | 129 | describe("Authenticated", () => { 130 | var auth; 131 | 132 | beforeEach(() => { 133 | auth = {loginURI: () => {}, authenticated: false}; 134 | rendered = TestUtils.renderIntoDocument(); 135 | }); 136 | 137 | it("sync records on mount if auth is provided", () => { 138 | sinon.assert.calledOnce(store.sync); 139 | }); 140 | 141 | it("shows a signin button", () => { 142 | var node = React.findDOMNode(rendered); 143 | expect(node.querySelectorAll("a.signin").length).to.eql(1); 144 | }); 145 | 146 | it("shows a permalink with location hash", () => { 147 | auth.token = 'mat'; 148 | rendered = TestUtils.renderIntoDocument(); 149 | var node = React.findDOMNode(rendered); 150 | var selector = "a[href='https://leplatrem.github.io/Routina/#mat']" 151 | expect(node.querySelectorAll(selector).length).to.eql(1); 152 | }); 153 | 154 | }); 155 | 156 | 157 | describe("Offline", () => { 158 | beforeEach(() => { 159 | store.online = false; 160 | }); 161 | 162 | it("disables the button while offline", () => { 163 | const selector = "button.sync[disabled]"; 164 | const node = React.findDOMNode(rendered); 165 | expect(node.querySelector(selector)).to.exist; 166 | }); 167 | 168 | it("shows a warning while offline", () => { 169 | const node = React.findDOMNode(rendered).querySelector("button.sync"); 170 | expect(node.textContent).to.contain("Offline"); 171 | expect(node.querySelector(".glyphicon-alert")).to.exist; 172 | }); 173 | }); 174 | 175 | 176 | describe("Editing", () => { 177 | 178 | var record; 179 | 180 | beforeEach(() => { 181 | record = new Routine("Existing"); 182 | record.id = 42; 183 | 184 | // Fill list. 185 | store.emit("change", {items: [record]}); 186 | const item = React.findDOMNode(rendered).querySelector("button.edit"); 187 | // Set an item in edition mode. 188 | TestUtils.Simulate.click(item); 189 | }); 190 | 191 | it("stops store autorefresh on edit", () => { 192 | expect(store.autorefresh).to.be.false; 193 | }); 194 | 195 | it("updates item in the store on edit", () => { 196 | // Change and submit. 197 | const field = React.findDOMNode(rendered).querySelector("input[name='label']"); 198 | TestUtils.Simulate.change(field, {target: {value: "Hola, mundo"}}); 199 | TestUtils.Simulate.submit(field); 200 | var updateArg = store.update.lastCall.args[0]; 201 | expect(updateArg.id).to.eql(42); 202 | expect(updateArg.label).to.eql("Hola, mundo"); 203 | }); 204 | 205 | it("deletes item from the store", () => { 206 | const button = React.findDOMNode(rendered).querySelector("button.delete"); 207 | TestUtils.Simulate.click(button); 208 | sinon.assert.calledWithExactly(store.delete, record); 209 | }); 210 | }); 211 | }); 212 | }); 213 | 214 | 215 | describe("List", () => { 216 | 217 | var rendered; 218 | const items = [new Routine("Hello"), new Routine("World")]; 219 | 220 | beforeEach(() => { 221 | rendered = TestUtils.renderIntoDocument(); 222 | }); 223 | 224 | it("renders without problems", () => { 225 | expect(React.findDOMNode(rendered).tagName).to.equal("UL"); 226 | }); 227 | 228 | it("renders items in list", () => { 229 | expect(React.findDOMNode(rendered).querySelectorAll("li").length).to.equal(2); 230 | }); 231 | 232 | 233 | describe("Editing", () => { 234 | 235 | var node; 236 | var editItemCallback, updateCallback, deleteCallback; 237 | 238 | beforeEach(() => { 239 | editItemCallback = sinon.spy(); 240 | updateCallback = sinon.spy(); 241 | deleteCallback = sinon.spy(); 242 | rendered = TestUtils.renderIntoDocument(); 245 | node = React.findDOMNode(rendered); 246 | 247 | TestUtils.Simulate.click(node.querySelector("li:first-child button.edit")); 248 | const field = node.querySelector("input[name='label']"); 249 | TestUtils.Simulate.change(field, {target: {value: "Hola, mundo"}}); 250 | }); 251 | 252 | it("uses callback on edit", () => { 253 | sinon.assert.calledOnce(editItemCallback); 254 | }); 255 | 256 | it("renders as form on item click", () => { 257 | expect(node.querySelectorAll("form").length).to.equal(1); 258 | }); 259 | 260 | it("hides form after save", () => { 261 | TestUtils.Simulate.submit(node.querySelector("form")); 262 | expect(node.querySelectorAll("form").length).to.equal(0); 263 | }); 264 | 265 | it("allows editing only one item at a time", () => { 266 | TestUtils.Simulate.click(node.querySelector("li:last-child button.edit")); 267 | expect(node.querySelectorAll("form").length).to.equal(1); 268 | }); 269 | 270 | it("allows editing the same item several times", () => { 271 | TestUtils.Simulate.submit(node.querySelector("form")); 272 | TestUtils.Simulate.click(node.querySelector("li:first-child button.edit")); 273 | expect(node.querySelectorAll("form").length).to.equal(1); 274 | }); 275 | 276 | it("cancels edition when other item is clicked", () => { 277 | TestUtils.Simulate.click(node.querySelector("li:last-child button.edit")); 278 | const form = node.querySelectorAll("li:first-child form"); 279 | expect(form.length).to.equal(0); 280 | }); 281 | 282 | it("uses callback on save", () => { 283 | TestUtils.Simulate.submit(node.querySelector("form")); 284 | var updateArg = updateCallback.lastCall.args[0]; 285 | expect(updateArg.label).to.eql("Hola, mundo"); 286 | }); 287 | 288 | it("uses callback on delete", () => { 289 | TestUtils.Simulate.click(node.querySelector("li > button")); 290 | sinon.assert.calledOnce(deleteCallback); 291 | }); 292 | 293 | it("hides form after delete", () => { 294 | TestUtils.Simulate.click(node.querySelector("li > button")); 295 | expect(node.querySelectorAll("form").length).to.equal(0); 296 | }); 297 | }); 298 | }); 299 | 300 | 301 | describe("Item", () => { 302 | 303 | var rendered; 304 | var record; 305 | var node; 306 | 307 | beforeEach(() => { 308 | record = new Routine("Value", {value: 4, unit: "days"}); 309 | record.check(); 310 | rendered = TestUtils.renderIntoDocument(); 311 | node = React.findDOMNode(rendered); 312 | }); 313 | 314 | it("renders without problems", () => { 315 | expect(React.findDOMNode(rendered).tagName).to.equal("LI"); 316 | }); 317 | 318 | it("renders routine label in a routine span", () => { 319 | expect(node.querySelector("span.routine").textContent).to.equal("Value"); 320 | }); 321 | 322 | it("renders routine status in item class", () => { 323 | expect(node.className).to.contain("ok"); 324 | }); 325 | 326 | it("renders routine period in a period span", () => { 327 | expect(node.querySelector(".period").textContent).equal("Every4days"); 328 | expect(node.querySelector(".period .value").textContent).equal("4"); 329 | expect(node.querySelector(".period .unit").textContent).equal("days"); 330 | }); 331 | 332 | it("renders routine next occurence in a next span", () => { 333 | expect(node.querySelector(".next").textContent).to.contain("in"); 334 | }); 335 | 336 | it("renders routine last occurence in a last span", () => { 337 | expect(node.querySelector(".last").textContent).to.contain("ago"); 338 | }); 339 | 340 | it("display last as never if never checked", () => { 341 | rendered = TestUtils.renderIntoDocument(); 342 | node = React.findDOMNode(rendered); 343 | expect(node.querySelector(".last").textContent).to.eql("Never"); 344 | }); 345 | 346 | it("display late if task is overdue", () => { 347 | const longTimeAgo = moment().subtract(6, "days").toDate(); 348 | record.occurences = [longTimeAgo]; 349 | rendered = TestUtils.renderIntoDocument(); 350 | node = React.findDOMNode(rendered); 351 | expect(node.querySelector(".next").textContent).to.contain("late"); 352 | }); 353 | 354 | it("uses callback on edit", () => { 355 | var callback = sinon.spy(); 356 | const item = TestUtils.renderIntoDocument(); 357 | node = React.findDOMNode(item).querySelector("button.edit"); 358 | TestUtils.Simulate.click(node); 359 | sinon.assert.calledOnce(callback); 360 | }); 361 | 362 | it("checks routine and call save callback", () => { 363 | var callback = sinon.spy(); 364 | const item = TestUtils.renderIntoDocument(); 365 | node = React.findDOMNode(item).querySelector("button.check"); 366 | expect(record.occurences.length).to.eql(1); 367 | TestUtils.Simulate.click(node); 368 | expect(record.occurences.length).to.eql(2); 369 | sinon.assert.calledWithExactly(callback, record); 370 | }); 371 | 372 | 373 | describe("Editing", () => { 374 | 375 | var saveCallback, deleteCallback; 376 | 377 | beforeEach(() => { 378 | saveCallback = sinon.spy(); 379 | deleteCallback = sinon.spy(); 380 | rendered = TestUtils.renderIntoDocument(); 384 | }); 385 | 386 | it("renders label in field value if editing", () => { 387 | var node = React.findDOMNode(rendered); 388 | expect(node.querySelector("input").value).to.equal("Value"); 389 | }); 390 | 391 | it("uses callback on save", () => { 392 | var newvalue = "Hello, world"; 393 | var field = React.findDOMNode(rendered).querySelector("input[name='label']") 394 | TestUtils.Simulate.change(field, {target: {value: newvalue}}); 395 | TestUtils.Simulate.submit(field); 396 | const saveArg = saveCallback.lastCall.args[0]; 397 | expect(saveArg.label).to.eql(newvalue); 398 | }); 399 | 400 | it("uses callback on delete", () => { 401 | const node = React.findDOMNode(rendered).querySelector("button.delete"); 402 | TestUtils.Simulate.click(node); 403 | sinon.assert.calledOnce(deleteCallback); 404 | }); 405 | }); 406 | }); 407 | 408 | 409 | describe("Form", () => { 410 | 411 | var rendered; 412 | 413 | beforeEach(() => { 414 | rendered = TestUtils.renderIntoDocument(); 415 | }); 416 | 417 | it("renders without problems", () => { 418 | expect(React.findDOMNode(rendered).tagName).to.equal("FORM"); 419 | }); 420 | 421 | it("shows add button if no record", () => { 422 | const button = React.findDOMNode(rendered).querySelector("button"); 423 | expect(button.getAttribute("aria-label")).to.equal("Add"); 424 | }); 425 | 426 | it("sets placeholder if no record", () => { 427 | const button = React.findDOMNode(rendered).querySelector("input"); 428 | expect(button.placeholder).to.equal("New habit"); 429 | }); 430 | 431 | it("contains an fields with default values and a submit button", () => { 432 | const node = React.findDOMNode(rendered); 433 | expect(node.querySelector("input[name='label']").value).to.eql(""); 434 | expect(node.querySelector("input[name='value']").value).to.eql("3"); 435 | expect(node.querySelector("select[name='unit']").value).to.eql("days"); 436 | }); 437 | 438 | it("uses callback on submit", () => { 439 | const callback = sinon.spy(); 440 | rendered = TestUtils.renderIntoDocument(); 441 | const field = React.findDOMNode(rendered).querySelector("input[name='label']") 442 | const newvalue = "Hola, mundo"; 443 | TestUtils.Simulate.change(field, {target: {value: newvalue}}); 444 | TestUtils.Simulate.submit(field); 445 | 446 | const saveArgs = callback.lastCall.args[0]; 447 | expect(saveArgs.label).to.eql(newvalue); 448 | expect(saveArgs.period.value).to.eql(3); 449 | expect(saveArgs.period.unit).to.eql("days"); 450 | }); 451 | 452 | 453 | describe("Editing", () => { 454 | 455 | var callback; 456 | var record; 457 | 458 | beforeEach(() => { 459 | record = new Routine("Value", {value: 12, unit: "years"}); 460 | record.id = 42; 461 | 462 | callback = sinon.spy(); 463 | rendered = TestUtils.renderIntoDocument(); 465 | }); 466 | 467 | it("renders record values in fields", () => { 468 | var node = React.findDOMNode(rendered); 469 | expect(node.querySelector("input[name='label']").value).to.equal("Value"); 470 | expect(node.querySelector("input[name='value']").value).to.equal("12"); 471 | expect(node.querySelector("select[name='unit']").value).to.equal("years"); 472 | }); 473 | 474 | it("updates its state when field change", () => { 475 | const newvalue = "Hola, mundo"; 476 | const field = React.findDOMNode(rendered).querySelector("input[name='label']") 477 | TestUtils.Simulate.change(field, {target: {value: newvalue}}); 478 | expect(rendered.state.record.label).to.equal(newvalue); 479 | }); 480 | 481 | it("updates period attributes when fields change", () => { 482 | const newvalue = "days"; 483 | const field = React.findDOMNode(rendered).querySelector("select[name='unit']") 484 | TestUtils.Simulate.change(field, {target: {value: newvalue}}); 485 | expect(rendered.state.record.period.unit).to.equal(newvalue); 486 | }); 487 | 488 | it("uses callback on submit", () => { 489 | const newvalue = "Hola, mundo"; 490 | const field = React.findDOMNode(rendered).querySelector("input[name='label']") 491 | TestUtils.Simulate.change(field, {target: {value: newvalue}}); 492 | TestUtils.Simulate.submit(field); 493 | 494 | const saveArgs = callback.lastCall.args[0]; 495 | expect(saveArgs.label).to.eql(newvalue); 496 | expect(saveArgs.id).to.eql(record.id); 497 | }); 498 | 499 | it("clears form on submit", () => { 500 | const node = React.findDOMNode(rendered); 501 | TestUtils.Simulate.submit(node); 502 | expect(rendered.state.record.label).to.equal(""); 503 | }); 504 | }); 505 | }); 506 | -------------------------------------------------------------------------------- /test/models_test.js: -------------------------------------------------------------------------------- 1 | import { expect, Assertion } from "chai"; 2 | import moment from "moment"; 3 | import {Routine} from "../scripts/models"; 4 | 5 | 6 | Assertion.addMethod('almost', function (value) { 7 | if (value instanceof Date) { 8 | var diff = moment(this._obj).diff(value, 'seconds'); 9 | new Assertion(diff).to.be.within(-1, 1); 10 | } 11 | else { 12 | new Assertion(this._obj).to.be.within(value - 1, value + 1); 13 | } 14 | }); 15 | 16 | 17 | describe("Routine", () => { 18 | 19 | var routine; 20 | 21 | beforeEach(() => { 22 | let period = {value: 4, unit: "days"}; 23 | routine = new Routine("Call mum", period); 24 | }); 25 | 26 | 27 | describe("New routine", () => { 28 | 29 | it("has time left equal to period in seconds", () => { 30 | let fourDays = 4 * 24 * 3600; 31 | expect(routine.timeleft).to.be.almost(fourDays); 32 | }); 33 | 34 | it("has no past occurence.", () => { 35 | expect(routine.last).to.be.undefined; 36 | }); 37 | 38 | it("has next occurence computed from now", () => { 39 | let inFourDays = moment().add(4, "days").toDate(); 40 | expect(routine.next).to.almost(inFourDays); 41 | }); 42 | 43 | it("has status ok", () => { 44 | expect(routine.status).to.eql(Routine.status.OK); 45 | }); 46 | }); 47 | 48 | 49 | describe("Usual routine", () => { 50 | 51 | beforeEach(() => { 52 | // Simulates checked 1 day ago. 53 | let yesterday = moment().subtract(1, "days").toDate(); 54 | routine.occurences.push(yesterday); 55 | }); 56 | 57 | it("has last occurence.", () => { 58 | expect(routine.last).to.exist; 59 | }); 60 | 61 | it("has elapsed equal seconds since last", () => { 62 | let oneDay = 24 * 3600; 63 | expect(routine.elapsed).to.be.almost(oneDay); 64 | }); 65 | 66 | it("has next occurence computed from last one", () => { 67 | let inThreeDays = moment().add(3, "days").toDate(); 68 | expect(routine.next).to.be.almost(inThreeDays); 69 | }); 70 | 71 | it("has time left equal to period minus elapsed", () => { 72 | let threeDays = 3 * 24 * 3600; 73 | expect(routine.timeleft).to.be.almost(threeDays); 74 | }); 75 | 76 | it("is shifted from now when checked", () => { 77 | routine.check(); 78 | let inFourDays = moment().add(4, "days").toDate(); 79 | expect(routine.next).to.be.almost(inFourDays); 80 | }); 81 | 82 | it("has status ok if next occurence is far", () => { 83 | expect(routine.status).to.eql(Routine.status.OK); 84 | }); 85 | 86 | it("has status warning if next occurence is close", () => { 87 | let threeDaysAgo = moment().subtract(4, "days") 88 | .add(2, "hours") 89 | .toDate(); 90 | routine.occurences.push(threeDaysAgo); 91 | expect(routine.status).to.eql(Routine.status.WARNING); 92 | }); 93 | }); 94 | 95 | 96 | describe("Overdue routine", () => { 97 | 98 | beforeEach(() => { 99 | // Simulates checked 6 days ago. 100 | let longTimeAgo = moment().subtract(6, "days").toDate(); 101 | routine.occurences.push(longTimeAgo); 102 | }); 103 | 104 | it("has negative time left", () => { 105 | expect(routine.timeleft).to.be.negative; 106 | }); 107 | 108 | it("has next occurence in the past", () => { 109 | let comparison = moment(routine.next).isBefore(new Date()); 110 | expect(comparison).to.be.true; 111 | }); 112 | 113 | it("is shifted from now when checked", () => { 114 | routine.check(); 115 | let inFourDays = moment().add(4, "days").toDate(); 116 | expect(routine.next).to.be.almost(inFourDays); 117 | }); 118 | 119 | it("has status warning if missed occurence is close", () => { 120 | let someTimeAgo = moment().subtract(4, "days") 121 | .subtract(2, "hours") 122 | .toDate(); 123 | routine.occurences.push(someTimeAgo); 124 | expect(routine.status).to.eql(Routine.status.WARNING); 125 | }); 126 | 127 | it("has status overdue if missed occurence is far", () => { 128 | expect(routine.status).to.eql(Routine.status.OVERDUE); 129 | }); 130 | 131 | it("has status critical if missed occurence is more than period", () => { 132 | let someTimeAgo = moment().subtract(9, "days") 133 | .toDate(); 134 | routine.occurences.push(someTimeAgo); 135 | expect(routine.status).to.eql(Routine.status.CRITICAL); 136 | }); 137 | }); 138 | 139 | 140 | describe("Serialization", () => { 141 | it("serializes as mapping", () => { 142 | const serialized = routine.serialize(); 143 | expect(serialized).to.eql({ 144 | label: "Call mum", 145 | period: {value: 4, unit: "days"}, 146 | occurences: [] 147 | }); 148 | }); 149 | 150 | it("exports occurences as list of ISO strings", () => { 151 | routine.occurences.push(new Date(Date.UTC(1995, 11, 17))); 152 | const serialized = routine.serialize(); 153 | expect(serialized.occurences).to.eql(["1995-12-17T00:00:00.000Z"]); 154 | }); 155 | 156 | it("exports every Kinto attributes", () => { 157 | routine.id = "42"; 158 | routine._status = "sync"; 159 | routine.last_modified = 234; 160 | const serialized = routine.serialize(); 161 | expect(serialized.id).to.eql("42"); 162 | expect(serialized._status).to.eql("sync"); 163 | expect(serialized.last_modified).to.eql(234); 164 | }); 165 | 166 | it("ignores class attributes", () => { 167 | const serialized = routine.serialize(); 168 | expect(serialized._period).to.not.exist; 169 | }); 170 | }); 171 | 172 | 173 | describe("Deserialization", () => { 174 | const serialized = { 175 | label: "Call dad", 176 | period: {value: 2, unit: "months"}, 177 | occurences: ["1995-12-17T00:00:00.000Z"] 178 | }; 179 | 180 | it("deserializes a mapping", () => { 181 | const routine = Routine.deserialize(serialized); 182 | expect(routine.label).to.eql("Call dad"); 183 | expect(routine.period.value).to.eql(2); 184 | expect(routine.period.unit).to.eql("months"); 185 | }); 186 | 187 | it("import occurences from ISO to native dates", () => { 188 | const routine = Routine.deserialize(serialized); 189 | expect(routine.occurences).to.eql([new Date(Date.UTC(1995, 11, 17))]); 190 | }); 191 | 192 | it("import serialized attributes", () => { 193 | serialized._status = "sync"; 194 | const routine = Routine.deserialize(serialized); 195 | expect(routine._status).to.eql("sync"); 196 | }) 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /test/store_test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import sinon from "sinon"; 3 | import Kinto from "kinto"; 4 | 5 | import { Store } from "../scripts/store"; 6 | import { Routine } from "../scripts/models"; 7 | 8 | 9 | describe("Store", () => { 10 | 11 | var sandbox; 12 | var store; 13 | 14 | var sample = {id: 1, label: "Hola!"}; 15 | 16 | beforeEach((done) => { 17 | sandbox = sinon.sandbox.create(); 18 | const kinto = new Kinto(); 19 | store = new Store(kinto, "items"); 20 | 21 | sandbox.stub(store.collection, "create") 22 | .returns(Promise.resolve({data: sample})); 23 | store.create(new Routine()) 24 | .then(done); 25 | }); 26 | 27 | afterEach(() => { 28 | sandbox.restore(); 29 | }); 30 | 31 | it("uses the specified collection name", () => { 32 | store.on("change", event => { 33 | const store = new Store(kinto, "articles"); 34 | expect(store.collection.name).to.equal("articles"); 35 | }); 36 | }); 37 | 38 | describe("#load()", () => { 39 | 40 | beforeEach(() => { 41 | sandbox.stub(store.collection, "list") 42 | .returns(Promise.resolve({data: [sample]})); 43 | }); 44 | 45 | it("fills items and emits change", (done) => { 46 | store.on("change", event => { 47 | expect(store.state.items).to.eql([Routine.deserialize(sample)]); 48 | done(); 49 | }); 50 | store.load(); 51 | }); 52 | 53 | it("sorts routines by timeleft", () => { 54 | const shuffled = [ 55 | new Routine("1", {value: 1, unit: "days"}), 56 | new Routine("3", {value: 3, unit: "days"}), 57 | new Routine("2", {value: 2, unit: "days"}), 58 | ]; 59 | store.collection.list.returns(Promise.resolve({data: shuffled})); 60 | store.on("change", event => { 61 | const sorted = store.state.items.map(r => r.label); 62 | expect(sorted).to.eql(["1", "2", "3"]); 63 | }); 64 | }); 65 | }); 66 | 67 | 68 | describe("#create()", () => { 69 | 70 | it("adds Kinto record to its state and emits change", (done) => { 71 | store.on("change", event => { 72 | expect(event.items).to.eql([Routine.deserialize(sample), Routine.deserialize(sample)]); 73 | done(); 74 | }); 75 | store.create(new Routine()); 76 | }); 77 | 78 | it("syncs if online and autorefresh", (done) => { 79 | sandbox.stub(store.collection, "sync") 80 | .returns(Promise.resolve({ok: true})); 81 | store.autorefresh = store.online = true; 82 | 83 | store.create(new Routine()); 84 | store.on('busy', state => { 85 | if (!state) { 86 | sinon.assert.calledOnce(store.collection.sync); 87 | done(); 88 | } 89 | }); 90 | }); 91 | }); 92 | 93 | 94 | describe("#update()", () => { 95 | 96 | const existing = {id: 1, label: "from db"}; 97 | 98 | beforeEach(() => { 99 | sandbox.stub(store.collection, 'update') 100 | .returns(Promise.resolve({data: existing})); 101 | }); 102 | 103 | it("update() replaces with Kinto record and emits change", (done) => { 104 | store.on('change', event => { 105 | expect(event.items).to.eql([Routine.deserialize(existing)]); 106 | done(); 107 | }); 108 | 109 | const updated = Routine.deserialize({id: 1, label: "Mundo"}); 110 | store.update(updated); 111 | }); 112 | 113 | it("syncs if online and autorefresh", (done) => { 114 | sandbox.stub(store.collection, "sync") 115 | .returns(Promise.resolve({ok: true})); 116 | store.autorefresh = store.online = true; 117 | 118 | store.update(new Routine()); 119 | store.on('busy', state => { 120 | if (!state) { 121 | sinon.assert.calledOnce(store.collection.sync); 122 | done(); 123 | } 124 | }); 125 | }); 126 | }); 127 | 128 | 129 | describe("#delete()", () => { 130 | beforeEach(() => { 131 | sandbox.stub(store.collection, "delete") 132 | .returns(Promise.resolve({})); 133 | }); 134 | 135 | it("removes record and emits change", (done) => { 136 | store.on('change', event => { 137 | expect(event.items).to.eql([]); 138 | done(); 139 | }); 140 | store.delete({id: 1, label: "Mundo"}); 141 | }); 142 | 143 | it("syncs if online and autorefresh", (done) => { 144 | sandbox.stub(store.collection, "sync") 145 | .returns(Promise.resolve({ok: true})); 146 | store.autorefresh = store.online = true; 147 | 148 | store.delete({id: 1, label: "Mundo"}); 149 | store.on('busy', state => { 150 | if (!state) { 151 | sinon.assert.calledOnce(store.collection.sync); 152 | done(); 153 | } 154 | }); 155 | }); 156 | }); 157 | 158 | 159 | describe("#sync()", () => { 160 | 161 | const existing = {label: "from db"}; 162 | 163 | beforeEach(() => { 164 | sandbox.stub(store.collection, "sync") 165 | .returns(Promise.resolve({ok: true})); 166 | sandbox.stub(store.collection, "list") 167 | .returns(Promise.resolve({data: [existing]})); 168 | }); 169 | 170 | it("emits busy when sync starts and stops", (done) => { 171 | var callback = sinon.spy(); 172 | store.on("busy", callback); 173 | store.sync() 174 | .then(() => { 175 | sinon.assert.calledTwice(callback); 176 | done(); 177 | }); 178 | }); 179 | 180 | it("reloads the local db after sync if ok", (done) => { 181 | store.on("change", event => { 182 | expect(event.items).to.eql([Routine.deserialize(existing)]); 183 | done(); 184 | }); 185 | store.sync(); 186 | }); 187 | 188 | it("resolves conflicts using remote records", (done) => { 189 | store.on("change", event => { 190 | sinon.assert.calledWithExactly(store.collection.sync, { strategy: "server_wins" }); 191 | done(); 192 | }); 193 | store.sync(); 194 | }); 195 | 196 | it("emits error when fails", (done) => { 197 | store.collection.sync.returns(Promise.reject(new Error("Server down"))); 198 | store.on("error", error => { 199 | expect(error.message).to.eql("Server down"); 200 | done(); 201 | }); 202 | store.sync(); 203 | }); 204 | 205 | it("does nothing while offline", () => { 206 | store.online = false; 207 | var result = store.sync(); 208 | expect(result).to.not.exist; 209 | }); 210 | 211 | it("does nothing while busy", () => { 212 | store.busy = true; 213 | var result = store.sync(); 214 | expect(result).to.not.exist; 215 | }); 216 | }); 217 | 218 | 219 | describe("Online", () => { 220 | it("is online by default", () => { 221 | expect(store.online).to.be.true; 222 | }); 223 | 224 | it("sends an event when going offline", () => { 225 | var callback = sinon.spy(); 226 | store.on("online", callback); 227 | store.online = false; 228 | sinon.assert.calledOnce(callback); 229 | }); 230 | 231 | it("sends an event only when state changes", () => { 232 | var callback = sinon.spy(); 233 | store.on("online", callback); 234 | store.online = false; 235 | store.online = false; 236 | sinon.assert.calledOnce(callback); 237 | }); 238 | 239 | it("is syncs when coming back online", () => { 240 | sinon.stub(store, "sync"); 241 | store.autorefresh = true; 242 | store.online = false; 243 | store.online = true; 244 | sinon.assert.calledOnce(store.sync); 245 | }); 246 | }); 247 | 248 | 249 | describe("Autorefresh", () => { 250 | var clock; 251 | var callback; 252 | 253 | beforeEach(() => { 254 | clock = sinon.useFakeTimers(); 255 | callback = sinon.spy(); 256 | store.on("change", callback); 257 | }); 258 | 259 | afterEach(() => { 260 | clock.restore(); 261 | }); 262 | 263 | it("does not autorefresh by default", () => { 264 | clock.tick(6000); 265 | expect(callback.called).to.be.false; 266 | }); 267 | 268 | it("autorefreshes every 5 seconds", () => { 269 | store.autorefresh = true; 270 | clock.tick(5002); 271 | expect(callback.calledOnce).to.be.true; 272 | }); 273 | 274 | it("cancels autorefresh if set to false", () => { 275 | store.autorefresh = true; 276 | clock.tick(3000); 277 | store.autorefresh = false; 278 | clock.tick(6000); 279 | expect(callback.called).to.be.false; 280 | }); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | var context = require.context('./test/components', true, /_test\.js$/); 2 | context.keys().forEach(context); 3 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var webpack = require("webpack"); 3 | 4 | module.exports = { 5 | devtool: "eval", 6 | entry: [ 7 | "webpack-dev-server/client?http://localhost:3000", 8 | "webpack/hot/only-dev-server", 9 | path.resolve(__dirname, "scripts/index.js") 10 | ], 11 | output: { 12 | path: path.join(__dirname, "assets"), 13 | filename: "bundle.js", 14 | publicPath: "/assets/" 15 | }, 16 | plugins: [ 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin() 19 | ], 20 | resolve: { 21 | extensions: ["", ".js", ".jsx", ".less"] 22 | }, 23 | module: { 24 | loaders: [ 25 | { test: /\.jsx?$/, loaders: ["react-hot", "babel"], include: path.join(__dirname, "scripts") }, 26 | { test: /\.less$/, loader: 'style!css!less' }, 27 | // Font files 28 | { test: /\.woff2?(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/font-woff" }, 29 | { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/octet-stream" }, 30 | { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file" }, 31 | { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/svg+xml" } 32 | ] 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var webpack = require("webpack"); 3 | 4 | module.exports = { 5 | entry: { 6 | app: path.resolve(__dirname, "scripts/index.js"), 7 | vendors: ["react", "kinto", "moment", "uuid", "bootstrap/less/bootstrap.less"] 8 | }, 9 | output: { 10 | path: path.join(__dirname, "assets"), 11 | filename: "bundle.js", 12 | publicPath: "assets/" 13 | }, 14 | plugins: [ 15 | new webpack.optimize.CommonsChunkPlugin('vendors', 'vendors.js') 16 | ], 17 | resolve: { 18 | extensions: ["", ".js", ".jsx", ".less"] 19 | }, 20 | module: { 21 | loaders: [ 22 | { test: /\.jsx?$/, loaders: ["babel"], include: path.join(__dirname, "scripts") }, 23 | { test: /\.less$/, loader: 'style!css!less' }, 24 | // Font files 25 | { test: /\.woff2?(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/font-woff" }, 26 | { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/octet-stream" }, 27 | { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file" }, 28 | { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/svg+xml" } 29 | ] 30 | } 31 | }; 32 | --------------------------------------------------------------------------------