├── src ├── views │ ├── app │ │ ├── index.js │ │ └── app.js │ ├── components │ │ ├── icon │ │ │ ├── index.js │ │ │ ├── icon.js │ │ │ └── icon.spec.js │ │ ├── button │ │ │ ├── index.js │ │ │ ├── button.scss │ │ │ ├── button.js │ │ │ └── button.spec.js │ │ ├── header │ │ │ ├── index.js │ │ │ ├── header.js │ │ │ └── header.scss │ │ ├── task-form │ │ │ ├── index.js │ │ │ ├── task-form.scss │ │ │ ├── task-form.js │ │ │ └── task-form.spec.js │ │ ├── task-item │ │ │ ├── index.js │ │ │ ├── task-item.scss │ │ │ ├── task-item.js │ │ │ └── task-item.spec.js │ │ ├── task-list │ │ │ ├── index.js │ │ │ ├── task-list.scss │ │ │ └── task-list.js │ │ ├── github-logo │ │ │ ├── index.js │ │ │ └── github-logo.js │ │ ├── notification │ │ │ ├── index.js │ │ │ ├── notification.scss │ │ │ ├── notification.js │ │ │ └── notification.spec.js │ │ ├── task-filters │ │ │ ├── index.js │ │ │ ├── task-filters.scss │ │ │ └── task-filters.js │ │ ├── require-auth-route │ │ │ ├── index.js │ │ │ └── require-auth-route.js │ │ └── require-unauth-route │ │ │ ├── index.js │ │ │ └── require-unauth-route.js │ ├── pages │ │ ├── sign-in │ │ │ ├── index.js │ │ │ ├── sign-in-page.scss │ │ │ └── sign-in-page.js │ │ └── tasks │ │ │ ├── index.js │ │ │ └── tasks-page.js │ └── styles │ │ ├── _shared.scss │ │ ├── _grid.scss │ │ ├── _settings.scss │ │ └── styles.scss ├── notification │ ├── action-types.js │ ├── selectors.js │ ├── actions.js │ ├── index.js │ ├── actions.spec.js │ ├── reducer.js │ └── reducer.spec.js ├── history.js ├── firebase │ ├── index.js │ ├── config.js │ ├── firebase.js │ └── firebase-list.js ├── tasks │ ├── task.js │ ├── index.js │ ├── task-list.js │ ├── action-types.js │ ├── selectors.js │ ├── selectors.spec.js │ ├── reducer.js │ ├── actions.js │ └── reducer.spec.js ├── auth │ ├── action-types.js │ ├── index.js │ ├── selectors.js │ ├── auth.js │ ├── reducer.js │ ├── actions.js │ └── reducer.spec.js ├── utils │ ├── create-test-component.js │ └── register-service-worker.js ├── reducers.js ├── store.js └── index.js ├── .firebaserc ├── public ├── favicon.ico ├── manifest.json └── index.html ├── firebase.rules.json ├── circle.yml ├── .gitignore ├── firebase.json ├── LICENSE ├── package.json └── README.md /src/views/app/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './app'; 2 | -------------------------------------------------------------------------------- /src/views/components/icon/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './icon'; 2 | -------------------------------------------------------------------------------- /src/views/components/button/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './button'; 2 | -------------------------------------------------------------------------------- /src/views/components/header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './header'; 2 | -------------------------------------------------------------------------------- /src/views/pages/sign-in/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './sign-in-page'; 2 | -------------------------------------------------------------------------------- /src/views/pages/tasks/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './tasks-page'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-form/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-form'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-item/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-item'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-list/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-list'; 2 | -------------------------------------------------------------------------------- /src/views/components/github-logo/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './github-logo'; 2 | -------------------------------------------------------------------------------- /src/views/components/notification/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './notification'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-filters/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-filters'; 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "firebase-todo-react-redux" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r-park/todo-react-redux/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/notification/action-types.js: -------------------------------------------------------------------------------- 1 | export const DISMISS_NOTIFICATION = 'DISMISS_NOTIFICATION'; 2 | -------------------------------------------------------------------------------- /src/views/components/require-auth-route/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './require-auth-route'; 2 | -------------------------------------------------------------------------------- /src/views/components/require-unauth-route/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './require-unauth-route'; 2 | -------------------------------------------------------------------------------- /src/notification/selectors.js: -------------------------------------------------------------------------------- 1 | export function getNotification(state) { 2 | return state.notification; 3 | } 4 | -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import createHistory from 'history/createBrowserHistory'; 2 | 3 | 4 | export default createHistory(); 5 | -------------------------------------------------------------------------------- /src/views/styles/_shared.scss: -------------------------------------------------------------------------------- 1 | @import 2 | './settings', 3 | 'minx/src/settings', 4 | 'minx/src/functions', 5 | 'minx/src/mixins'; 6 | -------------------------------------------------------------------------------- /src/views/styles/_grid.scss: -------------------------------------------------------------------------------- 1 | .g-row { 2 | @include grid-row; 3 | } 4 | 5 | .g-col { 6 | @include grid-column; 7 | width: 100%; 8 | } 9 | -------------------------------------------------------------------------------- /src/firebase/index.js: -------------------------------------------------------------------------------- 1 | export { firebaseApp, firebaseAuth, firebaseDb } from './firebase'; 2 | export { FirebaseList } from './firebase-list'; 3 | -------------------------------------------------------------------------------- /src/views/components/task-list/task-list.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .task-list { 5 | border-top: 1px dotted #666; 6 | } 7 | -------------------------------------------------------------------------------- /src/tasks/task.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | 4 | export const Task = new Record({ 5 | completed: false, 6 | key: null, 7 | title: null 8 | }); 9 | -------------------------------------------------------------------------------- /src/notification/actions.js: -------------------------------------------------------------------------------- 1 | import { DISMISS_NOTIFICATION } from './action-types'; 2 | 3 | 4 | export function dismissNotification() { 5 | return { 6 | type: DISMISS_NOTIFICATION 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/action-types.js: -------------------------------------------------------------------------------- 1 | export const INIT_AUTH = 'INIT_AUTH'; 2 | 3 | export const SIGN_IN_ERROR = 'SIGN_IN_ERROR'; 4 | export const SIGN_IN_SUCCESS = 'SIGN_IN_SUCCESS'; 5 | 6 | export const SIGN_OUT_SUCCESS = 'SIGN_OUT_SUCCESS'; 7 | -------------------------------------------------------------------------------- /firebase.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "tasks": { 4 | "$uid": { 5 | ".read": "auth !== null && auth.uid === $uid", 6 | ".write": "auth !== null && auth.uid === $uid" 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/notification/index.js: -------------------------------------------------------------------------------- 1 | import * as notificationActions from './actions'; 2 | 3 | 4 | export { notificationActions }; 5 | export * from './action-types'; 6 | export { notificationReducer } from './reducer'; 7 | export { getNotification } from './selectors'; 8 | -------------------------------------------------------------------------------- /src/auth/index.js: -------------------------------------------------------------------------------- 1 | import * as authActions from './actions'; 2 | 3 | 4 | export { authActions }; 5 | export * from './action-types'; 6 | export { initAuth } from './auth'; 7 | export { authReducer } from './reducer'; 8 | export { getAuth, isAuthenticated } from './selectors'; 9 | -------------------------------------------------------------------------------- /src/tasks/index.js: -------------------------------------------------------------------------------- 1 | import * as tasksActions from './actions'; 2 | 3 | 4 | export { tasksActions }; 5 | export * from './action-types'; 6 | export { tasksReducer } from './reducer'; 7 | export { getTaskFilter, getVisibleTasks } from './selectors'; 8 | export { Task } from './task'; 9 | -------------------------------------------------------------------------------- /src/firebase/config.js: -------------------------------------------------------------------------------- 1 | export const firebaseConfig = { 2 | apiKey: 'AIzaSyBsVVpEDrlNPEmshLcmOuE0FxhjPn0AqMg', 3 | authDomain: 'todo-react-redux.firebaseapp.com', 4 | databaseURL: 'https://todo-react-redux.firebaseio.com', 5 | storageBucket: 'firebase-todo-react-redux.appspot.com' 6 | }; 7 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8.1 4 | 5 | dependencies: 6 | pre: 7 | - rm -rf node_modules 8 | 9 | test: 10 | override: 11 | - npm run build 12 | - npm test 13 | 14 | deployment: 15 | production: 16 | branch: master 17 | commands: 18 | - ./node_modules/.bin/firebase deploy --token $FIREBASE_TOKEN 19 | -------------------------------------------------------------------------------- /src/firebase/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | 3 | import 'firebase/auth'; 4 | import 'firebase/database'; 5 | 6 | import { firebaseConfig } from './config'; 7 | 8 | 9 | export const firebaseApp = firebase.initializeApp(firebaseConfig); 10 | export const firebaseAuth = firebase.auth(); 11 | export const firebaseDb = firebase.database(); 12 | -------------------------------------------------------------------------------- /src/utils/create-test-component.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { findRenderedComponentWithType, renderIntoDocument } from 'react-dom/test-utils'; 3 | 4 | 5 | export function createTestComponent(TestComponent, props) { 6 | return findRenderedComponentWithType( 7 | renderIntoDocument(), 8 | TestComponent 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/views/components/button/button.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .btn { 5 | @include button-base; 6 | outline: none; 7 | border: 0; 8 | padding: 0; 9 | overflow: hidden; 10 | transform: translate(0, 0); 11 | background: transparent; 12 | } 13 | 14 | .btn--icon { 15 | border-radius: 40px; 16 | padding: 8px; 17 | width: 40px; 18 | height: 40px; 19 | } 20 | -------------------------------------------------------------------------------- /src/tasks/task-list.js: -------------------------------------------------------------------------------- 1 | import { FirebaseList } from 'src/firebase'; 2 | import * as taskActions from './actions'; 3 | import { Task } from './task'; 4 | 5 | 6 | export const taskList = new FirebaseList({ 7 | onAdd: taskActions.createTaskSuccess, 8 | onChange: taskActions.updateTaskSuccess, 9 | onLoad: taskActions.loadTasksSuccess, 10 | onRemove: taskActions.removeTaskSuccess 11 | }, Task); 12 | -------------------------------------------------------------------------------- /src/auth/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | 4 | export function isAuthenticated(state) { 5 | return getAuth(state).authenticated; 6 | } 7 | 8 | 9 | //===================================== 10 | // MEMOIZED SELECTORS 11 | //------------------------------------- 12 | 13 | export const getAuth = createSelector( 14 | state => state.auth, 15 | auth => auth.toJS() 16 | ); 17 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Todo React Redux", 3 | "name": "Todo app with React, React Redux, and Firebase", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/views/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | $base-background-color: #222 !default; 2 | $base-font-color: #999 !default; 3 | $base-font-family: 'aktiv-grotesk-std', Helvetica Neue, Arial, sans-serif !default; 4 | $base-font-size: 18px !default; 5 | $base-line-height: 24px !default; 6 | 7 | 8 | //===================================== 9 | // Grid 10 | //------------------------------------- 11 | $grid-max-width: 810px !default; 12 | -------------------------------------------------------------------------------- /src/views/styles/styles.scss: -------------------------------------------------------------------------------- 1 | @import 2 | './shared', 3 | 'minx/src/reset', 4 | 'minx/src/elements', 5 | './grid'; 6 | 7 | 8 | html { 9 | overflow-y: scroll; 10 | } 11 | 12 | body { 13 | padding-bottom: 120px; 14 | } 15 | 16 | a { 17 | color: inherit; 18 | text-decoration: none; 19 | } 20 | 21 | .hide { 22 | display: none !important; 23 | } 24 | 25 | ::selection { 26 | background: rgba(200,200,255,.1); 27 | } 28 | -------------------------------------------------------------------------------- /src/notification/actions.spec.js: -------------------------------------------------------------------------------- 1 | import { DISMISS_NOTIFICATION } from './action-types'; 2 | import { dismissNotification } from './actions'; 3 | 4 | 5 | describe('Notification actions', () => { 6 | describe('dismissNotification', () => { 7 | it('should create DISMISS_NOTIFICATION', () => { 8 | expect(dismissNotification()).toEqual({ 9 | type: DISMISS_NOTIFICATION 10 | }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | import { routerReducer } from 'react-router-redux'; 2 | import { combineReducers } from 'redux'; 3 | import { authReducer } from './auth'; 4 | import { notificationReducer } from './notification'; 5 | import { tasksReducer } from './tasks'; 6 | 7 | 8 | export default combineReducers({ 9 | auth: authReducer, 10 | notification: notificationReducer, 11 | routing: routerReducer, 12 | tasks: tasksReducer 13 | }); 14 | -------------------------------------------------------------------------------- /src/auth/auth.js: -------------------------------------------------------------------------------- 1 | import { firebaseAuth } from 'src/firebase'; 2 | import * as authActions from './actions'; 3 | 4 | 5 | export function initAuth(dispatch) { 6 | return new Promise((resolve, reject) => { 7 | const unsubscribe = firebaseAuth.onAuthStateChanged( 8 | authUser => { 9 | dispatch(authActions.initAuth(authUser)); 10 | unsubscribe(); 11 | resolve(); 12 | }, 13 | error => reject(error) 14 | ); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/views/components/icon/icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | 5 | 6 | const Icon = ({className, name}) => { 7 | const cssClasses = classNames('material-icons', className); 8 | return {name}; 9 | }; 10 | 11 | Icon.propTypes = { 12 | className: PropTypes.string, 13 | name: PropTypes.string.isRequired 14 | }; 15 | 16 | 17 | export default Icon; 18 | -------------------------------------------------------------------------------- /src/views/components/notification/notification.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .notification { 5 | @include clearfix; 6 | position: fixed; 7 | left: 50%; 8 | top: 60px; 9 | margin-left: -100px; 10 | border: 1px solid #aaa; 11 | padding: 10px 15px; 12 | width: 200px; 13 | font-size: rem(16px); 14 | line-height: 24px; 15 | } 16 | 17 | .notification__message { 18 | float: left; 19 | } 20 | 21 | .notification__button { 22 | float: right; 23 | font-size: rem(16px); 24 | line-height: 24px; 25 | text-transform: uppercase; 26 | color: #85bf6b; 27 | } 28 | -------------------------------------------------------------------------------- /src/views/components/require-auth-route/require-auth-route.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from 'react-router-dom' 3 | 4 | 5 | const RequireAuthRoute = ({component: Component, authenticated, ...rest}) => ( 6 | { 9 | return authenticated ? ( 10 | 11 | ) : ( 12 | 16 | ) 17 | }} 18 | /> 19 | ); 20 | 21 | 22 | export default RequireAuthRoute; 23 | -------------------------------------------------------------------------------- /src/views/components/require-unauth-route/require-unauth-route.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from 'react-router-dom' 3 | 4 | 5 | const RequireUnauthRoute = ({component: Component, authenticated, ...rest}) => ( 6 | { 9 | return authenticated ? ( 10 | 14 | ) : ( 15 | 16 | ) 17 | }} 18 | /> 19 | ); 20 | 21 | 22 | export default RequireUnauthRoute; 23 | -------------------------------------------------------------------------------- /src/views/pages/sign-in/sign-in-page.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .sign-in { 5 | margin-top: 90px; 6 | max-width: 300px; 7 | } 8 | 9 | .sign-in__heading { 10 | margin-bottom: 36px; 11 | font-size: 30px; 12 | font-weight: 300; 13 | text-align: center; 14 | } 15 | 16 | .sign-in__button { 17 | margin-bottom: 10px; 18 | border: 1px solid #555; 19 | width: 100%; 20 | height: 48px; 21 | font-family: inherit; 22 | font-size: rem(18px); 23 | line-height: 48px; 24 | color: #999; 25 | 26 | &:hover { 27 | border: 2px solid #aaa; 28 | line-height: 46px; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/tasks/action-types.js: -------------------------------------------------------------------------------- 1 | export const CREATE_TASK_ERROR = 'CREATE_TASK_ERROR'; 2 | export const CREATE_TASK_SUCCESS = 'CREATE_TASK_SUCCESS'; 3 | 4 | export const REMOVE_TASK_ERROR = 'REMOVE_TASK_ERROR'; 5 | export const REMOVE_TASK_SUCCESS = 'REMOVE_TASK_SUCCESS'; 6 | 7 | export const UNDELETE_TASK_ERROR = 'UNDELETE_TASK_ERROR'; 8 | 9 | export const UPDATE_TASK_ERROR = 'UPDATE_TASK_ERROR'; 10 | export const UPDATE_TASK_SUCCESS = 'UPDATE_TASK_SUCCESS'; 11 | 12 | export const FILTER_TASKS = 'FILTER_TASKS'; 13 | export const LOAD_TASKS_SUCCESS = 'LOAD_TASKS_SUCCESS'; 14 | export const UNLOAD_TASKS_SUCCESS = 'UNLOAD_TASKS_SUCCESS'; 15 | -------------------------------------------------------------------------------- /src/views/components/github-logo/github-logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | 4 | export default function GitHubLogo() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/views/components/icon/icon.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, shallow } from 'enzyme'; 3 | import Icon from './icon'; 4 | 5 | 6 | describe('Icon', () => { 7 | it('should render an icon', () => { 8 | const wrapper = shallow(); 9 | expect(wrapper.contains(play)).toBe(true); 10 | }); 11 | 12 | it('should add provided props.className', () => { 13 | const wrapper = render(); 14 | const icon = wrapper.find('span'); 15 | 16 | expect(icon.hasClass('material-icons foo bar')).toBe(true); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/reducer.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import { INIT_AUTH, SIGN_IN_SUCCESS, SIGN_OUT_SUCCESS } from './action-types'; 3 | 4 | 5 | export const AuthState = new Record({ 6 | authenticated: false, 7 | id: null 8 | }); 9 | 10 | 11 | export function authReducer(state = new AuthState(), {payload, type}) { 12 | switch (type) { 13 | case INIT_AUTH: 14 | case SIGN_IN_SUCCESS: 15 | return state.merge({ 16 | authenticated: !!payload, 17 | id: payload ? payload.uid : null 18 | }); 19 | 20 | case SIGN_OUT_SUCCESS: 21 | return new AuthState(); 22 | 23 | default: 24 | return state; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #====================================== 2 | # Directories 3 | #-------------------------------------- 4 | build/ 5 | dist/ 6 | coverage/ 7 | node_modules/ 8 | tmp/ 9 | 10 | 11 | #====================================== 12 | # Extensions 13 | #-------------------------------------- 14 | *.css 15 | *.gz 16 | *.local 17 | *.log 18 | *.rar 19 | *.tar 20 | *.zip 21 | 22 | 23 | #====================================== 24 | # IDE generated 25 | #-------------------------------------- 26 | .idea/ 27 | .project 28 | *.iml 29 | 30 | 31 | #====================================== 32 | # OS generated 33 | #-------------------------------------- 34 | __MACOSX/ 35 | .DS_Store 36 | Thumbs.db 37 | -------------------------------------------------------------------------------- /src/views/components/button/button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import './button.css'; 6 | 7 | 8 | const Button = ({children, className, onClick, type = 'button'}) => { 9 | const cssClasses = classNames('btn', className); 10 | return ( 11 | 14 | ); 15 | }; 16 | 17 | Button.propTypes = { 18 | children: PropTypes.node, 19 | className: PropTypes.string, 20 | onClick: PropTypes.func, 21 | type: PropTypes.oneOf(['button', 'reset', 'submit']) 22 | }; 23 | 24 | 25 | export default Button; 26 | -------------------------------------------------------------------------------- /src/views/components/task-filters/task-filters.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .task-filters { 5 | @include clearfix; 6 | margin-bottom: 45px; 7 | padding-left: 1px; 8 | font-size: rem(16px); 9 | line-height: 24px; 10 | list-style-type: none; 11 | 12 | @include media-query(540) { 13 | margin-bottom: 55px; 14 | } 15 | 16 | li { 17 | float: left; 18 | 19 | &:not(:first-child) { 20 | margin-left: 12px; 21 | } 22 | 23 | &:not(:first-child):before { 24 | padding-right: 12px; 25 | content: '/'; 26 | font-weight: 300; 27 | } 28 | } 29 | 30 | a { 31 | color: #999; 32 | text-decoration: none; 33 | 34 | &.active { 35 | color: #fff; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/views/components/task-filters/task-filters.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { NavLink } from 'react-router-dom'; 4 | 5 | import './task-filters.css'; 6 | 7 | 8 | const TaskFilters = ({filter}) => ( 9 |
    10 |
  • !filter} to="/">View All
  • 11 |
  • filter === 'active'} to={{pathname: '/', search: '?filter=active'}}>Active
  • 12 |
  • filter === 'completed'} to={{pathname: '/', search: '?filter=completed'}}>Completed
  • 13 |
