├── .gitignore ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── packages ├── platforms ├── release └── versions ├── client ├── main.html ├── main.js └── main.scss ├── imports ├── api │ ├── notes.js │ ├── notes.test.js │ ├── users.js │ └── users.test.js ├── client │ └── styles │ │ ├── _base.scss │ │ ├── _main.scss │ │ ├── _variables.scss │ │ ├── components │ │ ├── _boxed-view.scss │ │ ├── _button.scss │ │ ├── _checkbox.scss │ │ ├── _editor.scss │ │ ├── _header.scss │ │ ├── _item.scss │ │ └── _page-content.scss │ │ └── mixins │ │ └── _media-query-mixins.scss ├── fixtures │ └── fixtures.js ├── routes │ └── routes.js ├── startup │ └── simple-schema-configuration.js └── ui │ ├── Dashboard.js │ ├── Editor.js │ ├── Editor.test.js │ ├── Login.js │ ├── Login.test.js │ ├── NotFound.js │ ├── NoteList.js │ ├── NoteList.test.js │ ├── NoteListEmptyItem.js │ ├── NoteListHeader.js │ ├── NoteListHeader.test.js │ ├── NoteListItem.js │ ├── NoteListItem.test.js │ ├── PrivateHeader.js │ ├── PrivateHeader.test.js │ ├── Signup.js │ └── Signup.test.js ├── package.json ├── public └── images │ ├── bars.svg │ ├── favicon.png │ └── x.svg ├── readme.md └── server └── main.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | 1.4.0-remove-old-dev-bundle-link 15 | 1.4.1-add-shell-server-package 16 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 1x1sbsv1xme3tt17sw9id 8 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base@1.0.4 # Packages every Meteor app needs to have 8 | mobile-experience@1.0.4 # Packages for a great mobile UX 9 | mongo@1.1.14 # The database Meteor supports right now 10 | blaze-html-templates@1.0.4 # Compile .html files into Meteor Blaze views 11 | reactive-var@1.0.11 # Reactive variable for tracker 12 | jquery@1.11.10 # Helpful client-side library 13 | tracker@1.1.1 # Meteor's client-side reactive programming library 14 | 15 | standard-minifier-css@1.3.2 # CSS minifier run for production mode 16 | standard-minifier-js@1.2.1 # JS minifier run for production mode 17 | es5-shim@4.6.15 # ECMAScript 5 compatibility for older browsers. 18 | ecmascript@0.6.1 # Enable ECMAScript2015+ syntax in app code 19 | shell-server@0.2.1 # Server-side component of the `meteor shell` command 20 | 21 | accounts-password 22 | session 23 | fourseven:scss@=3.13.0 24 | practicalmeteor:mocha@=2.4.5_6 25 | react-meteor-data@=0.2.9 26 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.4.2.3 2 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.14 2 | accounts-password@1.3.3 3 | allow-deny@1.0.5 4 | autoupdate@1.2.11 5 | babel-compiler@6.13.0 6 | babel-runtime@1.0.1 7 | base64@1.0.10 8 | binary-heap@1.0.10 9 | blaze@2.1.9 10 | blaze-html-templates@1.0.5 11 | blaze-tools@1.0.10 12 | boilerplate-generator@1.0.11 13 | caching-compiler@1.1.9 14 | caching-html-compiler@1.0.7 15 | callback-hook@1.0.10 16 | check@1.2.4 17 | coffeescript@1.0.17 18 | ddp@1.2.5 19 | ddp-client@1.2.9 20 | ddp-common@1.2.8 21 | ddp-rate-limiter@1.0.6 22 | ddp-server@1.2.10 23 | deps@1.0.12 24 | diff-sequence@1.0.7 25 | ecmascript@0.6.1 26 | ecmascript-runtime@0.3.15 27 | ejson@1.0.13 28 | email@1.1.18 29 | es5-shim@4.6.15 30 | fastclick@1.0.13 31 | fourseven:scss@3.13.0 32 | geojson-utils@1.0.10 33 | hot-code-push@1.0.4 34 | html-tools@1.0.11 35 | htmljs@1.0.11 36 | http@1.1.8 37 | id-map@1.0.9 38 | jquery@1.11.10 39 | launch-screen@1.0.12 40 | livedata@1.0.18 41 | localstorage@1.0.12 42 | logging@1.1.16 43 | meteor@1.6.0 44 | meteor-base@1.0.4 45 | minifier-css@1.2.15 46 | minifier-js@1.2.15 47 | minimongo@1.0.19 48 | mobile-experience@1.0.4 49 | mobile-status-bar@1.0.13 50 | modules@0.7.7 51 | modules-runtime@0.7.7 52 | mongo@1.1.14 53 | mongo-id@1.0.6 54 | npm-bcrypt@0.9.2 55 | npm-mongo@2.2.11_2 56 | observe-sequence@1.0.14 57 | ordered-dict@1.0.9 58 | practicalmeteor:chai@2.1.0_1 59 | practicalmeteor:loglevel@1.2.0_2 60 | practicalmeteor:mocha@2.4.5_6 61 | practicalmeteor:mocha-core@1.0.1 62 | practicalmeteor:sinon@1.14.1_2 63 | promise@0.8.8 64 | random@1.0.10 65 | rate-limit@1.0.6 66 | react-meteor-data@0.2.9 67 | reactive-dict@1.1.8 68 | reactive-var@1.0.11 69 | reload@1.1.11 70 | retry@1.0.9 71 | routepolicy@1.0.12 72 | service-configuration@1.0.11 73 | session@1.1.7 74 | sha@1.0.9 75 | shell-server@0.2.1 76 | spacebars@1.0.13 77 | spacebars-compiler@1.0.13 78 | srp@1.0.10 79 | standard-minifier-css@1.3.2 80 | standard-minifier-js@1.2.1 81 | templating@1.2.15 82 | templating-compiler@1.2.15 83 | templating-runtime@1.2.15 84 | templating-tools@1.0.5 85 | tmeasday:check-npm-versions@0.2.0 86 | tmeasday:test-reporter-helpers@0.2.1 87 | tracker@1.1.1 88 | ui@1.0.12 89 | underscore@1.0.10 90 | url@1.0.11 91 | webapp@1.3.12 92 | webapp-hashing@1.0.9 93 | -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | Notes App 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import ReactDOM from 'react-dom'; 3 | import { Tracker } from 'meteor/tracker'; 4 | import { Session } from 'meteor/session'; 5 | import { browserHistory } from 'react-router'; 6 | 7 | import { routes, onAuthChange } from '../imports/routes/routes'; 8 | import '../imports/startup/simple-schema-configuration.js'; 9 | 10 | Tracker.autorun(() => { 11 | const isAuthenticated = !!Meteor.userId(); 12 | const currentPagePrivacy = Session.get('currentPagePrivacy'); 13 | 14 | onAuthChange(isAuthenticated, currentPagePrivacy); 15 | }); 16 | 17 | Tracker.autorun(() => { 18 | const selectedNoteId = Session.get('selectedNoteId'); 19 | Session.set('isNavOpen', false); 20 | 21 | if (selectedNoteId) { 22 | browserHistory.replace(`/dashboard/${selectedNoteId}`); 23 | } 24 | }); 25 | 26 | Tracker.autorun(() => { 27 | const isNavOpen = Session.get('isNavOpen'); 28 | 29 | document.body.classList.toggle('is-nav-open', isNavOpen); 30 | }); 31 | 32 | Meteor.startup(() => { 33 | Session.set('selectedNoteId', undefined); 34 | Session.set('isNavOpen', false); 35 | ReactDOM.render(routes, document.getElementById('app')); 36 | }); 37 | -------------------------------------------------------------------------------- /client/main.scss: -------------------------------------------------------------------------------- 1 | @import './../imports/client/styles/main'; 2 | -------------------------------------------------------------------------------- /imports/api/notes.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import moment from 'moment'; 4 | import SimpleSchema from 'simpl-schema'; 5 | 6 | export const Notes = new Mongo.Collection('notes'); 7 | 8 | if (Meteor.isServer) { 9 | Meteor.publish('notes', function () { 10 | return Notes.find({ userId: this.userId }); 11 | }); 12 | } 13 | 14 | Meteor.methods({ 15 | 'notes.insert'() { 16 | if (!this.userId) { 17 | throw new Meteor.Error('not-authorized'); 18 | } 19 | 20 | return Notes.insert({ 21 | title: '', 22 | body: '', 23 | userId: this.userId, 24 | updatedAt: moment().valueOf() 25 | }); 26 | }, 27 | 'notes.remove'(_id) { 28 | if (!this.userId) { 29 | throw new Meteor.Error('not-authorized'); 30 | } 31 | 32 | new SimpleSchema({ 33 | _id: { 34 | type: String, 35 | min: 1 36 | } 37 | }).validate({ _id }); 38 | 39 | Notes.remove({ _id, userId: this.userId }); 40 | }, 41 | 'notes.update'(_id, updates) { 42 | if (!this.userId) { 43 | throw new Meteor.Error('not-authorized'); 44 | } 45 | 46 | new SimpleSchema({ 47 | _id: { 48 | type: String, 49 | min: 1 50 | }, 51 | title: { 52 | type: String, 53 | optional: true 54 | }, 55 | body: { 56 | type: String, 57 | optional: true 58 | } 59 | }).validate({ 60 | _id, 61 | ...updates 62 | }); 63 | 64 | Notes.update({ 65 | _id, 66 | userId: this.userId 67 | }, { 68 | $set: { 69 | updatedAt: moment().valueOf(), 70 | ...updates 71 | } 72 | }); 73 | } 74 | }); 75 | -------------------------------------------------------------------------------- /imports/api/notes.test.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import expect from 'expect'; 3 | 4 | import { Notes } from './notes'; 5 | 6 | if (Meteor.isServer) { 7 | describe('notes', function () { 8 | const noteOne = { 9 | _id: 'testNoteId1', 10 | title: 'My Title', 11 | body: 'My body for note', 12 | updatedAt: 0, 13 | userId: 'testUserId1' 14 | }; 15 | const noteTwo = { 16 | _id: 'testNoteId2', 17 | title: 'Things To Buy', 18 | body: 'Couch', 19 | updatedAt: 0, 20 | userId: 'testUserId2' 21 | }; 22 | 23 | beforeEach(function () { 24 | Notes.remove({}); 25 | Notes.insert(noteOne); 26 | Notes.insert(noteTwo); 27 | }); 28 | 29 | it('should insert new note', function () { 30 | const userId = 'testid'; 31 | const _id = Meteor.server.method_handlers['notes.insert'].apply({ userId }); 32 | 33 | expect(Notes.findOne({ _id, userId })).toExist(); 34 | }); 35 | 36 | it('should not insert note if not authenticated', function () { 37 | expect(() => { 38 | Meteor.server.method_handlers['notes.insert'](); 39 | }).toThrow(); 40 | }); 41 | 42 | it('should remove note', function () { 43 | Meteor.server.method_handlers['notes.remove'].apply({ userId: noteOne.userId }, [noteOne._id]); 44 | 45 | expect(Notes.findOne({ _id: noteOne._id})).toNotExist(); 46 | }); 47 | 48 | it('should not remove note if unauthenticated', function () { 49 | expect(() => { 50 | Meteor.server.method_handlers['notes.remove'].apply({}, [noteOne._id]); 51 | }).toThrow(); 52 | }); 53 | 54 | it('should not remove note if invalid _id', function () { 55 | expect(() => { 56 | Meteor.server.method_handlers['notes.remove'].apply({ userId: noteOne.userId}); 57 | }).toThrow(); 58 | }); 59 | 60 | it('should update note', function () { 61 | const title = 'This is an updated title'; 62 | 63 | Meteor.server.method_handlers['notes.update'].apply({ 64 | userId: noteOne.userId 65 | }, [ 66 | noteOne._id, 67 | { title } 68 | ]); 69 | 70 | const note = Notes.findOne(noteOne._id); 71 | 72 | expect(note.updatedAt).toBeGreaterThan(0); 73 | expect(note).toInclude({ 74 | title, 75 | body: noteOne.body 76 | }); 77 | }); 78 | 79 | it('should throw error if extra updates provided', function () { 80 | expect(() => { 81 | Meteor.server.method_handlers['notes.update'].apply({ 82 | userId: noteOne.userId 83 | }, [ 84 | noteOne._id, 85 | { title: 'new title', name: 'Andrew' } 86 | ]); 87 | }).toThrow(); 88 | }); 89 | 90 | it('should not update note if user was not creator', function () { 91 | const title = 'This is an updated title'; 92 | 93 | Meteor.server.method_handlers['notes.update'].apply({ 94 | userId: 'testid' 95 | }, [ 96 | noteOne._id, 97 | { title } 98 | ]); 99 | 100 | const note = Notes.findOne(noteOne._id); 101 | 102 | expect(note).toInclude(noteOne); 103 | }); 104 | 105 | it('should not update note if unauthenticated', function () { 106 | expect(() => { 107 | Meteor.server.method_handlers['notes.update'].apply({}, [noteOne._id]); 108 | }).toThrow(); 109 | }); 110 | 111 | it('should not update note if invalid _id', function () { 112 | expect(() => { 113 | Meteor.server.method_handlers['notes.update'].apply({ userId: noteOne.userId}); 114 | }).toThrow(); 115 | }); 116 | 117 | it('should return a users notes', function () { 118 | const res = Meteor.server.publish_handlers.notes.apply({ userId: noteOne.userId }); 119 | const notes = res.fetch(); 120 | 121 | expect(notes.length).toBe(1); 122 | expect(notes[0]).toEqual(noteOne); 123 | }); 124 | 125 | it('should return no notes for user that has none', function () { 126 | const res = Meteor.server.publish_handlers.notes.apply({ userId: 'testid' }); 127 | const notes = res.fetch(); 128 | 129 | expect(notes.length).toBe(0); 130 | }); 131 | 132 | }); 133 | } 134 | -------------------------------------------------------------------------------- /imports/api/users.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import SimpleSchema from 'simpl-schema'; 3 | import { Accounts } from 'meteor/accounts-base'; 4 | 5 | export const validateNewUser = (user) => { 6 | const email = user.emails[0].address; 7 | 8 | new SimpleSchema({ 9 | email: { 10 | type: String, 11 | regEx: SimpleSchema.RegEx.Email 12 | } 13 | }).validate({ email }); 14 | 15 | return true; 16 | }; 17 | 18 | if (Meteor.isServer) { 19 | Accounts.validateNewUser(validateNewUser); 20 | } 21 | -------------------------------------------------------------------------------- /imports/api/users.test.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import expect from 'expect'; 3 | 4 | import { validateNewUser } from './users'; 5 | 6 | if (Meteor.isServer) { 7 | describe('users', function () { 8 | 9 | it('should allow valid email address', function () { 10 | const testUser = { 11 | emails: [ 12 | { 13 | address: 'Test@example.com' 14 | } 15 | ] 16 | }; 17 | const res = validateNewUser(testUser); 18 | 19 | expect(res).toBe(true); 20 | }); 21 | 22 | it('should reject invalid email', function () { 23 | const testUser = { 24 | emails: [ 25 | { 26 | address: 'Testom' 27 | } 28 | ] 29 | }; 30 | 31 | expect(() => { 32 | validateNewUser(testUser); 33 | }).toThrow(); 34 | }); 35 | 36 | }); 37 | } 38 | 39 | 40 | 41 | // const add = (a, b) => { 42 | // if (typeof b !== 'number') { 43 | // return a + a; 44 | // } 45 | // 46 | // return a + b; 47 | // }; 48 | // 49 | // const square = (a) => a * a; 50 | // 51 | // describe('add', function () { 52 | // it('should add two numbers', function () { 53 | // const res = add(11, 9); 54 | // 55 | // expect(res).toBe(20); 56 | // }); 57 | // 58 | // it('should double a single number', function () { 59 | // const res = add(44); 60 | // 61 | // expect(res).toBe(88); 62 | // }); 63 | // }); 64 | // 65 | // describe('square', function () { 66 | // it('should square a number', function () { 67 | // const res = square(11); 68 | // 69 | // expect(res).toBe(121); 70 | // }); 71 | // }); 72 | -------------------------------------------------------------------------------- /imports/client/styles/_base.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | html { 8 | font-size: 62.5%; 9 | } 10 | 11 | body { 12 | background-color: $body-background-color; 13 | color: $body-default-color; 14 | font-family: $body-default-font-family; 15 | font-size: $base-font-size; 16 | } 17 | 18 | h1 { 19 | font-weight: 300; 20 | margin-bottom: $space; 21 | } 22 | 23 | h2 { 24 | font-size: $large-font-size; 25 | margin-bottom: $space; 26 | } 27 | 28 | p { 29 | margin-bottom: $space; 30 | } 31 | 32 | a { 33 | color: $body-default-color; 34 | } 35 | 36 | input[type=text], input[type=email], input[type=password], textarea { 37 | border: 1px solid $input-border; 38 | color: $body-default-color; 39 | font-size: $base-font-size; 40 | font-family: $body-default-font-family; 41 | margin-bottom: $space; 42 | padding: $input-spacing; 43 | } 44 | -------------------------------------------------------------------------------- /imports/client/styles/_main.scss: -------------------------------------------------------------------------------- 1 | @import './variables'; 2 | @import './mixins/media-query-mixins'; 3 | @import './base'; 4 | @import './components/boxed-view'; 5 | @import './components/button'; 6 | @import './components/editor'; 7 | @import './components/header'; 8 | @import './components/page-content'; 9 | @import './components/checkbox'; 10 | @import './components/item'; 11 | -------------------------------------------------------------------------------- /imports/client/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | // Colors 2 | $brand-primary: #397ab1; 3 | $grey: #dddddd; 4 | $light-grey: #f9f9f9; 5 | $dark-grey: #777777; 6 | 7 | // Body 8 | $body-background-color: #fafafa; 9 | $body-default-color: #333333; 10 | $body-default-font-family: Helvetica, Arial, sans-serif; 11 | 12 | // Spacing 13 | $space: 1.4rem; 14 | $large-space: 2.8rem; 15 | $site-max-width: 100rem; 16 | 17 | // Font sizes 18 | $base-font-size: 1.4rem; 19 | $large-font-size: 2.2rem; 20 | 21 | // Form inputs 22 | $input-border: $grey; 23 | $input-spacing: 1rem; 24 | 25 | // Boxed view 26 | $boxed-view-overlay-bg: $grey; 27 | $boxed-view-bg: white; 28 | $boxed-view-border-color: $grey; 29 | 30 | // Button 31 | $button-bg: $brand-primary; 32 | $button-color: white; 33 | $button-pill-color: $brand-primary; 34 | $button-secondary-bg: $light-grey; 35 | $button-secondary-color: $dark-grey; 36 | $button-secondary-border-color: $grey; 37 | 38 | // Header 39 | $header-bg: $brand-primary; 40 | $header-color: white; 41 | $header-height: 6rem; 42 | 43 | // Item 44 | $item-border-width: 4px; 45 | 46 | // Page Content 47 | $page-content-sidebar-width: 30rem; 48 | $page-content-main-width: calc(#{$site-max-width} - #{$page-content-sidebar-width}); 49 | $page-content-height: calc(100vh - #{$header-height}); 50 | -------------------------------------------------------------------------------- /imports/client/styles/components/_boxed-view.scss: -------------------------------------------------------------------------------- 1 | .boxed-view { 2 | align-items: center; 3 | display: flex; 4 | justify-content: center; 5 | height: 100vh; 6 | width: 100vw; 7 | } 8 | 9 | .boxed-view--modal { 10 | background: fade-out($boxed-view-overlay-bg, .3); 11 | position: fixed; 12 | top: 0; 13 | left: 0; 14 | right: 0; 15 | bottom: 0; 16 | } 17 | 18 | .boxed-view__box { 19 | background-color: $boxed-view-bg; 20 | border: 1px solid $boxed-view-border-color; 21 | padding: 2.4rem; 22 | text-align: center; 23 | width: 24rem; 24 | } 25 | 26 | .boxed-view__form { 27 | display: flex; 28 | flex-direction: column; 29 | } 30 | -------------------------------------------------------------------------------- /imports/client/styles/components/_button.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: $button-bg; 3 | border: none; 4 | color: $button-color; 5 | cursor: pointer; 6 | font-size: $base-font-size; 7 | line-height: 1.2; 8 | margin-bottom: $space; 9 | padding: $input-spacing; 10 | text-transform: uppercase; 11 | } 12 | 13 | .button--link { 14 | display: inline-block; 15 | text-decoration: none; 16 | } 17 | 18 | .button--pill { 19 | background-color: transparent; 20 | border: 1px solid $button-pill-color; 21 | color: $button-pill-color; 22 | margin: 0 ($space / 2) 0 0; 23 | padding: .3rem .8rem; 24 | } 25 | 26 | .button--secondary { 27 | background: $button-secondary-bg; 28 | border: 1px solid $button-secondary-border-color; 29 | color: $button-secondary-color; 30 | } 31 | 32 | .button--link-text { 33 | background: none; 34 | margin: 0; 35 | padding: 0; 36 | text-decoration: underline; 37 | text-transform: none; 38 | } 39 | -------------------------------------------------------------------------------- /imports/client/styles/components/_checkbox.scss: -------------------------------------------------------------------------------- 1 | .checkbox { 2 | display: block; 3 | margin-bottom: $space; 4 | } 5 | 6 | .checkbox__box { 7 | margin-right: $space / 2; 8 | } 9 | -------------------------------------------------------------------------------- /imports/client/styles/components/_editor.scss: -------------------------------------------------------------------------------- 1 | .editor { 2 | background-color: white; 3 | border: 1px solid $grey; 4 | display: flex; 5 | flex-direction: column; 6 | padding: $large-space; 7 | width: 100%; 8 | } 9 | 10 | .editor__title { 11 | border: none; 12 | border-bottom: 2px solid $grey; 13 | font-size: $large-font-size; 14 | margin-bottom: $large-space; 15 | outline: none; 16 | padding: $input-spacing; 17 | } 18 | 19 | .editor__body { 20 | flex-grow: 1; 21 | font-weight: 300; 22 | margin-bottom: $large-space; 23 | outline: none; 24 | padding: $input-spacing; 25 | resize: none; 26 | -webkit-overflow-scrolling: touch; 27 | } 28 | 29 | .editor__message { 30 | text-align: center; 31 | margin: $large-space * 2; 32 | font-style: italic; 33 | } 34 | -------------------------------------------------------------------------------- /imports/client/styles/components/_header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | background: $header-bg; 3 | color: $header-color; 4 | } 5 | 6 | .header__content { 7 | align-items: center; 8 | display: flex; 9 | height: $header-height; 10 | justify-content: space-between; 11 | max-width: $site-max-width; 12 | margin: 0 auto; 13 | padding: $space; 14 | } 15 | 16 | .header__title { 17 | margin: 0; 18 | } 19 | 20 | .header__nav-toggle { 21 | cursor: pointer; 22 | height: 2rem; 23 | width: 2rem; 24 | 25 | @include desktop { 26 | display: none; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /imports/client/styles/components/_item.scss: -------------------------------------------------------------------------------- 1 | // Item List 2 | .item-list { 3 | background-color: white; 4 | border: 1px solid $grey; 5 | overflow-y: scroll; 6 | width: 100%; 7 | -webkit-overflow-scrolling: touch; 8 | } 9 | 10 | .item-list__header { 11 | display: flex; 12 | flex-direction: column; 13 | padding: $space; 14 | } 15 | 16 | // Item 17 | .item { 18 | border-left: $item-border-width solid transparent; 19 | cursor: pointer; 20 | padding: $space; 21 | transition: background-color .3s ease, border-left .3s ease; 22 | } 23 | 24 | .item:hover { 25 | background-color: $light-grey; 26 | border-left: $item-border-width solid $grey; 27 | } 28 | 29 | .item--selected, .item--selected:hover { 30 | background-color: $light-grey; 31 | border-left: $item-border-width solid $brand-primary; 32 | } 33 | 34 | .item__title { 35 | font-weight: normal; 36 | font-size: $base-font-size; 37 | margin-bottom: .4rem; 38 | } 39 | 40 | .item--selected .item__title { 41 | font-weight: bold; 42 | } 43 | 44 | .item__subtitle { 45 | color: $dark-grey; 46 | margin: 0; 47 | } 48 | 49 | // Empty Item 50 | .empty-item { 51 | font-style: italic; 52 | padding: $space; 53 | text-align: center; 54 | } 55 | -------------------------------------------------------------------------------- /imports/client/styles/components/_page-content.scss: -------------------------------------------------------------------------------- 1 | .page-content { 2 | display: flex; 3 | height: $page-content-height; 4 | margin: 0 auto; 5 | max-width: $site-max-width; 6 | 7 | @include desktop { 8 | padding: $large-space $space; 9 | } 10 | } 11 | 12 | .page-content__sidebar { 13 | display: flex; 14 | transition: left .3s ease; 15 | width: 100vw; 16 | 17 | position: fixed; 18 | top: $header-height; 19 | left: -100vw; 20 | bottom: 0; 21 | z-index: 1; 22 | 23 | @include desktop { 24 | display: flex; 25 | padding-right: $large-space; 26 | position: static; 27 | width: $page-content-sidebar-width; 28 | } 29 | } 30 | 31 | .is-nav-open .page-content__sidebar { 32 | left: 0; 33 | } 34 | 35 | .page-content__main { 36 | display: flex; 37 | width: 100%; 38 | 39 | @include desktop { 40 | width: $page-content-main-width; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /imports/client/styles/mixins/_media-query-mixins.scss: -------------------------------------------------------------------------------- 1 | // @mixin funkyBorder ($borderSize: 10px, $borderColor: orange) { 2 | // border: $borderSize solid $borderColor; 3 | // 4 | // * { 5 | // @content; 6 | // } 7 | // } 8 | // 9 | // @mixin funkyBackground ($backgroundColor: green) { 10 | // background-color: $backgroundColor; 11 | // } 12 | 13 | @mixin desktop { 14 | @media (min-width: 50rem) { 15 | @content; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /imports/fixtures/fixtures.js: -------------------------------------------------------------------------------- 1 | export const notes = [ 2 | { 3 | _id: 'noteId1', 4 | title: 'Test title', 5 | body: '', 6 | updatedAt: 1486137505429, 7 | userId: 'userId1' 8 | }, { 9 | _id: 'noteId2', 10 | title: '', 11 | body: 'Something is here.', 12 | updatedAt: 1486137505429, 13 | userId: 'userId2' 14 | } 15 | ]; 16 | -------------------------------------------------------------------------------- /imports/routes/routes.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import React from 'react'; 3 | import { Router, Route, browserHistory } from 'react-router'; 4 | import { Session } from 'meteor/session'; 5 | 6 | import Signup from '../ui/Signup'; 7 | import Dashboard from '../ui/Dashboard'; 8 | import NotFound from '../ui/NotFound'; 9 | import Login from '../ui/Login'; 10 | 11 | const onEnterNotePage = (nextState) => { 12 | Session.set('selectedNoteId', nextState.params.id); 13 | }; 14 | const onLeaveNotePage = () => { 15 | Session.set('selectedNoteId', undefined); 16 | }; 17 | export const onAuthChange = (isAuthenticated, currentPagePrivacy) => { 18 | const isUnauthenticatedPage = currentPagePrivacy === 'unauth'; 19 | const isAuthenticatedPage = currentPagePrivacy === 'auth'; 20 | 21 | if (isUnauthenticatedPage && isAuthenticated) { 22 | browserHistory.replace('/dashboard'); 23 | } else if (isAuthenticatedPage && !isAuthenticated) { 24 | browserHistory.replace('/'); 25 | } 26 | }; 27 | export const globalOnChange = (prevState, nextState) => { 28 | globalOnEnter(nextState); 29 | }; 30 | export const globalOnEnter = (nextState) => { 31 | const lastRoute = nextState.routes[nextState.routes.length - 1]; 32 | Session.set('currentPagePrivacy', lastRoute.privacy); 33 | }; 34 | export const routes = ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | -------------------------------------------------------------------------------- /imports/startup/simple-schema-configuration.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import SimpleSchema from 'simpl-schema'; 3 | 4 | SimpleSchema.defineValidationErrorTransform((e) => { 5 | return new Meteor.Error(400, e.message) 6 | }); 7 | -------------------------------------------------------------------------------- /imports/ui/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PrivateHeader from './PrivateHeader'; 4 | import NoteList from './NoteList'; 5 | import Editor from './Editor'; 6 | 7 | export default () => { 8 | return ( 9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /imports/ui/Editor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createContainer } from 'meteor/react-meteor-data'; 3 | import { Session } from 'meteor/session'; 4 | import { Meteor } from 'meteor/meteor'; 5 | import { browserHistory } from 'react-router'; 6 | 7 | import { Notes } from '../api/notes'; 8 | 9 | export class Editor extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | title: '', 14 | body: '' 15 | }; 16 | } 17 | handleBodyChange(e) { 18 | const body = e.target.value; 19 | this.setState({ body }); 20 | this.props.call('notes.update', this.props.note._id, { body }); 21 | } 22 | handleTitleChange(e) { 23 | const title = e.target.value; 24 | this.setState({ title }); 25 | this.props.call('notes.update', this.props.note._id, { title }); 26 | } 27 | handleRemoval(){ 28 | this.props.call('notes.remove', this.props.note._id); 29 | this.props.browserHistory.push('/dashboard'); 30 | } 31 | componentDidUpdate(prevProps, prevState) { 32 | const currentNoteId = this.props.note ? this.props.note._id : undefined; 33 | const prevNoteId = prevProps.note ? prevProps.note._id : undefined; 34 | 35 | if (currentNoteId && currentNoteId !== prevNoteId) { 36 | this.setState({ 37 | title: this.props.note.title, 38 | body: this.props.note.body 39 | }); 40 | } 41 | } 42 | render() { 43 | if (this.props.note) { 44 | return ( 45 |
46 | 47 | 48 |
49 | 50 |
51 |
52 | ); 53 | } else { 54 | return ( 55 |
56 |

57 | { this.props.selectedNoteId ? 'Note not found.' : 'Pick or create a note to get started.'} 58 |

59 |
60 | ); 61 | } 62 | } 63 | }; 64 | 65 | Editor.propTypes = { 66 | note: React.PropTypes.object, 67 | selectedNoteId: React.PropTypes.string, 68 | call: React.PropTypes.func.isRequired, 69 | browserHistory: React.PropTypes.object.isRequired 70 | }; 71 | 72 | export default createContainer(() => { 73 | const selectedNoteId = Session.get('selectedNoteId'); 74 | 75 | return { 76 | selectedNoteId, 77 | note: Notes.findOne(selectedNoteId), 78 | call: Meteor.call, 79 | browserHistory 80 | }; 81 | }, Editor); 82 | -------------------------------------------------------------------------------- /imports/ui/Editor.test.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import React from 'react'; 3 | import expect from 'expect'; 4 | import { mount } from 'enzyme'; 5 | 6 | import { Editor } from './Editor'; 7 | import { notes } from '../fixtures/fixtures'; 8 | 9 | if (Meteor.isClient) { 10 | describe('Editor', function () { 11 | let browserHistory; 12 | let call; 13 | 14 | beforeEach(function () { 15 | call = expect.createSpy(); 16 | browserHistory = { 17 | push: expect.createSpy() 18 | }; 19 | }); 20 | 21 | it('should render pick note mesesage', function () { 22 | const wrapper = mount(); 23 | expect(wrapper.find('p').text()).toBe('Pick or create a note to get started.'); 24 | }); 25 | 26 | it('should render not found message', function () { 27 | const wrapper = mount(); 28 | expect(wrapper.find('p').text()).toBe('Note not found.'); 29 | }); 30 | 31 | it('should remove note', function () { 32 | const wrapper = mount(); 33 | 34 | wrapper.find('button').simulate('click'); 35 | 36 | expect(browserHistory.push).toHaveBeenCalledWith('/dashboard'); 37 | expect(call).toHaveBeenCalledWith('notes.remove', notes[0]._id); 38 | }); 39 | 40 | it('should update the note body on textarea change', function () { 41 | const newBody = 'This is my new body text'; 42 | const wrapper = mount(); 43 | 44 | wrapper.find('textarea').simulate('change', { 45 | target: { 46 | value: newBody 47 | } 48 | }); 49 | 50 | expect(wrapper.state('body')).toBe(newBody); 51 | expect(call).toHaveBeenCalledWith('notes.update', notes[0]._id, { body: newBody }); 52 | }); 53 | 54 | it('should update the note title on input change', function () { 55 | const newTitle = 'This is my new title text'; 56 | const wrapper = mount(); 57 | 58 | wrapper.find('input').simulate('change', { 59 | target: { 60 | value: newTitle 61 | } 62 | }); 63 | 64 | expect(wrapper.state('title')).toBe(newTitle); 65 | expect(call).toHaveBeenCalledWith('notes.update', notes[0]._id, { title: newTitle }); 66 | }); 67 | 68 | it('should set state for new note', function () { 69 | const wrapper = mount(); 70 | 71 | wrapper.setProps({ 72 | selectedNoteId: notes[0]._id, 73 | note: notes[0] 74 | }); 75 | 76 | expect(wrapper.state('title')).toBe(notes[0].title); 77 | expect(wrapper.state('body')).toBe(notes[0].body); 78 | }); 79 | 80 | it('should not set state if note prop not provided', function () { 81 | const wrapper = mount(); 82 | 83 | wrapper.setProps({ 84 | selectedNoteId: notes[0]._id 85 | }); 86 | 87 | expect(wrapper.state('title')).toBe(''); 88 | expect(wrapper.state('body')).toBe(''); 89 | }); 90 | 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /imports/ui/Login.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import { createContainer } from 'meteor/react-meteor-data'; 5 | 6 | export class Login extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | error: '' 11 | }; 12 | } 13 | onSubmit(e) { 14 | e.preventDefault(); 15 | 16 | let email = this.refs.email.value.trim(); 17 | let password = this.refs.password.value.trim(); 18 | 19 | this.props.loginWithPassword({email}, password, (err) => { 20 | if (err) { 21 | this.setState({error: 'Unable to login. Check email and password.'}); 22 | } else { 23 | this.setState({error: ''}); 24 | } 25 | }); 26 | } 27 | render() { 28 | return ( 29 |
30 |
31 |

Login

32 | 33 | {this.state.error ?

{this.state.error}

: undefined} 34 | 35 |
36 | 37 | 38 | 39 |
40 | 41 | Need an account? 42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | Login.propTypes = { 49 | loginWithPassword: React.PropTypes.func.isRequired 50 | }; 51 | 52 | export default createContainer(() => { 53 | return { 54 | loginWithPassword: Meteor.loginWithPassword 55 | }; 56 | }, Login); 57 | -------------------------------------------------------------------------------- /imports/ui/Login.test.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import React from 'react'; 3 | import expect from 'expect'; 4 | import { mount } from 'enzyme'; 5 | 6 | import { Login } from './Login'; 7 | 8 | if (Meteor.isClient) { 9 | describe('Login', function () { 10 | 11 | it('should show error messages', function () { 12 | const error = 'This is not working'; 13 | const wrapper = mount( {}}/>); 14 | 15 | wrapper.setState({ error }); 16 | expect(wrapper.find('p').text()).toBe(error); 17 | 18 | wrapper.setState({ error: '' }); 19 | expect(wrapper.find('p').length).toBe(0); 20 | }); 21 | 22 | it('should call loginWithPassword with the form data', function () { 23 | const email = 'andrew@test.com'; 24 | const password = 'password123'; 25 | const spy = expect.createSpy(); 26 | const wrapper = mount(); 27 | 28 | wrapper.ref('email').node.value = email; 29 | wrapper.ref('password').node.value = password; 30 | wrapper.find('form').simulate('submit'); 31 | 32 | expect(spy.calls[0].arguments[0]).toEqual({ email }); 33 | expect(spy.calls[0].arguments[1]).toBe(password); 34 | }); 35 | 36 | it('should set loginWithPassword callback errors', function () { 37 | const spy = expect.createSpy(); 38 | const wrapper = mount(); 39 | 40 | wrapper.find('form').simulate('submit'); 41 | 42 | spy.calls[0].arguments[2]({}); 43 | expect(wrapper.state('error').length).toNotBe(0); 44 | 45 | spy.calls[0].arguments[2](); 46 | expect(wrapper.state('error').length).toBe(0); 47 | }); 48 | 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /imports/ui/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export default () => { 5 | return ( 6 |
7 |
8 |

404 - Page Not Found

9 |

We're unable to find that page.

10 | HEAD HOME 11 |
12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /imports/ui/NoteList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import { createContainer } from 'meteor/react-meteor-data'; 4 | import { Session } from 'meteor/session'; 5 | 6 | import { Notes } from '../api/notes'; 7 | import NoteListHeader from './NoteListHeader'; 8 | import NoteListItem from './NoteListItem'; 9 | import NoteListEmptyItem from './NoteListEmptyItem'; 10 | 11 | export const NoteList = (props) => { 12 | return ( 13 |
14 | 15 | { props.notes.length === 0 ? : undefined } 16 | {props.notes.map((note) => { 17 | return ; 18 | })} 19 |
20 | ); 21 | }; 22 | 23 | NoteList.propTypes = { 24 | notes: React.PropTypes.array.isRequired 25 | }; 26 | 27 | export default createContainer(() => { 28 | const selectedNoteId = Session.get('selectedNoteId'); 29 | 30 | Meteor.subscribe('notes'); 31 | 32 | return { 33 | notes: Notes.find({}, { 34 | sort: { 35 | updatedAt: -1 36 | } 37 | }).fetch().map((note) => { 38 | return { 39 | ...note, 40 | selected: note._id === selectedNoteId 41 | }; 42 | }) 43 | }; 44 | }, NoteList); 45 | -------------------------------------------------------------------------------- /imports/ui/NoteList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import expect from 'expect'; 3 | import { mount } from 'enzyme'; 4 | import { Meteor } from 'meteor/meteor'; 5 | 6 | import { notes } from '../fixtures/fixtures'; 7 | import { NoteList } from './NoteList'; 8 | 9 | if (Meteor.isClient) { 10 | describe('NoteList', function () { 11 | 12 | it('should render NoteListItem for each note', function () { 13 | const wrapper = mount(); 14 | 15 | expect(wrapper.find('NoteListItem').length).toBe(2); 16 | expect(wrapper.find('NoteListEmptyItem').length).toBe(0); 17 | }); 18 | 19 | it('should render NoteListEmptyItem if zero notes', function () { 20 | const wrapper = mount(); 21 | 22 | expect(wrapper.find('NoteListItem').length).toBe(0); 23 | expect(wrapper.find('NoteListEmptyItem').length).toBe(1); 24 | }); 25 | 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /imports/ui/NoteListEmptyItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NoteListEmptyItem = () => { 4 | return ( 5 |

Create a note to get started!

6 | ); 7 | }; 8 | 9 | export default NoteListEmptyItem; 10 | -------------------------------------------------------------------------------- /imports/ui/NoteListHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import { createContainer } from 'meteor/react-meteor-data'; 4 | import { Session } from 'meteor/session'; 5 | 6 | export const NoteListHeader = (props) => { 7 | return ( 8 |
9 | 16 |
17 | ); 18 | }; 19 | 20 | NoteListHeader.propTypes = { 21 | meteorCall: React.PropTypes.func.isRequired, 22 | Session: React.PropTypes.object.isRequired 23 | }; 24 | 25 | export default createContainer(() => { 26 | return { 27 | meteorCall: Meteor.call, 28 | Session 29 | }; 30 | }, NoteListHeader); 31 | -------------------------------------------------------------------------------- /imports/ui/NoteListHeader.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import expect from 'expect'; 3 | import { mount } from 'enzyme'; 4 | import { Meteor } from 'meteor/meteor'; 5 | 6 | import { NoteListHeader } from './NoteListHeader'; 7 | import { notes } from '../fixtures/fixtures'; 8 | 9 | if (Meteor.isClient) { 10 | describe('NoteListHeader', function () { 11 | let meteorCall; 12 | let Session; 13 | 14 | beforeEach(function () { 15 | meteorCall = expect.createSpy(); 16 | Session = { 17 | set: expect.createSpy() 18 | } 19 | }); 20 | 21 | it('should call meteorCall on click', function () { 22 | const wrapper = mount(); 23 | 24 | wrapper.find('button').simulate('click'); 25 | meteorCall.calls[0].arguments[1](undefined, notes[0]._id); 26 | 27 | expect(meteorCall.calls[0].arguments[0]).toBe('notes.insert'); 28 | expect(Session.set).toHaveBeenCalledWith('selectedNoteId', notes[0]._id); 29 | }); 30 | 31 | it('should not set session for failed insert', function () { 32 | const wrapper = mount(); 33 | 34 | wrapper.find('button').simulate('click'); 35 | meteorCall.calls[0].arguments[1]({}, undefined); 36 | 37 | expect(meteorCall.calls[0].arguments[0]).toBe('notes.insert'); 38 | expect(Session.set).toNotHaveBeenCalled(); 39 | }); 40 | 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /imports/ui/NoteListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | import { Session } from 'meteor/session'; 4 | import { createContainer } from 'meteor/react-meteor-data'; 5 | 6 | export const NoteListItem = (props) => { 7 | const className = props.note.selected ? 'item item--selected' : 'item'; 8 | 9 | return ( 10 |
{ 11 | props.Session.set('selectedNoteId', props.note._id); 12 | }}> 13 |
{ props.note.title || 'Untitled note' }
14 |

{ moment(props.note.updatedAt).format('M/DD/YY') }

15 |
16 | ); 17 | }; 18 | 19 | NoteListItem.propTypes = { 20 | note: React.PropTypes.object.isRequired, 21 | Session: React.PropTypes.object.isRequired 22 | }; 23 | 24 | export default createContainer(() => { 25 | return { Session }; 26 | }, NoteListItem); 27 | -------------------------------------------------------------------------------- /imports/ui/NoteListItem.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import expect from 'expect'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import { mount } from 'enzyme'; 5 | 6 | import { notes } from '../fixtures/fixtures'; 7 | import { NoteListItem } from './NoteListItem'; 8 | 9 | if (Meteor.isClient) { 10 | describe('NoteListItem', function () { 11 | let Session; 12 | 13 | beforeEach(() => { 14 | Session = { 15 | set: expect.createSpy() 16 | }; 17 | }); 18 | 19 | it('should render title and timestamp', function () { 20 | const wrapper = mount( ); 21 | 22 | expect(wrapper.find('h5').text()).toBe(notes[0].title); 23 | expect(wrapper.find('p').text()).toBe('2/03/17'); 24 | }); 25 | 26 | it('should set default title if no title set', function () { 27 | const wrapper = mount( ); 28 | 29 | expect(wrapper.find('h5').text()).toBe('Untitled note'); 30 | }); 31 | 32 | it('should call set on click', function () { 33 | const wrapper = mount( ); 34 | 35 | wrapper.find('div').simulate('click'); 36 | 37 | expect(Session.set).toHaveBeenCalledWith('selectedNoteId', notes[0]._id); 38 | }); 39 | 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /imports/ui/PrivateHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Accounts } from 'meteor/accounts-base'; 3 | import { createContainer } from 'meteor/react-meteor-data'; 4 | import { Session } from 'meteor/session'; 5 | 6 | export const PrivateHeader = (props) => { 7 | const navImageSrc = props.isNavOpen ? '/images/x.svg' : '/images/bars.svg'; 8 | 9 | return ( 10 |
11 |
12 | 13 |

{props.title}

14 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | PrivateHeader.propTypes = { 21 | title: React.PropTypes.string.isRequired, 22 | handleLogout: React.PropTypes.func.isRequired, 23 | isNavOpen: React.PropTypes.bool.isRequired, 24 | handleNavToggle: React.PropTypes.func.isRequired 25 | }; 26 | 27 | export default createContainer(() => { 28 | return { 29 | handleLogout: () => Accounts.logout(), 30 | handleNavToggle: () => Session.set('isNavOpen', !Session.get('isNavOpen')), 31 | isNavOpen: Session.get('isNavOpen') 32 | }; 33 | }, PrivateHeader); 34 | -------------------------------------------------------------------------------- /imports/ui/PrivateHeader.test.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import React from 'react'; 3 | import expect from 'expect'; 4 | import { mount } from 'enzyme'; 5 | 6 | import { PrivateHeader } from './PrivateHeader'; 7 | 8 | if (Meteor.isClient) { 9 | describe('PrivateHeader', function () { 10 | it('should set button text to logout', function () { 11 | const wrapper = mount( {}}/> ) 12 | const buttonText = wrapper.find('button').text(); 13 | 14 | expect(buttonText).toBe('Logout'); 15 | }); 16 | 17 | it('should use title prop as h1 text', function () { 18 | const title = 'Test title here'; 19 | const wrapper = mount( {}}/> ); 20 | const actualTitle = wrapper.find('h1').text(); 21 | 22 | expect(actualTitle).toBe(title); 23 | }); 24 | 25 | // it('should call the function', function () { 26 | // const spy = expect.createSpy(); 27 | // spy(3, 4, 123); 28 | // spy('Andrew'); 29 | // expect(spy).toHaveBeenCalledWith('Andrew'); 30 | // }); 31 | 32 | it('should call handleLogout on click', function () { 33 | const spy = expect.createSpy(); 34 | const wrapper = mount( ); 35 | 36 | wrapper.find('button').simulate('click'); 37 | 38 | expect(spy).toHaveBeenCalled(); 39 | }); 40 | 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /imports/ui/Signup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | import { Accounts } from 'meteor/accounts-base'; 4 | import { createContainer } from 'meteor/react-meteor-data'; 5 | 6 | export class Signup extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | error: '' 11 | }; 12 | } 13 | onSubmit(e) { 14 | e.preventDefault(); 15 | 16 | let email = this.refs.email.value.trim(); 17 | let password = this.refs.password.value.trim(); 18 | 19 | if (password.length < 9) { 20 | return this.setState({error: 'Password must be more than 8 characters long'}); 21 | } 22 | 23 | this.props.createUser({email, password}, (err) => { 24 | if (err) { 25 | this.setState({error: err.reason}); 26 | } else { 27 | this.setState({error: ''}); 28 | } 29 | }); 30 | } 31 | render() { 32 | return ( 33 |
34 |
35 |

Join

36 | 37 | {this.state.error ?

{this.state.error}

: undefined} 38 | 39 |
40 | 41 | 42 | 43 |
44 | 45 | Have an account? 46 |
47 |
48 | ); 49 | } 50 | } 51 | 52 | Signup.propTypes = { 53 | createUser: React.PropTypes.func.isRequired 54 | }; 55 | 56 | export default createContainer(() => { 57 | return { 58 | createUser: Accounts.createUser 59 | }; 60 | }, Signup); 61 | -------------------------------------------------------------------------------- /imports/ui/Signup.test.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import React from 'react'; 3 | import expect from 'expect'; 4 | import { mount } from 'enzyme'; 5 | 6 | import { Signup } from './Signup'; 7 | 8 | if (Meteor.isClient) { 9 | describe('Signup', function () { 10 | 11 | it('should show error messages', function () { 12 | const error = 'This is not working'; 13 | const wrapper = mount( {}}/>); 14 | 15 | wrapper.setState({ error }); 16 | expect(wrapper.find('p').text()).toBe(error); 17 | 18 | wrapper.setState({ error: '' }); 19 | expect(wrapper.find('p').length).toBe(0); 20 | }); 21 | 22 | it('should call createUser with the form data', function () { 23 | const email = 'andrew@test.com'; 24 | const password = 'password123'; 25 | const spy = expect.createSpy(); 26 | const wrapper = mount(); 27 | 28 | wrapper.ref('email').node.value = email; 29 | wrapper.ref('password').node.value = password; 30 | wrapper.find('form').simulate('submit'); 31 | 32 | expect(spy.calls[0].arguments[0]).toEqual({ email, password }); 33 | }); 34 | 35 | it('should set error if short password', function () { 36 | const email = 'andrew@test.com'; 37 | const password = '123 '; 38 | const spy = expect.createSpy(); 39 | const wrapper = mount(); 40 | 41 | wrapper.ref('email').node.value = email; 42 | wrapper.ref('password').node.value = password; 43 | wrapper.find('form').simulate('submit'); 44 | 45 | expect(wrapper.state('error').length).toBeGreaterThan(0); 46 | }); 47 | 48 | it('should set createUser callback errors', function () { 49 | const password = 'password123!'; 50 | const reason = 'This is why it failed'; 51 | const spy = expect.createSpy(); 52 | const wrapper = mount(); 53 | 54 | wrapper.ref('password').node.value = password; 55 | wrapper.find('form').simulate('submit'); 56 | 57 | spy.calls[0].arguments[1]({ reason }); 58 | expect(wrapper.state('error')).toBe(reason); 59 | 60 | spy.calls[0].arguments[1](); 61 | expect(wrapper.state('error').length).toBe(0); 62 | }); 63 | 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "short-lnk", 3 | "private": true, 4 | "scripts": { 5 | "start": "meteor run", 6 | "test": "meteor test --driver-package=practicalmeteor:mocha" 7 | }, 8 | "dependencies": { 9 | "babel-runtime": "6.18.0", 10 | "meteor-node-stubs": "~0.2.0", 11 | "moment": "^2.17.1", 12 | "react": "^15.4.1", 13 | "react-addons-pure-render-mixin": "^15.4.1", 14 | "react-dom": "^15.4.1", 15 | "react-router": "^3.0.0", 16 | "simpl-schema": "0.0.3" 17 | }, 18 | "engines": { 19 | "node": "4.6.2" 20 | }, 21 | "devDependencies": { 22 | "enzyme": "^2.7.1", 23 | "expect": "^1.20.2", 24 | "react-addons-test-utils": "^15.4.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/images/bars.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | bars 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewjmead/notes-meteor-course/29deffc68f096334988727dcf447dfa6435419e8/public/images/favicon.png -------------------------------------------------------------------------------- /public/images/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | x 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Notes App 2 | 3 | This is a notes application built on Meteor and created for my Udemy course. 4 | 5 | ## Getting Started 6 | 7 | This app requires you to have Meteor installed on your machine. Then you can clone the repo and run the following: 8 | 9 | ``` 10 | meteor npm install 11 | ``` 12 | 13 | ``` 14 | meteor 15 | ``` 16 | 17 | ## Running the Tests 18 | 19 | Running the tests is easy. All you have to do is run the following command and view the reporter at localhost port 3000. 20 | 21 | ``` 22 | npm test 23 | ``` 24 | -------------------------------------------------------------------------------- /server/main.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { WebApp } from 'meteor/webapp'; 3 | 4 | import '../imports/api/users'; 5 | import '../imports/api/notes'; 6 | import '../imports/startup/simple-schema-configuration.js'; 7 | 8 | Meteor.startup(() => { 9 | 10 | }); 11 | --------------------------------------------------------------------------------