Repository: r-park/todo-redux-saga Branch: master Commit: d693f0f757f0 Files: 72 Total size: 47.3 KB Directory structure: gitextract_if41cgni/ ├── .firebaserc ├── .gitignore ├── LICENSE ├── README.md ├── circle.yml ├── firebase.json ├── firebase.rules.json ├── package.json ├── public/ │ ├── index.html │ └── manifest.json └── src/ ├── auth/ │ ├── actions.js │ ├── auth.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ └── selectors.js ├── firebase/ │ ├── config.js │ ├── firebase-list.js │ ├── firebase.js │ └── index.js ├── history.js ├── index.js ├── reducers.js ├── register-service-worker.js ├── sagas.js ├── store.js ├── tasks/ │ ├── actions.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ ├── selectors.js │ ├── task-list.js │ └── task.js └── views/ ├── app/ │ ├── app.js │ └── index.js ├── components/ │ ├── button/ │ │ ├── button.js │ │ ├── button.scss │ │ ├── button.spec.js │ │ └── index.js │ ├── github-logo/ │ │ ├── github-logo.js │ │ └── index.js │ ├── header/ │ │ ├── header.js │ │ ├── header.scss │ │ └── index.js │ ├── icon/ │ │ ├── icon.js │ │ ├── icon.spec.js │ │ └── index.js │ ├── require-auth-route/ │ │ ├── index.js │ │ └── require-auth-route.js │ ├── require-unauth-route/ │ │ ├── index.js │ │ └── require-unauth-route.js │ ├── task-filters/ │ │ ├── index.js │ │ ├── task-filters.js │ │ └── task-filters.scss │ ├── task-form/ │ │ ├── index.js │ │ ├── task-form.js │ │ └── task-form.scss │ ├── task-item/ │ │ ├── index.js │ │ ├── task-item.js │ │ └── task-item.scss │ └── task-list/ │ ├── index.js │ ├── task-list.js │ └── task-list.scss ├── pages/ │ ├── sign-in/ │ │ ├── index.js │ │ ├── sign-in-page.js │ │ └── sign-in-page.scss │ └── tasks/ │ ├── index.js │ └── tasks-page.js └── styles/ ├── _grid.scss ├── _settings.scss ├── _shared.scss └── styles.scss ================================================ FILE CONTENTS ================================================ ================================================ FILE: .firebaserc ================================================ { "projects": { "default": "todo-redux-saga" } } ================================================ FILE: .gitignore ================================================ #====================================== # Directories #-------------------------------------- build/ dist/ coverage/ node_modules/ tmp/ #====================================== # Extensions #-------------------------------------- *.css *.gz *.local *.log *.rar *.tar *.zip #====================================== # IDE generated #-------------------------------------- .idea/ .project *.iml #====================================== # OS generated #-------------------------------------- __MACOSX/ .DS_Store Thumbs.db ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2016 Richard Park (objectiv@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![CircleCI](https://circleci.com/gh/r-park/todo-redux-saga.svg?style=shield&circle-token=dc7e150ab97aab05db8f8da4b5874488bf8da0c6)](https://circleci.com/gh/r-park/todo-redux-saga) # A simple Todo app example built with [Create React App](https://github.com/facebookincubator/create-react-app), [React Redux](https://github.com/reactjs/react-redux), [Redux Saga](https://github.com/redux-saga/redux-saga), and Firebase Try the demo at todo-redux-saga.firebaseapp.com. ## Stack - Create React App - React Redux - React Router - React Router Redux - Redux Saga - Redux Devtools Extension for Chrome - Firebase SDK with OAuth authentication - Immutable - Reselect - SASS ## Quick Start ```shell $ git clone https://github.com/r-park/todo-redux-saga.git $ cd todo-redux-saga $ npm install $ npm start ``` ## Deploying to Firebase #### Prerequisites: - Create a free Firebase account at https://firebase.google.com - Create a project from your [Firebase account console](https://console.firebase.google.com) - Configure the authentication providers for your Firebase project from your Firebase account console #### Configure this app with your project-specific details: ```json // .firebaserc { "projects": { "default": "your-project-id" } } ``` ```javascript // src/firebase/config.js export const firebaseConfig = { apiKey: 'your api key', authDomain: 'your-project-id.firebaseapp.com', databaseURL: 'https://your-project-id.firebaseio.com', storageBucket: 'your-project-id.appspot.com' }; ``` #### Install firebase-tools: ```shell $ npm install -g firebase-tools ``` #### Build and deploy the app: ```shell $ npm run build $ firebase login $ firebase use default $ firebase deploy ``` ## NPM Commands |Script|Description| |---|---| |`npm start`|Start webpack development server @ `localhost:3000`| |`npm run build`|Build the application to `./build` directory| |`npm test`|Test the application; watch for changes and retest| ================================================ FILE: circle.yml ================================================ machine: node: version: 8.1 dependencies: pre: - rm -rf node_modules test: override: - npm run build - npm test deployment: production: branch: master commands: - ./node_modules/.bin/firebase deploy --token $FIREBASE_TOKEN ================================================ FILE: firebase.json ================================================ { "database": { "rules": "firebase.rules.json" }, "hosting": { "public": "build", "headers": [ { "source": "**/*", "headers": [ {"key": "X-Content-Type-Options", "value": "nosniff"}, {"key": "X-Frame-Options", "value": "DENY"}, {"key": "X-UA-Compatible", "value": "ie=edge"}, {"key": "X-XSS-Protection", "value": "1; mode=block"} ] }, { "source": "**/*.@(css|html|js|map)", "headers": [ {"key": "Cache-Control", "value": "max-age=3600"} ] } ], "rewrites": [ {"source": "**", "destination": "/index.html"} ] } } ================================================ FILE: firebase.rules.json ================================================ { "rules": { "tasks": { "$uid": { ".read": "auth !== null && auth.uid === $uid", ".write": "auth !== null && auth.uid === $uid" } } } } ================================================ FILE: package.json ================================================ { "name": "todo-redux-saga", "version": "0.0.0", "description": "Todo app with React, Redux, Redux-Saga, and Firebase", "homepage": "https://todo-redux-saga.firebaseapp.com", "repository": { "type": "git", "url": "git+https://github.com/r-park/todo-redux-saga.git" }, "author": { "name": "Richard Park", "email": "objectiv@gmail.com" }, "license": "MIT", "private": true, "engines": { "node": ">=8.1.4" }, "scripts": { "eject": "react-scripts eject", "build": "run-s build.css build.js", "build.css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/", "build.js": "cross-env NODE_PATH=. react-scripts build", "start": "run-p start.css start.js", "start.css": "npm run build.css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive", "start.js": "cross-env NODE_PATH=. react-scripts start", "test": "cross-env NODE_PATH=. react-scripts test --env=jsdom", "test.ci": "cross-env CI=true NODE_PATH=. react-scripts test --env=jsdom" }, "dependencies": { "classnames": "^2.2.5", "firebase": "^4.1.3", "history": "^4.6.3", "immutable": "^3.8.1", "prop-types": "^15.5.10", "react": "^15.6.1", "react-dom": "^15.6.1", "react-redux": "^5.0.5", "react-router": "^4.1.1", "react-router-dom": "^4.1.1", "react-router-redux": "^5.0.0-alpha.6", "react-scripts": "1.0.10", "redux": "^3.7.1", "redux-saga": "^0.15.4", "reselect": "^3.0.1" }, "devDependencies": { "cross-env": "^5.0.1", "enzyme": "^2.9.1", "firebase-tools": "^3.9.1", "minx": "r-park/minx.git", "node-sass-chokidar": "0.0.3", "npm-run-all": "^4.0.2", "react-test-renderer": "^15.6.1" } } ================================================ FILE: public/index.html ================================================ Todo Redux Saga
================================================ FILE: public/manifest.json ================================================ { "short_name": "Todo Redux Saga", "name": "Todo app with React, Redux, Redux-Saga, and Firebase", "icons": [ { "src": "favicon.ico", "sizes": "192x192", "type": "image/png" } ], "start_url": "./index.html", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: src/auth/actions.js ================================================ import firebase from 'firebase/app'; export const authActions = { SIGN_IN: 'SIGN_IN', SIGN_IN_FAILED: 'SIGN_IN_FAILED', SIGN_IN_FULFILLED: 'SIGN_IN_FULFILLED', SIGN_OUT: 'SIGN_OUT', SIGN_OUT_FAILED: 'SIGN_OUT_FAILED', SIGN_OUT_FULFILLED: 'SIGN_OUT_FULFILLED', signIn: authProvider => ({ type: authActions.SIGN_IN, payload: {authProvider} }), signInFailed: error => ({ type: authActions.SIGN_IN_FAILED, payload: {error} }), signInFulfilled: authUser => ({ type: authActions.SIGN_IN_FULFILLED, payload: {authUser} }), signInWithGithub: () => authActions.signIn( new firebase.auth.GithubAuthProvider() ), signInWithGoogle: () => authActions.signIn( new firebase.auth.GoogleAuthProvider() ), signInWithTwitter: () => authActions.signIn( new firebase.auth.TwitterAuthProvider() ), signOut: () => ({ type: authActions.SIGN_OUT }), signOutFailed: error => ({ type: authActions.SIGN_OUT_FAILED, payload: {error} }), signOutFulfilled: () => ({ type: authActions.SIGN_OUT_FULFILLED }) }; ================================================ FILE: src/auth/auth.js ================================================ import { firebaseAuth } from 'src/firebase'; import { authActions } from './actions'; export function initAuth(dispatch) { return new Promise((resolve, reject) => { const unsubscribe = firebaseAuth.onAuthStateChanged( authUser => { if (authUser) { dispatch(authActions.signInFulfilled(authUser)); } resolve(); unsubscribe(); }, error => reject(error) ); }); } ================================================ FILE: src/auth/index.js ================================================ export { authActions } from './actions'; export { initAuth } from './auth'; export { authReducer } from './reducer'; export { authSagas } from './sagas'; export { getAuth, isAuthenticated } from './selectors'; ================================================ FILE: src/auth/reducer.js ================================================ import { Record } from 'immutable'; import { authActions } from './actions'; export const AuthState = new Record({ authenticated: false, uid: null, user: null }); export function authReducer(state = new AuthState(), {payload, type}) { switch (type) { case authActions.SIGN_IN_FULFILLED: return state.merge({ authenticated: true, uid: payload.uid, user: payload }); case authActions.SIGN_OUT_FULFILLED: return state.merge({ authenticated: false, uid: null, user: null }); default: return state; } } ================================================ FILE: src/auth/sagas.js ================================================ import { call, fork, put, take } from 'redux-saga/effects'; import { firebaseAuth } from 'src/firebase'; import history from 'src/history'; import { authActions } from './actions'; function* signIn(authProvider) { try { const authData = yield call([firebaseAuth, firebaseAuth.signInWithPopup], authProvider); yield put(authActions.signInFulfilled(authData.user)); yield history.push('/'); } catch (error) { yield put(authActions.signInFailed(error)); } } function* signOut() { try { yield call([firebaseAuth, firebaseAuth.signOut]); yield put(authActions.signOutFulfilled()); yield history.replace('/sign-in'); } catch (error) { yield put(authActions.signOutFailed(error)); } } //===================================== // WATCHERS //------------------------------------- function* watchSignIn() { while (true) { let { payload } = yield take(authActions.SIGN_IN); yield fork(signIn, payload.authProvider); } } function* watchSignOut() { while (true) { yield take(authActions.SIGN_OUT); yield fork(signOut); } } //===================================== // AUTH SAGAS //------------------------------------- export const authSagas = [ fork(watchSignIn), fork(watchSignOut) ]; ================================================ FILE: src/auth/selectors.js ================================================ import { createSelector } from 'reselect'; export function isAuthenticated(state) { return state.auth.authenticated; } //===================================== // MEMOIZED SELECTORS //------------------------------------- export const getAuth = createSelector( state => state.auth, auth => auth.toJS() ); ================================================ FILE: src/firebase/config.js ================================================ export const firebaseConfig = { apiKey: 'AIzaSyCUll5AyYba1XL8NDYKZ51RGt90KofQo6c', authDomain: 'todo-redux-saga.firebaseapp.com', databaseURL: 'https://todo-redux-saga.firebaseio.com', storageBucket: 'todo-redux-saga.appspot.com' }; ================================================ FILE: src/firebase/firebase-list.js ================================================ import { firebaseDb } from './firebase'; export class FirebaseList { constructor(actions, modelClass) { this._actions = actions; this._modelClass = modelClass; } get path() { return this._path; } set path(value) { this._path = value; } push(value) { return new Promise((resolve, reject) => { firebaseDb.ref(this.path) .push(value, error => error ? reject(error) : resolve()); }); } remove(key) { return new Promise((resolve, reject) => { firebaseDb.ref(`${this.path}/${key}`) .remove(error => error ? reject(error) : resolve()); }); } update(key, value) { return new Promise((resolve, reject) => { firebaseDb.ref(`${this.path}/${key}`) .update(value, error => error ? reject(error) : resolve()); }); } subscribe(emit) { let ref = firebaseDb.ref(this.path); let initialized = false; let list = []; ref.once('value', () => { initialized = true; emit(this._actions.onLoad(list)); }); ref.on('child_added', snapshot => { if (initialized) { emit(this._actions.onAdd(this.unwrapSnapshot(snapshot))); } else { list.push(this.unwrapSnapshot(snapshot)); } }); ref.on('child_changed', snapshot => { emit(this._actions.onChange(this.unwrapSnapshot(snapshot))); }); ref.on('child_removed', snapshot => { emit(this._actions.onRemove(this.unwrapSnapshot(snapshot))); }); return () => ref.off(); } unwrapSnapshot(snapshot) { let attrs = snapshot.val(); attrs.key = snapshot.key; return new this._modelClass(attrs); } } ================================================ FILE: src/firebase/firebase.js ================================================ import firebase from 'firebase/app'; import 'firebase/auth'; import 'firebase/database'; import { firebaseConfig } from './config'; export const firebaseApp = firebase.initializeApp(firebaseConfig); export const firebaseAuth = firebase.auth(); export const firebaseDb = firebase.database(); ================================================ FILE: src/firebase/index.js ================================================ export { firebaseApp, firebaseAuth, firebaseDb } from './firebase'; export { FirebaseList } from './firebase-list'; ================================================ FILE: src/history.js ================================================ import createHistory from 'history/createBrowserHistory'; export default createHistory(); ================================================ FILE: src/index.js ================================================ import './views/styles/styles.css'; import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { ConnectedRouter } from 'react-router-redux'; import { initAuth } from './auth'; import history from './history'; import configureStore from './store'; import App from './views/app'; import registerServiceWorker from './register-service-worker'; const store = configureStore(); const rootElement = document.getElementById('root'); function render(Component) { ReactDOM.render(
, rootElement ); } if (module.hot) { module.hot.accept('./views/app', () => { render(require('./views/app').default); }) } registerServiceWorker(); initAuth(store.dispatch) .then(() => render(App)) .catch(error => console.error(error)); ================================================ FILE: src/reducers.js ================================================ import { combineReducers } from 'redux'; import { routerReducer } from 'react-router-redux'; import { authReducer } from './auth'; import { tasksReducer } from './tasks'; export default combineReducers({ auth: authReducer, routing: routerReducer, tasks: tasksReducer }); ================================================ FILE: src/register-service-worker.js ================================================ // In production, we register a service worker to serve assets from local cache. // This lets the app load faster on subsequent visits in production, and gives // it offline capabilities. However, it also means that developers (and users) // will only see deployed updates on the "N+1" visit to a page, since previously // cached resources are updated in the background. // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. // This link also includes instructions on opting out of this behavior. const isLocalhost = Boolean( window.location.hostname === 'localhost' || // [::1] is the IPv6 localhost address. window.location.hostname === '[::1]' || // 127.0.0.1/8 is considered localhost for IPv4. window.location.hostname.match( /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ ) ); export default function register() { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { // The URL constructor is available in all browsers that support SW. const publicUrl = new URL(process.env.PUBLIC_URL, window.location); if (publicUrl.origin !== window.location.origin) { // Our service worker won't work if PUBLIC_URL is on a different origin // from what our page is served on. This might happen if a CDN is used to // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 return; } window.addEventListener('load', () => { const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; if (!isLocalhost) { // Is not local host. Just register service worker registerValidSW(swUrl); } else { // This is running on localhost. Lets check if a service worker still exists or not. checkValidServiceWorker(swUrl); } }); } } function registerValidSW(swUrl) { navigator.serviceWorker .register(swUrl) .then(registration => { registration.onupdatefound = () => { const installingWorker = registration.installing; installingWorker.onstatechange = () => { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { // At this point, the old content will have been purged and // the fresh content will have been added to the cache. // It's the perfect time to display a "New content is // available; please refresh." message in your web app. console.log('New content is available; please refresh.'); } else { // At this point, everything has been precached. // It's the perfect time to display a // "Content is cached for offline use." message. console.log('Content is cached for offline use.'); } } }; }; }) .catch(error => { console.error('Error during service worker registration:', error); }); } function checkValidServiceWorker(swUrl) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl) .then(response => { // Ensure service worker exists, and that we really are getting a JS file. if ( response.status === 404 || response.headers.get('content-type').indexOf('javascript') === -1 ) { // No service worker found. Probably a different app. Reload the page. navigator.serviceWorker.ready.then(registration => { registration.unregister().then(() => { window.location.reload(); }); }); } else { // Service worker found. Proceed as normal. registerValidSW(swUrl); } }) .catch(() => { console.log( 'No internet connection found. App is running in offline mode.' ); }); } export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then(registration => { registration.unregister(); }); } } ================================================ FILE: src/sagas.js ================================================ import { all } from 'redux-saga/effects' import { authSagas } from './auth'; import { taskSagas } from './tasks'; export default function* sagas() { yield all([ ...authSagas, ...taskSagas ]); } ================================================ FILE: src/store.js ================================================ import { routerMiddleware } from 'react-router-redux'; import { applyMiddleware, compose, createStore } from 'redux'; import createSagaMiddleware from 'redux-saga'; import history from './history'; import reducers from './reducers'; import sagas from './sagas'; export default function configureStore() { const sagaMiddleware = createSagaMiddleware(); let middleware = applyMiddleware(sagaMiddleware, routerMiddleware(history)); if (process.env.NODE_ENV !== 'production') { const devToolsExtension = window.devToolsExtension; if (typeof devToolsExtension === 'function') { middleware = compose(middleware, devToolsExtension()); } } const store = createStore(reducers, middleware); sagaMiddleware.run(sagas); if (module.hot) { module.hot.accept('./reducers', () => { store.replaceReducer(require('./reducers').default); }); } return store; } ================================================ FILE: src/tasks/actions.js ================================================ export const taskActions = { CREATE_TASK: 'CREATE_TASK', CREATE_TASK_FAILED: 'CREATE_TASK_FAILED', CREATE_TASK_FULFILLED: 'CREATE_TASK_FULFILLED', REMOVE_TASK: 'REMOVE_TASK', REMOVE_TASK_FAILED: 'REMOVE_TASK_FAILED', REMOVE_TASK_FULFILLED: 'REMOVE_TASK_FULFILLED', UPDATE_TASK: 'UPDATE_TASK', UPDATE_TASK_FAILED: 'UPDATE_TASK_FAILED', UPDATE_TASK_FULFILLED: 'UPDATE_TASK_FULFILLED', FILTER_TASKS: 'FILTER_TASKS', LOAD_TASKS_FULFILLED: 'LOAD_TASKS_FULFILLED', createTask: title => ({ type: taskActions.CREATE_TASK, payload: {task: {title, completed: false}} }), createTaskFailed: error => ({ type: taskActions.CREATE_TASK_FAILED, payload: {error} }), createTaskFulfilled: task => ({ type: taskActions.CREATE_TASK_FULFILLED, payload: {task} }), removeTask: task => ({ type: taskActions.REMOVE_TASK, payload: {task} }), removeTaskFailed: error => ({ type: taskActions.REMOVE_TASK_FAILED, payload: {error} }), removeTaskFulfilled: task => ({ type: taskActions.REMOVE_TASK_FULFILLED, payload: {task} }), updateTask: (task, changes) => ({ type: taskActions.UPDATE_TASK, payload: {task, changes} }), updateTaskFailed: error => ({ type: taskActions.UPDATE_TASK_FAILED, payload: {error} }), updateTaskFulfilled: task => ({ type: taskActions.UPDATE_TASK_FULFILLED, payload: {task} }), filterTasks: filterType => ({ type: taskActions.FILTER_TASKS, payload: {filterType} }), loadTasksFulfilled: tasks => ({ type: taskActions.LOAD_TASKS_FULFILLED, payload: {tasks} }) }; ================================================ FILE: src/tasks/index.js ================================================ export { taskActions } from './actions'; export { tasksReducer } from './reducer'; export { taskSagas } from './sagas'; export { getVisibleTasks } from './selectors'; ================================================ FILE: src/tasks/reducer.js ================================================ import { List, Record } from 'immutable'; import { taskActions } from './actions'; export const TasksState = new Record({ filter: '', list: new List() }); export function tasksReducer(state = new TasksState(), {payload, type}) { switch (type) { case taskActions.CREATE_TASK_FULFILLED: return state.set('list', state.list.unshift(payload.task)); case taskActions.FILTER_TASKS: return state.set('filter', payload.filterType || ''); case taskActions.LOAD_TASKS_FULFILLED: return state.set('list', new List(payload.tasks.reverse())); case taskActions.REMOVE_TASK_FULFILLED: return state.set('list', state.list.filter(task => { return task.key !== payload.task.key; })); case taskActions.UPDATE_TASK_FULFILLED: return state.set('list', state.list.map(task => { return task.key === payload.task.key ? payload.task : task; })); default: return state; } } ================================================ FILE: src/tasks/sagas.js ================================================ import { LOCATION_CHANGE } from 'react-router-redux'; import { eventChannel } from 'redux-saga'; import { call, cancel, fork, put, take } from 'redux-saga/effects'; import { authActions } from 'src/auth'; import { taskActions } from './actions'; import { taskList } from './task-list'; function subscribe() { return eventChannel(emit => taskList.subscribe(emit)); } function* read() { const channel = yield call(subscribe); while (true) { let action = yield take(channel); yield put(action); } } function* write(context, method, onError, ...params) { try { yield call([context, method], ...params); } catch (error) { yield put(onError(error)); } } const createTask = write.bind(null, taskList, taskList.push, taskActions.createTaskFailed); const removeTask = write.bind(null, taskList, taskList.remove, taskActions.removeTaskFailed); const updateTask = write.bind(null, taskList, taskList.update, taskActions.updateTaskFailed); //===================================== // WATCHERS //------------------------------------- function* watchAuthentication() { while (true) { let { payload } = yield take(authActions.SIGN_IN_FULFILLED); taskList.path = `tasks/${payload.authUser.uid}`; const job = yield fork(read); yield take([authActions.SIGN_OUT_FULFILLED]); yield cancel(job); } } function* watchCreateTask() { while (true) { let { payload } = yield take(taskActions.CREATE_TASK); yield fork(createTask, payload.task); } } function* watchLocationChange() { while (true) { let { payload } = yield take(LOCATION_CHANGE); if (payload.pathname === '/') { const params = new URLSearchParams(payload.search); const filter = params.get('filter'); yield put(taskActions.filterTasks(filter)); } } } function* watchRemoveTask() { while (true) { let { payload } = yield take(taskActions.REMOVE_TASK); yield fork(removeTask, payload.task.key); } } function* watchUpdateTask() { while (true) { let { payload } = yield take(taskActions.UPDATE_TASK); yield fork(updateTask, payload.task.key, payload.changes); } } //===================================== // TASK SAGAS //------------------------------------- export const taskSagas = [ fork(watchAuthentication), fork(watchCreateTask), fork(watchLocationChange), fork(watchRemoveTask), fork(watchUpdateTask) ]; ================================================ FILE: src/tasks/selectors.js ================================================ import { createSelector } from 'reselect'; export function getTasks(state) { return state.tasks; } export function getTaskFilter(state) { return getTasks(state).filter; } export function getTaskList(state) { return getTasks(state).list; } //===================================== // MEMOIZED SELECTORS //------------------------------------- export const getVisibleTasks = createSelector( getTaskFilter, getTaskList, (filter, taskList) => { switch (filter) { case 'active': return taskList.filter(task => !task.completed); case 'completed': return taskList.filter(task => task.completed); default: return taskList; } } ); ================================================ FILE: src/tasks/task-list.js ================================================ import { FirebaseList } from 'src/firebase'; import { taskActions } from './actions'; import { Task } from './task'; export const taskList = new FirebaseList({ onAdd: taskActions.createTaskFulfilled, onChange: taskActions.updateTaskFulfilled, onLoad: taskActions.loadTasksFulfilled, onRemove: taskActions.removeTaskFulfilled }, Task); ================================================ FILE: src/tasks/task.js ================================================ import { Record } from 'immutable'; export const Task = new Record({ completed: false, key: null, title: null }); ================================================ FILE: src/views/app/app.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { authActions, getAuth } from 'src/auth'; import Header from '../components/header'; import RequireAuthRoute from '../components/require-auth-route'; import RequireUnauthRoute from '../components/require-unauth-route'; import SignInPage from '../pages/sign-in'; import TasksPage from '../pages/tasks'; const App = ({authenticated, signOut}) => (
); App.propTypes = { authenticated: PropTypes.bool.isRequired, signOut: PropTypes.func.isRequired }; //===================================== // CONNECT //------------------------------------- const mapStateToProps = getAuth; const mapDispatchToProps = { signOut: authActions.signOut }; export default withRouter( connect( mapStateToProps, mapDispatchToProps )(App) ); ================================================ FILE: src/views/app/index.js ================================================ export { default } from './app'; ================================================ FILE: src/views/components/button/button.js ================================================ import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import './button.css'; const Button = ({children, className, onClick, type = 'button'}) => { const cssClasses = classNames('btn', className); return ( ); }; Button.propTypes = { children: PropTypes.node, className: PropTypes.string, onClick: PropTypes.func, type: PropTypes.oneOf(['button', 'reset', 'submit']) }; export default Button; ================================================ FILE: src/views/components/button/button.scss ================================================ @import 'views/styles/shared'; .btn { @include button-base; outline: none; border: 0; padding: 0; overflow: hidden; transform: translate(0, 0); background: transparent; } .btn--icon { border-radius: 40px; padding: 8px; width: 40px; height: 40px; } ================================================ FILE: src/views/components/button/button.spec.js ================================================ import React from 'react'; import { render, shallow } from 'enzyme'; import Button from './button'; describe('Button', () => { it('should render a button with text node', () => { const wrapper = render(); const button = wrapper.find('button'); expect(button.length).toBe(1); expect(button.text()).toBe('Foo'); }); it('should render a button with child element', () => { const wrapper = shallow(); const button = wrapper.find('button'); expect(button.length).toBe(1); expect(button.contains(Foo)).toBe(true); }); it('should set default className', () => { const wrapper = render( : null}
  • ); Header.propTypes = { authenticated: PropTypes.bool.isRequired, signOut: PropTypes.func.isRequired }; export default Header; ================================================ FILE: src/views/components/header/header.scss ================================================ @import 'views/styles/shared'; .header { padding: 10px 0; height: 60px; overflow: hidden; line-height: 40px; } .header__title { float: left; font-size: rem(14px); font-weight: 400; text-rendering: auto; transform: translate(0,0); &:before { padding-right: 5px; color: #fff; line-height: 20px; } } .header__actions { @include clearfix; float: right; padding: 8px 0; line-height: 24px; li { float: left; list-style: none; &:last-child { margin-left: 12px; padding-left: 12px; border-left: 1px solid #333; } &:first-child { border: none; } } .btn { display: block; margin: 0; color: #999; font-size: rem(14px); line-height: 24px; } .link { display: block; fill: #98999a; transform: translate(0, 0); } .link--github { padding-top: 1px; width: 22px; height: 24px; } } ================================================ FILE: src/views/components/header/index.js ================================================ export { default } from './header'; ================================================ FILE: src/views/components/icon/icon.js ================================================ import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; const Icon = ({className, name}) => { const cssClasses = classNames('material-icons', className); return {name}; }; Icon.propTypes = { className: PropTypes.string, name: PropTypes.string.isRequired }; export default Icon; ================================================ FILE: src/views/components/icon/icon.spec.js ================================================ import React from 'react'; import { render, shallow } from 'enzyme'; import Icon from './icon'; describe('Icon', () => { it('should render an icon', () => { const wrapper = shallow(); expect(wrapper.contains(play)).toBe(true); }); it('should add provided props.className', () => { const wrapper = render(); const icon = wrapper.find('span'); expect(icon.hasClass('material-icons foo bar')).toBe(true); }); }); ================================================ FILE: src/views/components/icon/index.js ================================================ export { default } from './icon'; ================================================ FILE: src/views/components/require-auth-route/index.js ================================================ export { default } from './require-auth-route'; ================================================ FILE: src/views/components/require-auth-route/require-auth-route.js ================================================ import React from 'react'; import { Route, Redirect } from 'react-router-dom' const RequireAuthRoute = ({component: Component, authenticated, ...rest}) => ( { return authenticated ? ( ) : ( ) }} /> ); export default RequireAuthRoute; ================================================ FILE: src/views/components/require-unauth-route/index.js ================================================ export { default } from './require-unauth-route'; ================================================ FILE: src/views/components/require-unauth-route/require-unauth-route.js ================================================ import React from 'react'; import { Route, Redirect } from 'react-router-dom' const RequireUnauthRoute = ({component: Component, authenticated, ...rest}) => ( { return authenticated ? ( ) : ( ) }} /> ); export default RequireUnauthRoute; ================================================ FILE: src/views/components/task-filters/index.js ================================================ export { default } from './task-filters'; ================================================ FILE: src/views/components/task-filters/task-filters.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { NavLink } from 'react-router-dom'; import './task-filters.css'; const TaskFilters = ({filter}) => (
    • !filter} to="/">View All
    • filter === 'active'} to={{pathname: '/', search: '?filter=active'}}>Active
    • filter === 'completed'} to={{pathname: '/', search: '?filter=completed'}}>Completed
    ); TaskFilters.propTypes = { filter: PropTypes.string }; export default TaskFilters; ================================================ FILE: src/views/components/task-filters/task-filters.scss ================================================ @import 'views/styles/shared'; .task-filters { @include clearfix; margin-bottom: 45px; padding-left: 1px; font-size: rem(16px); line-height: 24px; list-style-type: none; @include media-query(540) { margin-bottom: 55px; } li { float: left; &:not(:first-child) { margin-left: 12px; } &:not(:first-child):before { padding-right: 12px; content: '/'; font-weight: 300; } } a { color: #999; text-decoration: none; &.active { color: #fff; } } } ================================================ FILE: src/views/components/task-form/index.js ================================================ export { default } from './task-form'; ================================================ FILE: src/views/components/task-form/task-form.js ================================================ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import './task-form.css'; export class TaskForm extends Component { static propTypes = { handleSubmit: PropTypes.func.isRequired }; constructor() { super(...arguments); this.state = {title: ''}; this.handleChange = this.handleChange.bind(this); this.handleKeyUp = this.handleKeyUp.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } clearInput() { this.setState({title: ''}); } handleChange(event) { this.setState({title: event.target.value}); } handleKeyUp(event) { if (event.keyCode === 27) this.clearInput(); } handleSubmit(event) { event.preventDefault(); const title = this.state.title.trim(); if (title.length) this.props.handleSubmit(title); this.clearInput(); } render() { return (
    ); } } export default TaskForm; ================================================ FILE: src/views/components/task-form/task-form.scss ================================================ @import 'views/styles/shared'; .task-form { margin: 40px 0 10px; @include media-query(540) { margin: 80px 0 20px; } } .task-form__input { outline: none; border: 0; border-bottom: 1px dotted #666; border-radius: 0; padding: 0 0 5px 0; width: 100%; height: 50px; font-family: inherit; font-size: rem(24px); font-weight: 300; color: #fff; background: transparent; @include media-query(540) { height: 61px; font-size: rem(32px); } &::placeholder { color: #999; opacity: 1; // firefox native placeholder style has opacity < 1 } &:focus::placeholder { color: #777; opacity: 1; } // webkit input doesn't inherit font-smoothing from ancestors -webkit-font-smoothing: antialiased; // remove `x` &::-ms-clear { display: none; } } ================================================ FILE: src/views/components/task-item/index.js ================================================ export { default } from './task-item'; ================================================ FILE: src/views/components/task-item/task-item.js ================================================ import React, { Component } from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import Button from '../button'; import Icon from '../icon'; import './task-item.css'; export class TaskItem extends Component { constructor() { super(...arguments); this.state = {editing: false}; this.edit = this.edit.bind(this); this.handleKeyUp = this.handleKeyUp.bind(this); this.remove = this.remove.bind(this); this.save = this.save.bind(this); this.stopEditing = this.stopEditing.bind(this); this.toggleStatus = this.toggleStatus.bind(this); } edit() { this.setState({editing: true}); } handleKeyUp(event) { if (event.keyCode === 13) { this.save(event); } else if (event.keyCode === 27) { this.stopEditing(); } } remove() { this.props.removeTask(this.props.task); } save(event) { if (this.state.editing) { const { task } = this.props; const title = event.target.value.trim(); if (title.length && title !== task.title) { this.props.updateTask(task, {title}); } this.stopEditing(); } } stopEditing() { this.setState({editing: false}); } toggleStatus() { const { task } = this.props; this.props.updateTask(task, {completed: !task.completed}); } renderTitle(task) { return (
    {task.title}
    ); } renderTitleInput(task) { return ( ); } render() { const { editing } = this.state; const { task } = this.props; let containerClasses = classNames('task-item', { 'task-item--completed': task.completed, 'task-item--editing': editing }); return (
    {editing ? this.renderTitleInput(task) : this.renderTitle(task)}
    ); } } TaskItem.propTypes = { removeTask: PropTypes.func.isRequired, task: PropTypes.object.isRequired, updateTask: PropTypes.func.isRequired }; export default TaskItem; ================================================ FILE: src/views/components/task-item/task-item.scss ================================================ @import 'views/styles/shared'; .task-item { display: flex; outline: none; border-bottom: 1px dotted #666; height: 60px; overflow: hidden; color: #fff; font-size: rem(18px); font-weight: 300; @include media-query(540) { font-size: rem(24px); } } .task-item--editing { border-bottom: 1px dotted #ccc; } //===================================== // Cells //------------------------------------- .cell { &:first-child, &:last-child { display: flex; flex: 0 0 auto; align-items: center; } &:first-child { padding-right: 20px; } &:nth-child(2) { flex: 1; padding-right: 30px; overflow: hidden; } } //===================================== // Buttons //------------------------------------- .task-item__button { margin-left: 5px; background: #2a2a2a; &:first-child { margin: 0; } color: #555; &:hover { color: #999; } &:active { background: #262626; } &.active { color: #85bf6b; } } //===================================== // Title (static) //------------------------------------- .task-item__title { display: inline-block; position: relative; max-width: 100%; line-height: 60px; outline: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; &:after { position: absolute; left: 0; bottom: 0; border-top: 2px solid #85bf6b; width: 0; height: 46%; content: ''; } .task-item--completed & { color: #666; } .task-item--completed &:after { width: 100%; } } //===================================== // Title (input) //------------------------------------- .task-item__input { outline: none; border: 0; padding: 0; width: 100%; height: 60px; color: inherit; font: inherit; background: transparent; // hide `x` &::-ms-clear { display: none; } } ================================================ FILE: src/views/components/task-list/index.js ================================================ export { default } from './task-list'; ================================================ FILE: src/views/components/task-list/task-list.js ================================================ import React from 'react'; import { List } from 'immutable'; import PropTypes from 'prop-types'; import TaskItem from '../task-item'; import './task-list.css'; const TaskList = ({removeTask, tasks, updateTask}) => { let taskItems = tasks.map((task, index) => { return ( ); }); return (
    {taskItems}
    ); }; TaskList.propTypes = { removeTask: PropTypes.func.isRequired, tasks: PropTypes.instanceOf(List), updateTask: PropTypes.func.isRequired }; export default TaskList; ================================================ FILE: src/views/components/task-list/task-list.scss ================================================ .task-list { border-top: 1px dotted #666; } ================================================ FILE: src/views/pages/sign-in/index.js ================================================ export { default } from './sign-in-page'; ================================================ FILE: src/views/pages/sign-in/sign-in-page.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { authActions } from 'src/auth'; import Button from 'src/views/components/button'; import './sign-in-page.css'; const SignInPage = ({signInWithGithub, signInWithGoogle, signInWithTwitter}) => { return (

    Sign in

    ); }; SignInPage.propTypes = { signInWithGithub: PropTypes.func.isRequired, signInWithGoogle: PropTypes.func.isRequired, signInWithTwitter: PropTypes.func.isRequired }; //===================================== // CONNECT //------------------------------------- const mapDispatchToProps = { signInWithGithub: authActions.signInWithGithub, signInWithGoogle: authActions.signInWithGoogle, signInWithTwitter: authActions.signInWithTwitter }; export default withRouter( connect( null, mapDispatchToProps )(SignInPage) ); ================================================ FILE: src/views/pages/sign-in/sign-in-page.scss ================================================ @import 'views/styles/shared'; .sign-in { margin-top: 90px; max-width: 300px; } .sign-in__heading { margin-bottom: 36px; font-size: 30px; font-weight: 300; text-align: center; } .sign-in__button { margin-bottom: 10px; border: 1px solid #555; width: 100%; height: 48px; font-family: inherit; font-size: rem(18px); line-height: 48px; color: #999; &:hover { border: 2px solid #aaa; line-height: 46px; } } ================================================ FILE: src/views/pages/tasks/index.js ================================================ export { default } from './tasks-page'; ================================================ FILE: src/views/pages/tasks/tasks-page.js ================================================ import React from 'react'; import { List } from 'immutable'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { taskActions, getVisibleTasks } from 'src/tasks'; import TaskFilters from 'src/views/components/task-filters'; import TaskForm from 'src/views/components/task-form'; import TaskList from 'src/views/components/task-list'; const TasksPage = ({createTask, location, removeTask, tasks, updateTask}) => { const params = new URLSearchParams(location.search); const filter = params.get('filter'); return (
    ); }; TasksPage.propTypes = { createTask: PropTypes.func.isRequired, filterTasks: PropTypes.func.isRequired, location: PropTypes.object.isRequired, removeTask: PropTypes.func.isRequired, tasks: PropTypes.instanceOf(List), updateTask: PropTypes.func.isRequired }; //===================================== // CONNECT //------------------------------------- const mapStateToProps = state => ({ tasks: getVisibleTasks(state) }); const mapDispatchToProps = { createTask: taskActions.createTask, filterTasks: taskActions.filterTasks, removeTask: taskActions.removeTask, updateTask: taskActions.updateTask }; export default withRouter( connect( mapStateToProps, mapDispatchToProps )(TasksPage) ); ================================================ FILE: src/views/styles/_grid.scss ================================================ .g-row { @include grid-row; } .g-col { @include grid-column; width: 100%; } ================================================ FILE: src/views/styles/_settings.scss ================================================ $base-background-color: #222 !default; $base-font-color: #999 !default; $base-font-family: 'aktiv-grotesk-std', Helvetica Neue, Arial, sans-serif !default; $base-font-size: 18px !default; $base-line-height: 24px !default; //===================================== // Grid //------------------------------------- $grid-max-width: 810px !default; ================================================ FILE: src/views/styles/_shared.scss ================================================ @import './settings', 'minx/src/settings', 'minx/src/functions', 'minx/src/mixins'; ================================================ FILE: src/views/styles/styles.scss ================================================ @import './shared', 'minx/src/reset', 'minx/src/elements', './grid'; html { overflow-y: scroll; } body { padding-bottom: 120px; } a { color: inherit; text-decoration: none; } ::selection { background: rgba(200,200,255,.1); } .hide { display: none !important; }