14 | ); 15 | 16 | TaskFilters.propTypes = { 17 | filter: PropTypes.string 18 | }; 19 | 20 | 21 | export default TaskFilters; 22 | -------------------------------------------------------------------------------- /src/notification/reducer.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import { REMOVE_TASK_SUCCESS } from 'src/tasks'; 3 | import { DISMISS_NOTIFICATION } from './action-types'; 4 | 5 | 6 | export const NotificationState = new Record({ 7 | actionLabel: '', 8 | display: false, 9 | message: '' 10 | }); 11 | 12 | 13 | export function notificationReducer(state = new NotificationState(), action) { 14 | switch (action.type) { 15 | case REMOVE_TASK_SUCCESS: 16 | return state.merge({ 17 | actionLabel: 'Undo', 18 | display: true, 19 | message: 'Task deleted' 20 | }); 21 | 22 | case DISMISS_NOTIFICATION: 23 | return new NotificationState(); 24 | 25 | default: 26 | return new NotificationState(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "firebase.rules.json" 4 | }, 5 | 6 | "hosting": { 7 | "public": "build", 8 | "headers": [ 9 | { 10 | "source": "**/*", 11 | "headers": [ 12 | {"key": "X-Content-Type-Options", "value": "nosniff"}, 13 | {"key": "X-Frame-Options", "value": "DENY"}, 14 | {"key": "X-UA-Compatible", "value": "ie=edge"}, 15 | {"key": "X-XSS-Protection", "value": "1; mode=block"} 16 | ] 17 | }, 18 | { 19 | "source": "**/*.@(css|html|js|map)", 20 | "headers": [ 21 | {"key": "Cache-Control", "value": "max-age=3600"} 22 | ] 23 | } 24 | ], 25 | "rewrites": [ 26 | {"source": "**", "destination": "/index.html"} 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Todo React Redux 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/views/components/task-list/task-list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List } from 'immutable'; 3 | import PropTypes from 'prop-types'; 4 | import TaskItem from '../task-item/task-item'; 5 | 6 | 7 | function TaskList({removeTask, tasks, updateTask}) { 8 | let taskItems = tasks.map((task, index) => { 9 | return ( 10 | 16 | ); 17 | }); 18 | 19 | return ( 20 |
21 | {taskItems} 22 |
23 | ); 24 | } 25 | 26 | TaskList.propTypes = { 27 | removeTask: PropTypes.func.isRequired, 28 | tasks: PropTypes.instanceOf(List).isRequired, 29 | updateTask: PropTypes.func.isRequired 30 | }; 31 | 32 | export default TaskList; 33 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { routerMiddleware } from 'react-router-redux'; 2 | import { applyMiddleware, compose, createStore } from 'redux'; 3 | import thunk from 'redux-thunk'; 4 | import history from './history'; 5 | import reducers from './reducers'; 6 | 7 | 8 | export default (initialState = {}) => { 9 | let middleware = applyMiddleware(thunk, routerMiddleware(history)); 10 | 11 | if (process.env.NODE_ENV !== 'production') { 12 | const devToolsExtension = window.devToolsExtension; 13 | if (typeof devToolsExtension === 'function') { 14 | middleware = compose(middleware, devToolsExtension()); 15 | } 16 | } 17 | 18 | const store = createStore(reducers, initialState, middleware); 19 | 20 | if (module.hot) { 21 | module.hot.accept('./reducers', () => { 22 | store.replaceReducer(require('./reducers').default); 23 | }); 24 | } 25 | 26 | return store; 27 | }; 28 | -------------------------------------------------------------------------------- /src/tasks/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | 4 | export function getTasks(state) { 5 | return state.tasks; 6 | } 7 | 8 | export function getTaskList(state) { 9 | return getTasks(state).list; 10 | } 11 | 12 | export function getTaskFilter(state) { 13 | return getTasks(state).filter; 14 | } 15 | 16 | export function getDeletedTask(state) { 17 | return getTasks(state).deleted; 18 | } 19 | 20 | 21 | //===================================== 22 | // MEMOIZED SELECTORS 23 | //------------------------------------- 24 | 25 | export const getVisibleTasks = createSelector( 26 | getTaskList, 27 | getTaskFilter, 28 | (tasks, filter) => { 29 | switch (filter) { 30 | case 'active': 31 | return tasks.filter(task => !task.completed); 32 | 33 | case 'completed': 34 | return tasks.filter(task => task.completed); 35 | 36 | default: 37 | return tasks; 38 | } 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /src/views/components/header/header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Button from '../button'; 4 | import GitHubLogo from '../github-logo'; 5 | 6 | import './header.css'; 7 | 8 | 9 | const Header = ({authenticated, signOut}) => ( 10 |
11 |
12 |
13 |

Todo React Redux

14 | 15 |
    16 | {authenticated ?
  • : null} 17 |
  • 18 | 19 | 20 | 21 |
  • 22 |
23 |
24 |
25 |
26 | ); 27 | 28 | Header.propTypes = { 29 | authenticated: PropTypes.bool.isRequired, 30 | signOut: PropTypes.func.isRequired 31 | }; 32 | 33 | 34 | export default Header; 35 | -------------------------------------------------------------------------------- /src/views/components/task-form/task-form.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .task-form { 5 | margin: 40px 0 10px; 6 | 7 | @include media-query(540) { 8 | margin: 80px 0 20px; 9 | } 10 | } 11 | 12 | .task-form__input { 13 | outline: none; 14 | border: 0; 15 | border-bottom: 1px dotted #666; 16 | border-radius: 0; 17 | padding: 0 0 5px 0; 18 | width: 100%; 19 | height: 50px; 20 | font-family: inherit; 21 | font-size: rem(24px); 22 | font-weight: 300; 23 | color: #fff; 24 | background: transparent; 25 | 26 | @include media-query(540) { 27 | height: 61px; 28 | font-size: rem(32px); 29 | } 30 | 31 | &::placeholder { 32 | color: #999; 33 | opacity: 1; // firefox native placeholder style has opacity < 1 34 | } 35 | 36 | &:focus::placeholder { 37 | color: #777; 38 | opacity: 1; 39 | } 40 | 41 | // webkit input doesn't inherit font-smoothing from ancestors 42 | -webkit-font-smoothing: antialiased; 43 | 44 | // remove `x` 45 | &::-ms-clear { 46 | display: none; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/notification/reducer.spec.js: -------------------------------------------------------------------------------- 1 | import { REMOVE_TASK_SUCCESS } from 'src/tasks/action-types'; 2 | import { DISMISS_NOTIFICATION } from './action-types'; 3 | import { notificationReducer } from './reducer'; 4 | 5 | 6 | describe('Notification reducer', () => { 7 | describe('REMOVE_TASK_SUCCESS', () => { 8 | it('should return correct state', () => { 9 | let nextState = notificationReducer(undefined, { 10 | type: REMOVE_TASK_SUCCESS, 11 | task: {} 12 | }); 13 | 14 | expect(nextState.actionLabel).toBe('Undo'); 15 | expect(nextState.display).toBe(true); 16 | expect(nextState.message).toBe('Task deleted'); 17 | }); 18 | }); 19 | 20 | 21 | describe('DISMISS_NOTIFICATION', () => { 22 | it('should return correct state', () => { 23 | let nextState = notificationReducer(undefined, { 24 | type: DISMISS_NOTIFICATION 25 | }); 26 | 27 | expect(nextState.actionLabel).toBe(''); 28 | expect(nextState.display).toBe(false); 29 | expect(nextState.message).toBe(''); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './views/styles/styles.css'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { Provider } from 'react-redux'; 6 | import { ConnectedRouter } from 'react-router-redux'; 7 | 8 | import { initAuth } from './auth'; 9 | import history from './history'; 10 | import configureStore from './store'; 11 | import registerServiceWorker from './utils/register-service-worker'; 12 | import App from './views/app'; 13 | 14 | 15 | const store = configureStore(); 16 | const rootElement = document.getElementById('root'); 17 | 18 | 19 | function render(Component) { 20 | ReactDOM.render( 21 | 22 | 23 |
24 | 25 |
26 |
27 |
, 28 | rootElement 29 | ); 30 | } 31 | 32 | 33 | if (module.hot) { 34 | module.hot.accept('./views/app', () => { 35 | render(require('./views/app').default); 36 | }) 37 | } 38 | 39 | 40 | registerServiceWorker(); 41 | 42 | 43 | initAuth(store.dispatch) 44 | .then(() => render(App)) 45 | .catch(error => console.error(error)); 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Richard Park 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/tasks/selectors.spec.js: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable'; 2 | import { TasksState } from './reducer'; 3 | import { getVisibleTasks } from './selectors'; 4 | import { Task } from './task'; 5 | 6 | 7 | describe('Tasks selectors', () => { 8 | let tasks; 9 | 10 | beforeEach(() => { 11 | tasks = new TasksState({ 12 | list: new List([ 13 | new Task({completed: false, title: 'task-1'}), 14 | new Task({completed: true, title: 'task-2'}) 15 | ]) 16 | }); 17 | }); 18 | 19 | 20 | describe('getVisibleTasks()', () => { 21 | it('should return list of all tasks', () => { 22 | let taskList = getVisibleTasks({tasks}); 23 | expect(taskList.size).toBe(2); 24 | }); 25 | 26 | it('should return list of active (incomplete) tasks', () => { 27 | tasks = tasks.set('filter', 'active'); 28 | let taskList = getVisibleTasks({tasks}); 29 | 30 | expect(taskList.size).toBe(1); 31 | expect(taskList.get(0).title).toBe('task-1'); 32 | }); 33 | 34 | it('should return list of completed tasks', () => { 35 | tasks = tasks.set('filter', 'completed'); 36 | let taskList = getVisibleTasks({tasks}); 37 | 38 | expect(taskList.size).toBe(1); 39 | expect(taskList.get(0).title).toBe('task-2'); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/views/app/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | 6 | import { authActions, getAuth } from 'src/auth'; 7 | import Header from '../components/header'; 8 | import RequireAuthRoute from '../components/require-auth-route'; 9 | import RequireUnauthRoute from '../components/require-unauth-route'; 10 | import SignInPage from '../pages/sign-in'; 11 | import TasksPage from '../pages/tasks'; 12 | 13 | 14 | const App = ({authenticated, signOut}) => ( 15 |
16 |
20 | 21 |
22 | 23 | 24 |
25 |
26 | ); 27 | 28 | App.propTypes = { 29 | authenticated: PropTypes.bool.isRequired, 30 | signOut: PropTypes.func.isRequired 31 | }; 32 | 33 | 34 | //===================================== 35 | // CONNECT 36 | //------------------------------------- 37 | 38 | const mapStateToProps = getAuth; 39 | 40 | const mapDispatchToProps = { 41 | signOut: authActions.signOut 42 | }; 43 | 44 | export default withRouter( 45 | connect( 46 | mapStateToProps, 47 | mapDispatchToProps 48 | )(App) 49 | ); 50 | -------------------------------------------------------------------------------- /src/views/components/header/header.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .header { 5 | padding: 10px 0; 6 | height: 60px; 7 | overflow: hidden; 8 | line-height: 40px; 9 | } 10 | 11 | .header__title { 12 | display: flex; 13 | align-items: center; 14 | 15 | float: left; 16 | font-size: rem(14px); 17 | font-weight: 400; 18 | line-height: 40px; 19 | text-rendering: auto; 20 | transform: translate(0,0); 21 | 22 | &:before { 23 | display: inline-block; 24 | border: 2px solid #eee; 25 | margin-right: 8px; 26 | border-radius: 100%; 27 | height: 16px; 28 | width: 16px; 29 | content: ' '; 30 | } 31 | } 32 | 33 | .header__actions { 34 | @include clearfix; 35 | float: right; 36 | padding: 8px 0; 37 | line-height: 24px; 38 | 39 | li { 40 | float: left; 41 | list-style: none; 42 | 43 | &:last-child { 44 | margin-left: 12px; 45 | padding-left: 12px; 46 | border-left: 1px solid #333; 47 | } 48 | 49 | &:first-child { 50 | border: none; 51 | } 52 | } 53 | 54 | .btn { 55 | display: block; 56 | margin: 0; 57 | color: #999; 58 | font-size: rem(14px); 59 | line-height: 24px; 60 | } 61 | 62 | .link { 63 | display: block; 64 | fill: #98999a; 65 | transform: translate(0, 0); 66 | } 67 | 68 | .link--github { 69 | padding-top: 1px; 70 | width: 22px; 71 | height: 24px; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/views/pages/sign-in/sign-in-page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | import { authActions } from 'src/auth'; 6 | import Button from 'src/views/components/button'; 7 | 8 | import './sign-in-page.css'; 9 | 10 | 11 | const SignInPage = ({signInWithGithub, signInWithGoogle, signInWithTwitter}) => { 12 | return ( 13 |
14 |
15 |

Sign in

16 | 17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | SignInPage.propTypes = { 25 | signInWithGithub: PropTypes.func.isRequired, 26 | signInWithGoogle: PropTypes.func.isRequired, 27 | signInWithTwitter: PropTypes.func.isRequired 28 | }; 29 | 30 | 31 | //===================================== 32 | // CONNECT 33 | //------------------------------------- 34 | 35 | const mapDispatchToProps = { 36 | signInWithGithub: authActions.signInWithGithub, 37 | signInWithGoogle: authActions.signInWithGoogle, 38 | signInWithTwitter: authActions.signInWithTwitter 39 | }; 40 | 41 | export default withRouter( 42 | connect( 43 | null, 44 | mapDispatchToProps 45 | )(SignInPage) 46 | ); 47 | -------------------------------------------------------------------------------- /src/auth/actions.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase'; 2 | import { firebaseAuth } from 'src/firebase'; 3 | import { 4 | INIT_AUTH, 5 | SIGN_IN_ERROR, 6 | SIGN_IN_SUCCESS, 7 | SIGN_OUT_SUCCESS 8 | } from './action-types'; 9 | 10 | 11 | function authenticate(provider) { 12 | return dispatch => { 13 | firebaseAuth.signInWithPopup(provider) 14 | .then(result => dispatch(signInSuccess(result))) 15 | .catch(error => dispatch(signInError(error))); 16 | }; 17 | } 18 | 19 | 20 | export function initAuth(user) { 21 | return { 22 | type: INIT_AUTH, 23 | payload: user 24 | }; 25 | } 26 | 27 | 28 | export function signInError(error) { 29 | return { 30 | type: SIGN_IN_ERROR, 31 | payload: error 32 | }; 33 | } 34 | 35 | 36 | export function signInSuccess(result) { 37 | return { 38 | type: SIGN_IN_SUCCESS, 39 | payload: result.user 40 | }; 41 | } 42 | 43 | 44 | export function signInWithGithub() { 45 | return authenticate(new firebase.auth.GithubAuthProvider()); 46 | } 47 | 48 | 49 | export function signInWithGoogle() { 50 | return authenticate(new firebase.auth.GoogleAuthProvider()); 51 | } 52 | 53 | 54 | export function signInWithTwitter() { 55 | return authenticate(new firebase.auth.TwitterAuthProvider()); 56 | } 57 | 58 | 59 | export function signOut() { 60 | return dispatch => { 61 | firebaseAuth.signOut() 62 | .then(() => dispatch(signOutSuccess())); 63 | }; 64 | } 65 | 66 | 67 | export function signOutSuccess() { 68 | return { 69 | type: SIGN_OUT_SUCCESS 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/views/components/notification/notification.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './notification.css'; 5 | 6 | 7 | class Notification extends Component { 8 | static propTypes = { 9 | action: PropTypes.func.isRequired, 10 | actionLabel: PropTypes.string.isRequired, 11 | dismiss: PropTypes.func.isRequired, 12 | display: PropTypes.bool.isRequired, 13 | duration: PropTypes.number, 14 | message: PropTypes.string.isRequired 15 | }; 16 | 17 | componentDidMount() { 18 | this.startTimer(); 19 | } 20 | 21 | componentWillReceiveProps(nextProps) { 22 | if (nextProps.display) { 23 | this.startTimer(); 24 | } 25 | } 26 | 27 | componentWillUnmount() { 28 | this.clearTimer(); 29 | } 30 | 31 | clearTimer() { 32 | if (this.timerId) { 33 | clearTimeout(this.timerId); 34 | } 35 | } 36 | 37 | startTimer() { 38 | this.clearTimer(); 39 | this.timerId = setTimeout(() => { 40 | this.props.dismiss(); 41 | }, this.props.duration || 5000); 42 | } 43 | 44 | render() { 45 | return ( 46 |
47 |

this.message = c}>{this.props.message}

48 | 53 |
54 | ); 55 | } 56 | } 57 | 58 | export default Notification; 59 | -------------------------------------------------------------------------------- /src/auth/reducer.spec.js: -------------------------------------------------------------------------------- 1 | import { INIT_AUTH, SIGN_IN_SUCCESS, SIGN_OUT_SUCCESS } from './action-types'; 2 | import { authReducer } from './reducer'; 3 | 4 | 5 | describe('Auth reducer', () => { 6 | describe('INIT_AUTH', () => { 7 | it('should set AuthState.authenticated to false when payload is null', () => { 8 | let state = authReducer(undefined, { 9 | type: INIT_AUTH, 10 | payload: null 11 | }); 12 | 13 | expect(state.authenticated).toBe(false); 14 | expect(state.id).toBe(null); 15 | }); 16 | 17 | it('should set AuthState.authenticated to true when payload provided', () => { 18 | let state = authReducer(undefined, { 19 | type: INIT_AUTH, 20 | payload: {uid: '123'} 21 | }); 22 | 23 | expect(state.authenticated).toBe(true); 24 | expect(state.id).toBe('123'); 25 | }); 26 | }); 27 | 28 | 29 | describe('SIGN_IN_SUCCESS', () => { 30 | it('should set AuthState.authenticated to true', () => { 31 | let state = authReducer(undefined, { 32 | type: SIGN_IN_SUCCESS, 33 | payload: {uid: '123'} 34 | }); 35 | 36 | expect(state.authenticated).toBe(true); 37 | expect(state.id).toBe('123'); 38 | }); 39 | }); 40 | 41 | 42 | describe('SIGN_OUT_SUCCESS', () => { 43 | it('should set AuthState.authenticated to false', () => { 44 | let state = authReducer(undefined, { 45 | type: SIGN_OUT_SUCCESS 46 | }); 47 | 48 | expect(state.authenticated).toBe(false); 49 | expect(state.id).toBe(null); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/tasks/reducer.js: -------------------------------------------------------------------------------- 1 | import { List, Record } from 'immutable'; 2 | import { SIGN_OUT_SUCCESS } from 'src/auth/action-types'; 3 | import { 4 | CREATE_TASK_SUCCESS, 5 | REMOVE_TASK_SUCCESS, 6 | FILTER_TASKS, 7 | LOAD_TASKS_SUCCESS, 8 | UPDATE_TASK_SUCCESS 9 | } from './action-types'; 10 | 11 | 12 | export const TasksState = new Record({ 13 | deleted: null, 14 | filter: '', 15 | list: new List(), 16 | previous: null 17 | }); 18 | 19 | 20 | export function tasksReducer(state = new TasksState(), {payload, type}) { 21 | switch (type) { 22 | case CREATE_TASK_SUCCESS: 23 | return state.merge({ 24 | deleted: null, 25 | previous: null, 26 | list: state.deleted && state.deleted.key === payload.key ? 27 | state.previous : 28 | state.list.unshift(payload) 29 | }); 30 | 31 | case REMOVE_TASK_SUCCESS: 32 | return state.merge({ 33 | deleted: payload, 34 | previous: state.list, 35 | list: state.list.filter(task => task.key !== payload.key) 36 | }); 37 | 38 | case FILTER_TASKS: 39 | return state.set('filter', payload.filterType || ''); 40 | 41 | case LOAD_TASKS_SUCCESS: 42 | return state.set('list', new List(payload.reverse())); 43 | 44 | case UPDATE_TASK_SUCCESS: 45 | return state.merge({ 46 | deleted: null, 47 | previous: null, 48 | list: state.list.map(task => { 49 | return task.key === payload.key ? payload : task; 50 | }) 51 | }); 52 | 53 | case SIGN_OUT_SUCCESS: 54 | return new TasksState(); 55 | 56 | default: 57 | return state; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/views/components/task-form/task-form.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './task-form.css'; 5 | 6 | 7 | export class TaskForm extends Component { 8 | static propTypes = { 9 | handleSubmit: PropTypes.func.isRequired 10 | }; 11 | 12 | constructor() { 13 | super(...arguments); 14 | 15 | this.state = {title: ''}; 16 | 17 | this.handleChange = this.handleChange.bind(this); 18 | this.handleKeyUp = this.handleKeyUp.bind(this); 19 | this.handleSubmit = this.handleSubmit.bind(this); 20 | } 21 | 22 | clearInput() { 23 | this.setState({title: ''}); 24 | } 25 | 26 | handleChange(event) { 27 | this.setState({title: event.target.value}); 28 | } 29 | 30 | handleKeyUp(event) { 31 | if (event.keyCode === 27) this.clearInput(); 32 | } 33 | 34 | handleSubmit(event) { 35 | event.preventDefault(); 36 | const title = this.state.title.trim(); 37 | if (title.length) this.props.handleSubmit(title); 38 | this.clearInput(); 39 | } 40 | 41 | render() { 42 | return ( 43 |
44 | this.titleInput = e} 53 | type="text" 54 | value={this.state.title} 55 | /> 56 |
57 | ); 58 | } 59 | } 60 | 61 | 62 | export default TaskForm; 63 | -------------------------------------------------------------------------------- /src/views/components/button/button.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, shallow } from 'enzyme'; 3 | import Button from './button'; 4 | 5 | 6 | describe('Button', () => { 7 | it('should render a button with text node', () => { 8 | const wrapper = render(); 9 | const button = wrapper.find('button'); 10 | 11 | expect(button.length).toBe(1); 12 | expect(button.text()).toBe('Foo'); 13 | }); 14 | 15 | it('should render a button with child element', () => { 16 | const wrapper = shallow(); 17 | const button = wrapper.find('button'); 18 | 19 | expect(button.length).toBe(1); 20 | expect(button.contains(Foo)).toBe(true); 21 | }); 22 | 23 | it('should set default className', () => { 24 | const wrapper = render( 102 | 103 | 104 |
105 | {editing ? this.renderTitleInput(task) : this.renderTitle(task)} 106 |
107 | 108 |
109 | 114 | 119 | 124 |
125 | 126 | ); 127 | } 128 | } 129 | 130 | TaskItem.propTypes = { 131 | removeTask: PropTypes.func.isRequired, 132 | task: PropTypes.object.isRequired, 133 | updateTask: PropTypes.func.isRequired 134 | }; 135 | 136 | 137 | export default TaskItem; 138 | -------------------------------------------------------------------------------- /src/utils/register-service-worker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (!isLocalhost) { 36 | // Is not local host. Just register service worker 37 | registerValidSW(swUrl); 38 | } else { 39 | // This is running on localhost. Lets check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/views/components/notification/notification.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, shallow } from 'enzyme'; 3 | import sinon from 'sinon'; 4 | import { createTestComponent } from 'src/utils/create-test-component'; 5 | import Notification from './notification'; 6 | 7 | 8 | describe('Notification', () => { 9 | let props; 10 | 11 | beforeEach(() => { 12 | props = { 13 | action: sinon.spy(), 14 | actionLabel: 'actionLabel', 15 | dismiss: sinon.spy(), 16 | display: true, 17 | message: 'message' 18 | }; 19 | }); 20 | 21 | 22 | describe('component', () => { 23 | let notification; 24 | 25 | afterEach(() => { 26 | // cleanup setTimeout 27 | notification.clearTimer(); 28 | }); 29 | 30 | 31 | describe('mounting', () => { 32 | it('should invoke `startTimer()`', () => { 33 | sinon.spy(Notification.prototype, 'startTimer'); 34 | notification = createTestComponent(Notification, props); 35 | 36 | expect(notification.startTimer.callCount).toBe(1); 37 | 38 | Notification.prototype.startTimer.restore(); 39 | }); 40 | }); 41 | 42 | 43 | describe('unmounting', () => { 44 | it('should invoke `clearTimer()`', () => { 45 | notification = createTestComponent(Notification, props); 46 | sinon.spy(notification, 'clearTimer'); 47 | notification.componentWillUnmount(); 48 | 49 | expect(notification.clearTimer.callCount).toBe(1); 50 | }); 51 | }); 52 | 53 | 54 | describe('receiving new props', () => { 55 | it('should re-start the timer if props.display === true', () => { 56 | notification = createTestComponent(Notification, props); 57 | sinon.spy(notification, 'startTimer'); 58 | notification.componentWillReceiveProps({display: true}); 59 | 60 | expect(notification.startTimer.callCount).toBe(1); 61 | }); 62 | 63 | it('should NOT re-start the timer if props.display === false', () => { 64 | notification = createTestComponent(Notification, props); 65 | sinon.spy(notification, 'startTimer'); 66 | notification.componentWillReceiveProps({display: false}); 67 | 68 | expect(notification.startTimer.callCount).toBe(0); 69 | }); 70 | }); 71 | 72 | 73 | describe('starting the timer', () => { 74 | it('should invoke `clearTimer()`', () => { 75 | notification = createTestComponent(Notification, props); 76 | sinon.spy(notification, 'clearTimer'); 77 | notification.startTimer(); 78 | 79 | expect(notification.clearTimer.callCount).toBe(1); 80 | }); 81 | 82 | it('should clear pre-existing timer (if any)', () => { 83 | notification = createTestComponent(Notification, props); 84 | 85 | expect(notification.timerId).toBeDefined(); 86 | 87 | let timerId = notification.timerId; 88 | notification.startTimer(); 89 | 90 | expect(notification.timerId).not.toBe(timerId); 91 | }); 92 | }); 93 | 94 | 95 | describe('expired timer', () => { 96 | it('should invoke `props.dismiss()`', done => { 97 | props.duration = 10; 98 | notification = createTestComponent(Notification, props); 99 | 100 | setTimeout(() => { 101 | expect(notification.props.dismiss.callCount).toBe(1); 102 | done(); 103 | }, 10); 104 | }); 105 | }); 106 | }); 107 | 108 | 109 | describe('DOM', () => { 110 | let wrapper; 111 | 112 | beforeEach(() => { 113 | wrapper = render(); 114 | }); 115 | 116 | describe('message', () => { 117 | it('should display `props.message`', () => { 118 | const wrapper = render(); 119 | const message = wrapper.find('.notification__message'); 120 | expect(message.length).toBe(1); 121 | expect(message.text()).toBe(props.message); 122 | }); 123 | }); 124 | 125 | describe('button', () => { 126 | it('should be labeled with `props.actionLabel`', () => { 127 | const wrapper = render(); 128 | const button = wrapper.find('.notification__button'); 129 | expect(button.length).toBe(1); 130 | expect(button.text()).toBe(props.actionLabel); 131 | }); 132 | 133 | it('should set onClick handler with `props.action`', () => { 134 | const wrapper = shallow(); 135 | const button = wrapper.find('.notification__button'); 136 | expect(button.prop('onClick')).toBe(props.action); 137 | }); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/views/components/task-form/task-form.spec.js: -------------------------------------------------------------------------------- 1 | import { Simulate } from 'react-dom/test-utils'; 2 | import { findDOMNode } from 'react-dom'; 3 | import sinon from 'sinon'; 4 | import { createTestComponent } from 'src/utils/create-test-component'; 5 | import TaskForm from './task-form'; 6 | 7 | 8 | describe('TaskForm', () => { 9 | let taskForm; 10 | 11 | 12 | beforeEach(() => { 13 | taskForm = createTestComponent(TaskForm, { 14 | handleSubmit: sinon.spy() 15 | }); 16 | }); 17 | 18 | 19 | describe('component', () => { 20 | describe('instantiation:', () => { 21 | it('should set #state.title with an empty string', () => { 22 | expect(taskForm.state.title).toEqual(''); 23 | }); 24 | }); 25 | 26 | describe('#clearInput() method', () => { 27 | it('should set #state.title with an empty string', () => { 28 | taskForm.state.title = 'foo'; 29 | expect(taskForm.state.title).toEqual('foo'); 30 | 31 | taskForm.clearInput(); 32 | expect(taskForm.state.title).toEqual(''); 33 | }); 34 | }); 35 | 36 | 37 | describe('#handleChange() method', () => { 38 | it('should set #state.title with event.target.value', () => { 39 | const event = {target: {value: 'value'}}; 40 | taskForm.handleChange(event); 41 | expect(taskForm.state.title).toEqual(event.target.value); 42 | }); 43 | }); 44 | 45 | 46 | describe('#handleKeyUp() method', () => { 47 | describe('with escape key', () => { 48 | it('should set #state.title with an empty string', () => { 49 | taskForm.state.title = 'foo'; 50 | taskForm.handleKeyUp({keyCode: 27}); 51 | expect(taskForm.state.title).toEqual(''); 52 | }); 53 | }); 54 | }); 55 | 56 | 57 | describe('#handleSubmit() method', () => { 58 | it('should prevent the default action of the event', () => { 59 | const event = {preventDefault: sinon.spy()}; 60 | taskForm.handleSubmit(event); 61 | expect(event.preventDefault.callCount).toEqual(1); 62 | }); 63 | 64 | it('should call taskActions#handleSubmit with #state.title', () => { 65 | const event = {preventDefault: sinon.spy()}; 66 | 67 | taskForm.state.title = 'foo'; 68 | taskForm.handleSubmit(event); 69 | 70 | expect(taskForm.props.handleSubmit.callCount).toEqual(1); 71 | expect(taskForm.props.handleSubmit.calledWith('foo')).toEqual(true); 72 | }); 73 | 74 | it('should set #state.title with an empty string', () => { 75 | const event = {preventDefault: sinon.spy()}; 76 | 77 | taskForm.state.title = 'foo'; 78 | taskForm.handleSubmit(event); 79 | 80 | expect(taskForm.state.title).toEqual(''); 81 | }); 82 | 83 | it('should not save if title evaluates to an empty string', () => { 84 | const event = {preventDefault: sinon.spy()}; 85 | 86 | taskForm.state.title = ''; 87 | taskForm.handleSubmit(event); 88 | 89 | expect(taskForm.props.handleSubmit.callCount).toBe(0); 90 | 91 | taskForm.state.title = ' '; 92 | taskForm.handleSubmit(event); 93 | 94 | expect(taskForm.props.handleSubmit.callCount).toBe(0); 95 | }); 96 | }); 97 | }); 98 | 99 | 100 | describe('DOM', () => { 101 | describe('`keyup` event triggered on text field with escape key', () => { 102 | it('should set #state.title with an empty string', () => { 103 | taskForm.setState({title: 'foo'}); 104 | Simulate.keyUp(taskForm.titleInput, {keyCode: 27}); 105 | expect(taskForm.state.title).toEqual(''); 106 | }); 107 | 108 | it('should set text field value with an empty string', () => { 109 | taskForm.setState({title: 'foo'}); 110 | Simulate.keyUp(taskForm.titleInput, {keyCode: 27}); 111 | expect(taskForm.titleInput.value).toEqual(''); 112 | }); 113 | }); 114 | 115 | 116 | describe('`change` event triggered on text field', () => { 117 | it('should set #state.title with the value from the text field', () => { 118 | taskForm.titleInput.value = 'foo'; 119 | expect(taskForm.state.title).toEqual(''); 120 | Simulate.change(taskForm.titleInput); 121 | expect(taskForm.state.title).toEqual('foo'); 122 | }); 123 | }); 124 | 125 | 126 | describe('`submit` event triggered on form', () => { 127 | it('should prevent the default action of the event', () => { 128 | const event = {preventDefault: sinon.spy()}; 129 | Simulate.submit(findDOMNode(taskForm), event); 130 | expect(event.preventDefault.callCount).toEqual(1); 131 | }); 132 | 133 | it('should set text field value with an empty string', () => { 134 | const event = {preventDefault: sinon.spy()}; 135 | taskForm.setState({title: 'foo'}); 136 | Simulate.submit(findDOMNode(taskForm), event); 137 | expect(taskForm.titleInput.value).toEqual(''); 138 | }); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/views/components/task-item/task-item.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import sinon from 'sinon'; 4 | import { Task } from 'src/tasks'; 5 | import { createTestComponent } from 'src/utils/create-test-component'; 6 | import TaskItem from './task-item'; 7 | 8 | 9 | describe('TaskItem', () => { 10 | let props; 11 | let task; 12 | 13 | 14 | beforeEach(() => { 15 | task = new Task({completed: true, title: 'test'}); 16 | 17 | props = { 18 | task, 19 | removeTask: sinon.spy(), 20 | updateTask: sinon.spy() 21 | }; 22 | }); 23 | 24 | 25 | describe('component', () => { 26 | let taskItem; 27 | 28 | beforeEach(() => { 29 | taskItem = createTestComponent(TaskItem, props); 30 | }); 31 | 32 | 33 | describe('instantiation', () => { 34 | it('should initialize #state.editing to be false', () => { 35 | expect(taskItem.state.editing).toEqual(false); 36 | }); 37 | 38 | it('should initialize #props.task with a Task instance', () => { 39 | expect(taskItem.props.task instanceof Task).toBe(true); 40 | }); 41 | }); 42 | 43 | describe('#delete() method', () => { 44 | it('should call #taskActions.deleteTask', () => { 45 | taskItem.remove(); 46 | expect(taskItem.props.removeTask.callCount).toEqual(1); 47 | expect(taskItem.props.removeTask.calledWith(taskItem.props.task)).toEqual(true); 48 | }); 49 | }); 50 | 51 | describe('#edit() method', () => { 52 | it('should set #state.editing to `true`', () => { 53 | taskItem.edit(); 54 | expect(taskItem.state.editing).toEqual(true); 55 | }); 56 | }); 57 | 58 | describe('#stopEditing() method', () => { 59 | it('should set #state.editing to `false`', () => { 60 | taskItem.state.editing = true; 61 | taskItem.stopEditing(); 62 | expect(taskItem.state.editing).toEqual(false); 63 | }); 64 | }); 65 | 66 | describe('#save() method', () => { 67 | it('should do nothing if not editing', () => { 68 | taskItem.stopEditing = sinon.spy(); 69 | taskItem.state.editing = false; 70 | taskItem.save(); 71 | expect(taskItem.stopEditing.callCount).toEqual(0); 72 | }); 73 | 74 | it('should set #state.editing to `false`', () => { 75 | taskItem.state.editing = true; 76 | taskItem.save({ 77 | target: {value: ''} 78 | }); 79 | expect(taskItem.state.editing).toEqual(false); 80 | }); 81 | 82 | it('should call #taskActions.updateTask', () => { 83 | taskItem.state.editing = true; 84 | taskItem.save({ 85 | target: {value: 'foo'} 86 | }); 87 | expect(taskItem.props.updateTask.callCount).toEqual(1); 88 | expect(taskItem.props.updateTask.args[0][0]).toEqual(task); 89 | expect(taskItem.props.updateTask.args[0][1].title).toEqual('foo'); 90 | }); 91 | }); 92 | 93 | describe('#toggleStatus() method', () => { 94 | it('should call #taskActions.updateTask', () => { 95 | taskItem.toggleStatus({ 96 | target: {checked: true} 97 | }); 98 | 99 | expect(taskItem.props.updateTask.callCount).toEqual(1); 100 | }); 101 | 102 | it('should toggle task.complete', () => { 103 | taskItem.toggleStatus(); 104 | expect(taskItem.props.updateTask.args[0][1].completed).toEqual(!task.completed); 105 | }); 106 | }); 107 | 108 | describe('#handleKeyUp() method', () => { 109 | describe('with enter key', () => { 110 | it('should call #save() with event object', () => { 111 | taskItem.save = sinon.spy(); 112 | taskItem.handleKeyUp({keyCode: 13}); 113 | expect(taskItem.save.callCount).toEqual(1); 114 | }); 115 | }); 116 | 117 | describe('with escape key', () => { 118 | it('should set #state.editing to `false`', () => { 119 | taskItem.state.editing = true; 120 | taskItem.handleKeyUp({keyCode: 27}); 121 | expect(taskItem.state.editing).toEqual(false); 122 | }); 123 | }); 124 | }); 125 | }); 126 | 127 | 128 | describe('DOM', () => { 129 | describe('title', () => { 130 | it('should be rendered as a text input field when editing', () => { 131 | const wrapper = mount(); 132 | wrapper.setState({editing: true}); 133 | 134 | const titleInput = wrapper.find('input'); 135 | const titleText = wrapper.find('.task-item__title'); 136 | 137 | expect(titleInput.length).toBe(1); 138 | expect(titleInput.get(0).value).toBe('test'); 139 | expect(titleText.length).toBe(0); 140 | }); 141 | 142 | it('should be rendered as text when not editing', () => { 143 | const wrapper = mount(); 144 | wrapper.setState({editing: false}); 145 | 146 | const titleInput = wrapper.find('input'); 147 | const titleText = wrapper.find('.task-item__title'); 148 | 149 | expect(titleInput.length).toBe(0); 150 | expect(titleText.length).toBe(1); 151 | expect(titleText.text()).toBe('test'); 152 | }); 153 | }); 154 | 155 | 156 | it('should set `onKeyUp` of input field to be #handleKeyUp()', () => { 157 | const wrapper = mount(); 158 | wrapper.setState({editing: true}); 159 | 160 | const component = wrapper.instance(); 161 | const input = wrapper.find('input'); 162 | expect(input.prop('onKeyUp')).toBe(component.handleKeyUp); 163 | }); 164 | 165 | it('should set `onClick` of status button to be #toggleStatus()', () => { 166 | const wrapper = mount(); 167 | const component = wrapper.instance(); 168 | const buttons = wrapper.find('Button'); 169 | expect(buttons.at(0).prop('onClick')).toBe(component.toggleStatus); 170 | }); 171 | 172 | it('should set `onClick` of edit button to be #edit()', () => { 173 | const wrapper = mount(); 174 | const component = wrapper.instance(); 175 | const buttons = wrapper.find('Button'); 176 | expect(buttons.at(1).prop('onClick')).toBe(component.edit); 177 | }); 178 | 179 | it('should set `onClick` of clear button to be #stopEditing()', () => { 180 | const wrapper = mount(); 181 | const component = wrapper.instance(); 182 | const buttons = wrapper.find('Button'); 183 | expect(buttons.at(2).prop('onClick')).toBe(component.stopEditing); 184 | }); 185 | 186 | it('should set `onClick` of delete button to be #remove()', () => { 187 | const wrapper = mount(); 188 | const component = wrapper.instance(); 189 | const buttons = wrapper.find('Button'); 190 | expect(buttons.at(3).prop('onClick')).toBe(component.remove); 191 | }); 192 | }); 193 | }); 194 | --------------------------------------------------------------------------------